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>
This commit is contained in:
2026-06-10 12:58:08 +02:00
parent 323558ed72
commit 043249e0e1
31 changed files with 6194 additions and 74 deletions
+64 -9
View File
@@ -1,10 +1,17 @@
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) {
@@ -71,19 +78,16 @@ func TestLoadEnvForeman(t *testing.T) {
if !ok {
t.Fatalf("provider %q not registered", name)
}
sp, ok := p.(*stubProvider)
op, ok := p.(*ollama.Provider)
if !ok {
t.Fatalf("provider %q is %T, want *stubProvider (phase 1)", name, p)
t.Fatalf("provider %q is %T, want *ollama.Provider (foreman scheme)", name, p)
}
if sp.kind != ProviderForeman {
t.Errorf("provider %q kind = %q, want foreman", name, sp.kind)
if op.Name() != name {
t.Errorf("provider name = %q, want %q", op.Name(), name)
}
wantURL := "https://foreman-" + name + ".orgrimmar.dudenhoeffer.casa"
if sp.baseURL != wantURL {
t.Errorf("provider %q baseURL = %q, want %q", name, sp.baseURL, wantURL)
}
if sp.token != "test-token-change-me" {
t.Errorf("provider %q token = %q, want the DSN userinfo", name, sp.token)
if op.BaseURL() != wantURL {
t.Errorf("provider %q baseURL = %q, want %q", name, op.BaseURL(), wantURL)
}
}
@@ -193,3 +197,54 @@ func TestNewLoadsProcessEnv(t *testing.T) {
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)
}
}