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