package majordomo import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "slices" "strings" "testing" "gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama" ) func TestParseDSN(t *testing.T) { tests := []struct { raw string want DSN wantErr error }{ { raw: "foreman://test-token-change-me@foreman-m1.orgrimmar.dudenhoeffer.casa", want: DSN{Scheme: "foreman", Token: "test-token-change-me", Host: "foreman-m1.orgrimmar.dudenhoeffer.casa"}, }, { raw: "ollama://my-host.example:11434", want: DSN{Scheme: "ollama", Token: "", Host: "my-host.example:11434"}, }, { raw: "openai://sk-key@api.example.com/v1/", want: DSN{Scheme: "openai", Token: "sk-key", Host: "api.example.com/v1"}, }, {raw: "no-scheme-here", wantErr: ErrInvalidDSN}, {raw: "foreman://token@", wantErr: ErrInvalidDSN}, {raw: "foreman:///", wantErr: ErrInvalidDSN}, } for _, tt := range tests { got, err := ParseDSN(tt.raw) if tt.wantErr != nil { if !errors.Is(err, tt.wantErr) { t.Errorf("ParseDSN(%q) error = %v, want %v", tt.raw, err, tt.wantErr) } continue } if err != nil { t.Errorf("ParseDSN(%q): %v", tt.raw, err) continue } if got != tt.want { t.Errorf("ParseDSN(%q) = %+v, want %+v", tt.raw, got, tt.want) } } } func TestDSNBaseURL(t *testing.T) { d := DSN{Scheme: "foreman", Host: "h.example:8443/base"} if got, want := d.BaseURL(), "https://h.example:8443/base"; got != want { t.Errorf("BaseURL = %q, want %q", got, want) } } // TestLoadEnvForeman covers the required behavior: an LLM_* foreman DSN // defines a named provider that is first-class in Parse and in chains. func TestLoadEnvForeman(t *testing.T) { r := newTestRegistry(t) err := r.LoadEnv(map[string]string{ "LLM_M1": "foreman://test-token-change-me@foreman-m1.orgrimmar.dudenhoeffer.casa", "LLM_M5": "foreman://test-token-change-me@foreman-m5.orgrimmar.dudenhoeffer.casa", }) if err != nil { t.Fatalf("LoadEnv: %v", err) } for _, name := range []string{"m1", "m5"} { p, ok := r.Provider(name) if !ok { t.Fatalf("provider %q not registered", name) } op, ok := p.(*ollama.Provider) if !ok { t.Fatalf("provider %q is %T, want *ollama.Provider (foreman scheme)", name, p) } if op.Name() != name { t.Errorf("provider name = %q, want %q", op.Name(), name) } wantURL := "https://foreman-" + name + ".orgrimmar.dudenhoeffer.casa" if op.BaseURL() != wantURL { t.Errorf("provider %q baseURL = %q, want %q", name, op.BaseURL(), wantURL) } } // Env-defined providers are first-class chain elements alongside // built-ins and aliases. r.RegisterAlias("thinking", "anthropic/opus-4.8") m, err := r.Parse("m5/qwen3:30b,m1/qwen3:30b,thinking") if err != nil { t.Fatalf("Parse: %v", err) } want := []string{"m5/qwen3:30b", "m1/qwen3:30b", "anthropic/opus-4.8"} if got := targetsOf(t, m); !slices.Equal(got, want) { t.Errorf("targets = %v, want %v", got, want) } } func TestLoadEnvNameNormalization(t *testing.T) { r := newTestRegistry(t) if err := r.LoadEnv(map[string]string{"LLM_MY_BOX": "ollama://my-box.example"}); err != nil { t.Fatalf("LoadEnv: %v", err) } if _, ok := r.Provider("my_box"); !ok { t.Error("LLM_MY_BOX should register provider \"my_box\"") } } func TestLoadEnvIgnoresNonLLMVars(t *testing.T) { r := newTestRegistry(t) if err := r.LoadEnv(map[string]string{ "PATH": "/usr/bin", "LLM_": "foreman://x@h", "NOT_LLM_": "foreman://x@h", }); err != nil { t.Fatalf("LoadEnv: %v", err) } if _, ok := r.Provider(""); ok { t.Error("empty-suffix LLM_ var must not register a provider") } } func TestLoadEnvInvalidDSN(t *testing.T) { r := newTestRegistry(t) err := r.LoadEnv(map[string]string{ "LLM_BAD": "not-a-dsn", "LLM_GOOD": "foreman://tok@good.example", }) if !errors.Is(err, ErrInvalidDSN) { t.Errorf("LoadEnv error = %v, want ErrInvalidDSN", err) } // The valid entry still registered. if _, ok := r.Provider("good"); !ok { t.Error("valid LLM_GOOD entry should register despite LLM_BAD failing") } // The invalid entry's error surfaces when the name is used. _, perr := r.Parse("bad/some-model") if perr == nil || !strings.Contains(perr.Error(), "LLM_BAD") { t.Errorf("Parse(bad/...) error = %v, want recorded LLM_BAD load error", perr) } } func TestLoadEnvUnknownScheme(t *testing.T) { r := newTestRegistry(t) err := r.LoadEnv(map[string]string{"LLM_X": "zorp://tok@host.example"}) if !errors.Is(err, ErrUnknownProvider) { t.Errorf("LoadEnv error = %v, want ErrUnknownProvider", err) } if err == nil || !strings.Contains(err.Error(), `"zorp"`) { t.Errorf("error %v should name the unknown scheme", err) } } // TestLazyEnvFallback covers go-llm parity: a provider name that is not // registered resolves through LLM_{UPPER(name)} at Parse time. func TestLazyEnvFallback(t *testing.T) { env := map[string]string{ "LLM_M9": "foreman://lazy-token@foreman-m9.example", "LLM_MY_PROV": "ollama://my-prov.example", } r := New( WithoutEnvProviders(), WithEnvLookup(func(k string) string { return env[k] }), ) m, err := r.Parse("m9/qwen3:30b") if err != nil { t.Fatalf("Parse(m9/...): %v", err) } if got := targetsOf(t, m); !slices.Equal(got, []string{"m9/qwen3:30b"}) { t.Errorf("targets = %v", got) } // The lazily-resolved provider is cached. if _, ok := r.Provider("m9"); !ok { t.Error("lazy env provider should be cached in the registry") } // Hyphenated names map to underscored env vars (go-llm parity). if _, err := r.Parse("my-prov/llama3"); err != nil { t.Errorf("Parse(my-prov/...): %v", err) } } // TestNewLoadsProcessEnv covers the eager scan in New(). func TestNewLoadsProcessEnv(t *testing.T) { t.Setenv("LLM_ENVTEST", "foreman://tok@envtest.example") r := New(WithEnvLookup(func(string) string { return "" })) if _, ok := r.Provider("envtest"); !ok { t.Error("New() should eagerly load LLM_ENVTEST from the process environment") } } // TestEnvForemanChatRoundTrip is the required end-to-end case: an LLM_* // foreman DSN resolves through Parse and serves a real chat over the wire // (TLS test server, since env DSNs always dial https), with the DSN token // arriving as the bearer credential. func TestEnvForemanChatRoundTrip(t *testing.T) { var gotAuth, gotPath string var gotModel string ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotPath = r.URL.Path var body struct { Model string `json:"model"` } _ = json.NewDecoder(r.Body).Decode(&body) gotModel = body.Model _, _ = io.WriteString(w, `{"message":{"role":"assistant","content":"hi from foreman"},"done":true,"done_reason":"stop","prompt_eval_count":2,"eval_count":3}`) })) defer ts.Close() host := strings.TrimPrefix(ts.URL, "https://") r := New( WithoutEnvProviders(), WithEnvLookup(func(string) string { return "" }), WithHTTPClient(ts.Client()), ) if err := r.LoadEnv(map[string]string{"LLM_FM": "foreman://round-trip-token@" + host}); err != nil { t.Fatalf("LoadEnv: %v", err) } m, err := r.Parse("fm/qwen3:30b") if err != nil { t.Fatalf("Parse: %v", err) } resp, err := m.Generate(context.Background(), Request{Messages: []Message{UserText("hello")}}) if err != nil { t.Fatalf("Generate: %v", err) } if resp.Text() != "hi from foreman" { t.Errorf("text = %q", resp.Text()) } if resp.Model != "fm/qwen3:30b" { t.Errorf("resp.Model = %q, want fm/qwen3:30b", resp.Model) } if gotAuth != "Bearer round-trip-token" { t.Errorf("auth = %q, want the DSN token as bearer", gotAuth) } if gotPath != "/api/chat" || gotModel != "qwen3:30b" { t.Errorf("path=%q model=%q", gotPath, gotModel) } }