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