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:
+64
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user