From 0e358148eb9abd5fcd19cc089d569def771f52e5 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Fri, 1 May 2026 18:22:11 +0000 Subject: [PATCH] feat(v2/ollama): scaffold native /api/chat provider Adds wire types and a Provider struct that will replace the openaicompat-based Ollama shim with a native /api/chat implementation. Complete and Stream methods are stubs; subsequent commits implement them. Adjusts the existing ollama.go to drop the type alias on openaicompat.Provider (renaming the legacy shim to a temporary internal helper) so the new native Provider type does not collide. Public New() still returns the openaicompat-backed provider until Task 4 swaps it. Co-Authored-By: Claude Opus 4.6 --- v2/ollama/native.go | 133 ++++++++++++++++++++++++++++++++++++++++++++ v2/ollama/ollama.go | 25 ++++++--- 2 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 v2/ollama/native.go diff --git a/v2/ollama/native.go b/v2/ollama/native.go new file mode 100644 index 0000000..7ff3976 --- /dev/null +++ b/v2/ollama/native.go @@ -0,0 +1,133 @@ +// Package ollama implements the go-llm v2 provider interface for Ollama, +// targeting Ollama's native /api/chat endpoint. Supports both local Ollama +// instances (no API key) and Ollama Cloud (https://ollama.com, requires an +// API key). +package ollama + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider" +) + +// DefaultLocalBaseURL is the default base URL for a locally-running Ollama +// instance. +const DefaultLocalBaseURL = "http://localhost:11434" + +// DefaultCloudBaseURL is the default base URL for Ollama Cloud. +const DefaultCloudBaseURL = "https://ollama.com" + +// Provider implements provider.Provider over Ollama's native /api/chat +// endpoint. An empty apiKey means local-mode (no Authorization header sent); +// a non-empty apiKey is sent as a Bearer token (cloud-mode). +type Provider struct { + apiKey string + baseURL string + client *http.Client +} + +// newNative constructs a native Ollama provider. Callers should use the +// package-level New() constructor or the v2 llm.Ollama() / llm.OllamaCloud() +// helpers. +func newNative(apiKey, baseURL string) *Provider { + return &Provider{ + apiKey: apiKey, + baseURL: baseURL, + client: &http.Client{}, + } +} + +// nativeChatRequest is the JSON body POSTed to /api/chat. +type nativeChatRequest struct { + Model string `json:"model"` + Messages []nativeChatMessage `json:"messages"` + Tools []nativeToolDef `json:"tools,omitempty"` + Stream bool `json:"stream"` + // Think is polymorphic — Ollama accepts true/false or "low"/"medium"/"high". + Think json.RawMessage `json:"think,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// nativeChatMessage is one entry in the messages array on the wire. It also +// carries assistant tool calls and tool-role responses. +type nativeChatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []nativeToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Thinking string `json:"thinking,omitempty"` +} + +// nativeToolCall mirrors Ollama's tool-call wire shape: a function with name +// and JSON-encoded arguments. Ollama's spec doesn't require an id, but some +// builds and some streaming chunks include one — we accept it on both wire and +// internal sides. +type nativeToolCall struct { + ID string `json:"id,omitempty"` + Function nativeFunctionCall `json:"function"` +} + +type nativeFunctionCall struct { + Index *int `json:"index,omitempty"` + Name string `json:"name,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +// nativeChatResponse is the JSON body returned from a non-streaming /api/chat +// call (and is also the per-line shape during streaming). +type nativeChatResponse struct { + Model string `json:"model,omitempty"` + Message nativeChatMessage `json:"message"` + Done bool `json:"done"` + DoneReason string `json:"done_reason,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + EvalCount int `json:"eval_count,omitempty"` + TotalDuration int64 `json:"total_duration,omitempty"` +} + +// nativeToolDef is the wire shape of a tool definition sent to Ollama. +type nativeToolDef struct { + Type string `json:"type"` + Function nativeFunctionDef `json:"function"` +} + +type nativeFunctionDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +// encodeThink converts a go-llm Reasoning string ("", "low", "medium", +// "high", or the literal strings "true"/"false") into Ollama's polymorphic +// `think` field. Returns nil for the empty string so the field is omitted. +func encodeThink(reasoning string) json.RawMessage { + switch reasoning { + case "": + return nil + case "true": + return json.RawMessage(`true`) + case "false": + return json.RawMessage(`false`) + default: + // "low" / "medium" / "high" — encode as a JSON string. + b, _ := json.Marshal(reasoning) + return b + } +} + +var errNotImplemented = errors.New("ollama native provider: not implemented") + +// Complete performs a non-streaming chat completion via /api/chat. +func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) { + return provider.Response{}, errNotImplemented +} + +// Stream performs a streaming chat completion via /api/chat with +// `stream: true`, parsing NDJSON line-by-line. +func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error { + return errNotImplemented +} diff --git a/v2/ollama/ollama.go b/v2/ollama/ollama.go index 75623ca..586e6a0 100644 --- a/v2/ollama/ollama.go +++ b/v2/ollama/ollama.go @@ -9,17 +9,28 @@ import ( "gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat" ) -// DefaultBaseURL points at a local Ollama instance with default port. +// DefaultBaseURL points at a local Ollama instance with default port. Kept +// for the openaicompat-based shim; callers should migrate to +// DefaultLocalBaseURL (no /v1 suffix) which targets the native /api/chat +// endpoint. const DefaultBaseURL = "http://localhost:11434/v1" -// Provider is a type alias over openaicompat.Provider. -type Provider = openaicompat.Provider - -// New creates a new Ollama provider. An empty baseURL uses DefaultBaseURL. -// Ollama ignores the API key; callers may pass "". -func New(apiKey, baseURL string) *Provider { +// shimNew is the legacy openaicompat-based constructor, retained until the +// native provider's Complete/Stream are fully implemented (Task 4 replaces +// the public New() with a native-backed constructor). +func shimNew(apiKey, baseURL string) *openaicompat.Provider { if baseURL == "" { baseURL = DefaultBaseURL } return openaicompat.New(apiKey, baseURL, openaicompat.Rules{}) } + +// New creates a new Ollama provider. An empty baseURL uses DefaultBaseURL. +// Ollama ignores the API key; callers may pass "". +// +// Note: this constructor currently still routes through the openaicompat +// shim. Subsequent commits replace the body with a native /api/chat +// implementation backed by the Provider type in native.go. +func New(apiKey, baseURL string) *openaicompat.Provider { + return shimNew(apiKey, baseURL) +}