Files
majordomo/env_test.go
T
steve 043249e0e1 feat: OpenAI, Anthropic, and native-Ollama providers + media pipeline
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>
2026-06-10 12:58:08 +02:00

251 lines
7.6 KiB
Go

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)
}
}