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/") { t.Errorf("error = %v, want hint about openai/", 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) } } }