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
+168
View File
@@ -0,0 +1,168 @@
// Package ollama implements majordomo's provider contract over Ollama's
// native chat API (POST {base}/api/chat), targeted at three backends that
// share one wire protocol:
//
// - a local Ollama instance (preset Local: OLLAMA_HOST or
// http://localhost:11434, no auth),
// - Ollama Cloud (preset Cloud: https://ollama.com, bearer key from
// OLLAMA_API_KEY), and
// - foreman, Steve's native-Ollama queue daemon (preset Foreman: explicit
// base URL + bearer token).
//
// Wire surface verified against docs.ollama.com and ollama/ollama
// docs/api.md + api/types.go (June 2026): NDJSON streaming (stream defaults
// true server-side — Generate always sends stream:false explicitly);
// tool_calls carry arguments as a JSON OBJECT (not a string); tool results
// return as {"role":"tool","content",...,"tool_name"}; structured output
// via "format" (a full JSON-schema object); thinking via the bool-or-string
// "think" field; errors as {"error":"message"} with a non-2xx status.
//
// foreman deviation (verified in its source): sync /api/chat does not
// stream — a stream:true request yields ONE buffered application/json
// object. The NDJSON reader here handles that transparently (a single JSON
// line parses as the final chunk).
package ollama
import (
"fmt"
"net/http"
"os"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// DefaultLocalBaseURL is the default base URL for a locally-running Ollama.
const DefaultLocalBaseURL = "http://localhost:11434"
// DefaultCloudBaseURL is the base URL for Ollama Cloud.
const DefaultCloudBaseURL = "https://ollama.com"
// defaultCapabilities is the conservative provider-wide default; individual
// models (e.g. high-resolution vision tags) override via llm.WithCapabilities.
var defaultCapabilities = llm.Capabilities{
SupportsTools: true,
SupportsStructured: true,
SupportsStreaming: true,
MaxImagesPerReq: 8,
MaxImageBytes: 20 << 20,
MaxImageDimension: 2048,
AllowedImageMIME: []string{"image/jpeg", "image/png"},
}
// Provider is a native-Ollama chat client bound to one base URL.
type Provider struct {
name string
baseURL string
token string
client *http.Client
caps llm.Capabilities
}
// Option configures the provider.
type Option func(*Provider)
// WithName overrides the registry name (default "ollama").
func WithName(name string) Option { return func(p *Provider) { p.name = name } }
// WithBaseURL sets the backend base URL (scheme://host[:port][/path]).
func WithBaseURL(u string) Option {
return func(p *Provider) { p.baseURL = strings.TrimRight(u, "/") }
}
// WithToken sets the bearer token (Ollama Cloud key / foreman token).
// Empty means no Authorization header (local mode).
func WithToken(token string) Option { return func(p *Provider) { p.token = token } }
// WithHTTPClient overrides the HTTP client (proxies, test TLS, timeouts —
// note foreman sync chat long-polls; prefer context deadlines over client
// timeouts).
func WithHTTPClient(c *http.Client) Option { return func(p *Provider) { p.client = c } }
// WithDefaultCapabilities overrides the provider-wide default capabilities.
func WithDefaultCapabilities(caps llm.Capabilities) Option {
return func(p *Provider) { p.caps = caps }
}
// New creates a generic native-Ollama provider. Most callers want one of
// the presets (Local, Cloud, Foreman) or an LLM_* env DSN instead.
// Construction never fails; a missing base URL surfaces at request time.
func New(opts ...Option) *Provider {
p := &Provider{
name: "ollama",
client: &http.Client{},
caps: defaultCapabilities,
}
for _, opt := range opts {
opt(p)
}
return p
}
// Local returns the local-Ollama preset: name "ollama", base URL from
// OLLAMA_HOST (normalized per Ollama conventions) or localhost:11434.
func Local(opts ...Option) *Provider {
base := DefaultLocalBaseURL
if h := os.Getenv("OLLAMA_HOST"); h != "" {
base = NormalizeHost(h)
}
return New(append([]Option{WithBaseURL(base)}, opts...)...)
}
// Cloud returns the Ollama Cloud preset: name "ollama-cloud",
// https://ollama.com, bearer key from OLLAMA_API_KEY.
func Cloud(opts ...Option) *Provider {
return New(append([]Option{
WithName("ollama-cloud"),
WithBaseURL(DefaultCloudBaseURL),
WithToken(os.Getenv("OLLAMA_API_KEY")),
}, opts...)...)
}
// Foreman returns a foreman preset bound to the given daemon.
func Foreman(baseURL, token string, opts ...Option) *Provider {
return New(append([]Option{
WithName("foreman"),
WithBaseURL(baseURL),
WithToken(token),
}, opts...)...)
}
// NormalizeHost turns an OLLAMA_HOST-style value into a base URL:
// "host" → http://host:11434, "host:port" → http://host:port, full URLs
// pass through (trailing slash trimmed).
func NormalizeHost(h string) string {
h = strings.TrimRight(strings.TrimSpace(h), "/")
if strings.Contains(h, "://") {
return h
}
if !strings.Contains(h, ":") {
h += ":11434"
}
return "http://" + h
}
// Name implements llm.Provider.
func (p *Provider) Name() string { return p.name }
// BaseURL reports the configured backend base URL (diagnostics).
func (p *Provider) BaseURL() string { return p.baseURL }
// Model implements llm.Provider; the id passes through verbatim.
func (p *Provider) Model(id string, opts ...llm.ModelOption) (llm.Model, error) {
cfg := llm.ApplyModelOptions(opts)
caps := p.caps
if cfg.Capabilities != nil {
caps = *cfg.Capabilities
}
return &model{provider: p, id: id, caps: caps}, nil
}
// checkReady reports a usable configuration (a base URL is the only hard
// requirement; auth problems surface as 401s from the backend).
func (p *Provider) checkReady() error {
if p.baseURL == "" {
return fmt.Errorf("ollama provider %q: no base URL configured (set one via the preset, WithBaseURL, or an LLM_* env DSN)", p.name)
}
return nil
}