043249e0e1
Phase 3: - provider/openai: Chat Completions for OpenAI + compat endpoints (SSE streaming with by-index tool-call assembly, response_format json_schema, legacy max_tokens option, reasoning_effort) - provider/anthropic: Messages API (tool_use/tool_result, GA structured output via output_config.format, full SSE event parser, 529 transient) - provider/ollama: one native /api/chat client behind the ollama, ollama-cloud, and foreman built-ins (presets; NDJSON streaming tolerant of foreman's buffered single-object responses; object tool arguments; format-schema structured output; think mapping) - media/: capability normalization (sniff, downscale, transcode, byte ladder, ErrUnsupported), wired into the chain executor per target with penalty-free advance past incapable elements - registry: real provider + scheme wiring, WithHTTPClient option, required env-foreman TLS chat round-trip test - ADR-0009 multimodal strategy, ADR-0010 tools/structured mapping; README matrix + CLAUDE.md synced Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
222 lines
6.3 KiB
Go
222 lines
6.3 KiB
Go
package majordomo
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
|
)
|
|
|
|
// newTestRegistry returns a registry isolated from the process environment.
|
|
func newTestRegistry(t *testing.T, opts ...RegistryOption) *Registry {
|
|
t.Helper()
|
|
opts = append([]RegistryOption{
|
|
WithoutEnvProviders(),
|
|
WithEnvLookup(func(string) string { return "" }),
|
|
}, opts...)
|
|
return New(opts...)
|
|
}
|
|
|
|
// targetsOf extracts the resolved chain keys from a parsed model.
|
|
func targetsOf(t *testing.T, m Model) []string {
|
|
t.Helper()
|
|
c, ok := m.(*chain)
|
|
if !ok {
|
|
t.Fatalf("Parse returned %T, want *chain", m)
|
|
}
|
|
return c.Targets()
|
|
}
|
|
|
|
func TestParseSingleTarget(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("fp"))
|
|
|
|
m, err := r.Parse("fp/some-model:7b")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
want := []string{"fp/some-model:7b"}
|
|
if got := targetsOf(t, m); !slices.Equal(got, want) {
|
|
t.Errorf("targets = %v, want %v", got, want)
|
|
}
|
|
|
|
resp, err := m.Generate(context.Background(), Request{Messages: []Message{UserText("hi")}})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if resp.Text() == "" {
|
|
t.Error("empty response text")
|
|
}
|
|
if resp.Model != "fp/some-model:7b" {
|
|
t.Errorf("resp.Model = %q, want fp/some-model:7b", resp.Model)
|
|
}
|
|
}
|
|
|
|
func TestParseModelIDIsVerbatim(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("google"))
|
|
r.RegisterProvider(fake.New("ollama-cloud"))
|
|
|
|
// Everything after the first slash, up to the next comma, is the model
|
|
// id: colons and additional slashes pass through untouched.
|
|
for spec, want := range map[string]string{
|
|
"ollama-cloud/minimax-m3:cloud": "ollama-cloud/minimax-m3:cloud",
|
|
"google/models/gemini-3.0-pro": "google/models/gemini-3.0-pro",
|
|
"ollama-cloud/qwen3-coder:480b-cloud": "ollama-cloud/qwen3-coder:480b-cloud",
|
|
} {
|
|
m, err := r.Parse(spec)
|
|
if err != nil {
|
|
t.Fatalf("Parse(%q): %v", spec, err)
|
|
}
|
|
if got := targetsOf(t, m); !slices.Equal(got, []string{want}) {
|
|
t.Errorf("Parse(%q) targets = %v, want [%s]", spec, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseTrailingAliasChain covers the README's flagship example: a chain
|
|
// whose tail is a registered alias, expanded inline.
|
|
func TestParseTrailingAliasChain(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("ollama-cloud"))
|
|
r.RegisterProvider(fake.New("anthropic"))
|
|
r.RegisterProvider(fake.New("openai"))
|
|
r.RegisterAlias("thinking", "openai/gpt-5.5,anthropic/opus-4.8")
|
|
|
|
m, err := r.Parse("ollama-cloud/minimax-m3:cloud,ollama-cloud/kimi-k2.6:cloud,anthropic/opus-4.8,thinking")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
// "thinking" expands inline at the tail; its anthropic/opus-4.8 element
|
|
// is a duplicate of the explicit one and is kept once (first wins).
|
|
want := []string{
|
|
"ollama-cloud/minimax-m3:cloud",
|
|
"ollama-cloud/kimi-k2.6:cloud",
|
|
"anthropic/opus-4.8",
|
|
"openai/gpt-5.5",
|
|
}
|
|
if got := targetsOf(t, m); !slices.Equal(got, want) {
|
|
t.Errorf("targets = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseAliasPositions(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("fp"))
|
|
r.RegisterAlias("mid", "fp/m1,fp/m2")
|
|
|
|
m, err := r.Parse("fp/head,mid,fp/tail")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
want := []string{"fp/head", "fp/m1", "fp/m2", "fp/tail"}
|
|
if got := targetsOf(t, m); !slices.Equal(got, want) {
|
|
t.Errorf("targets = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseNestedAlias(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("fp"))
|
|
r.RegisterAlias("inner", "fp/deep")
|
|
r.RegisterAlias("outer", "inner,fp/shallow")
|
|
|
|
m, err := r.Parse("outer")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
want := []string{"fp/deep", "fp/shallow"}
|
|
if got := targetsOf(t, m); !slices.Equal(got, want) {
|
|
t.Errorf("targets = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseAliasCycle(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterAlias("a", "b")
|
|
r.RegisterAlias("b", "a")
|
|
if _, err := r.Parse("a"); !errors.Is(err, ErrAliasCycle) {
|
|
t.Errorf("Parse(a) error = %v, want ErrAliasCycle", err)
|
|
}
|
|
|
|
r.RegisterAlias("self", "self")
|
|
if _, err := r.Parse("self"); !errors.Is(err, ErrAliasCycle) {
|
|
t.Errorf("Parse(self) error = %v, want ErrAliasCycle", err)
|
|
}
|
|
}
|
|
|
|
func TestParseUnknownAlias(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
if _, err := r.Parse("nonesuch"); !errors.Is(err, ErrUnknownProvider) {
|
|
t.Errorf("error = %v, want ErrUnknownProvider", err)
|
|
}
|
|
}
|
|
|
|
func TestParseBareProviderName(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
_, err := r.Parse("openai")
|
|
if err == nil || !strings.Contains(err.Error(), "openai/<model-id>") {
|
|
t.Errorf("error = %v, want hint about openai/<model-id>", err)
|
|
}
|
|
}
|
|
|
|
func TestParseUnknownProviderMentionsEnvVar(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
_, err := r.Parse("nope/some-model")
|
|
if !errors.Is(err, ErrUnknownProvider) {
|
|
t.Fatalf("error = %v, want ErrUnknownProvider", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "LLM_NOPE") {
|
|
t.Errorf("error %q should mention the LLM_NOPE env var", err)
|
|
}
|
|
}
|
|
|
|
func TestParseEmptySpecs(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
for _, spec := range []string{"", " ", ",", " , ,"} {
|
|
if _, err := r.Parse(spec); !errors.Is(err, ErrEmptySpec) {
|
|
t.Errorf("Parse(%q) error = %v, want ErrEmptySpec", spec, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTrimsWhitespace(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("fp"))
|
|
m, err := r.Parse(" fp/a , fp/b ")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
want := []string{"fp/a", "fp/b"}
|
|
if got := targetsOf(t, m); !slices.Equal(got, want) {
|
|
t.Errorf("targets = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseDeduplicatesElements(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
r.RegisterProvider(fake.New("fp"))
|
|
m, err := r.Parse("fp/a,fp/b,fp/a")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
want := []string{"fp/a", "fp/b"}
|
|
if got := targetsOf(t, m); !slices.Equal(got, want) {
|
|
t.Errorf("targets = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuiltinsResolve(t *testing.T) {
|
|
r := newTestRegistry(t)
|
|
// All built-in provider names resolve even before their client
|
|
// implementations land (stub providers error only on use).
|
|
for _, name := range []string{"openai", "anthropic", "google", "ollama", "ollama-cloud", "foreman"} {
|
|
if _, err := r.Parse(name + "/anything"); err != nil {
|
|
t.Errorf("Parse(%s/anything): %v", name, err)
|
|
}
|
|
}
|
|
}
|