From 127f428620e7bc891414367887fc43b9ce0f5070 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 31 Mar 2026 22:40:42 +0200 Subject: [PATCH] Add backend package: common LLM interface for ollama and OpenAI-compatible APIs Defines a Backend interface with shared Message/Tool/ToolCall types. OllamaBackend wraps /api/chat; OpenAIBackend covers OpenAI, OpenRouter, and any compatible API. Factory reads OLLIE_BACKEND, OLLIE_API_URL, OLLIE_API_KEY. Co-Authored-By: Claude Sonnet 4.6 --- backend/backend.go | 44 ++++++++++++ backend/new.go | 36 ++++++++++ backend/ollama.go | 138 ++++++++++++++++++++++++++++++++++++++ backend/openai.go | 162 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 backend/backend.go create mode 100644 backend/new.go create mode 100644 backend/ollama.go create mode 100644 backend/openai.go diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..4e118f8 --- /dev/null +++ b/backend/backend.go @@ -0,0 +1,44 @@ +// Package backend defines the Backend interface and shared types for LLM +// providers. All backends speak the same canonical types; provider-specific +// wire formats are handled inside each implementation. +package backend + +import ( + "context" + "encoding/json" +) + +// Message is a single conversation turn. +type Message struct { + Role string `json:"role"` // "system" | "user" | "assistant" | "tool" + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant when calling tools + ToolCallID string `json:"tool_call_id,omitempty"` // set on role=tool replies (required by OpenAI) +} + +// Tool describes a callable function exposed to the model. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` // JSON Schema object +} + +// ToolCall is the model's request to invoke a function. +type ToolCall struct { + ID string `json:"id,omitempty"` // provider-assigned; may be empty (Ollama) + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` // always a JSON object +} + +// Response is the model's reply for one Chat call. +type Response struct { + Message Message + StopReason string // "stop" | "tool_calls" | "length" | ... +} + +// Backend is the interface all LLM providers must implement. +type Backend interface { + // Chat sends messages to the model and returns its response. + // tools may be nil for plain completion requests. + Chat(ctx context.Context, model string, messages []Message, tools []Tool) (*Response, error) +} diff --git a/backend/new.go b/backend/new.go new file mode 100644 index 0000000..bb465fa --- /dev/null +++ b/backend/new.go @@ -0,0 +1,36 @@ +package backend + +import ( + "fmt" + "os" +) + +// New constructs a Backend from environment variables. +// +// OLLIE_BACKEND ollama | openai | openrouter (default: ollama) +// OLLIE_API_URL override base URL for the selected backend +// OLLIE_API_KEY API key (required for openai / openrouter) +func New() (Backend, error) { + which := os.Getenv("OLLIE_BACKEND") + if which == "" { + which = "ollama" + } + + apiURL := os.Getenv("OLLIE_API_URL") + apiKey := os.Getenv("OLLIE_API_KEY") + + switch which { + case "ollama": + return NewOllama(apiURL), nil + case "openai": + return NewOpenAI(apiURL, apiKey), nil + case "openrouter": + url := apiURL + if url == "" { + url = "https://openrouter.ai/api" + } + return NewOpenAI(url, apiKey), nil + default: + return nil, fmt.Errorf("unknown OLLIE_BACKEND %q (supported: ollama, openai, openrouter)", which) + } +} diff --git a/backend/ollama.go b/backend/ollama.go new file mode 100644 index 0000000..9123fb0 --- /dev/null +++ b/backend/ollama.go @@ -0,0 +1,138 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// OllamaBackend speaks the Ollama /api/chat wire format. +type OllamaBackend struct { + baseURL string + client *http.Client +} + +func NewOllama(baseURL string) *OllamaBackend { + if baseURL == "" { + baseURL = "http://localhost:11434" + } + return &OllamaBackend{baseURL: baseURL, client: &http.Client{}} +} + +// -- wire types -- + +type ollamaMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"` +} + +type ollamaToolCall struct { + Function ollamaFunction `json:"function"` +} + +type ollamaFunction struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type ollamaTool struct { + Type string `json:"type"` + Function ollamaToolFunction `json:"function"` +} + +type ollamaToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +type ollamaChatRequest struct { + Model string `json:"model"` + Messages []ollamaMessage `json:"messages"` + Tools []ollamaTool `json:"tools,omitempty"` + Stream bool `json:"stream"` +} + +type ollamaChatResponse struct { + Message ollamaMessage `json:"message"` + Done bool `json:"done"` +} + +// -- implementation -- + +func (b *OllamaBackend) Chat(ctx context.Context, model string, messages []Message, tools []Tool) (*Response, error) { + wireMessages := make([]ollamaMessage, len(messages)) + for i, m := range messages { + wireMessages[i] = ollamaMessage{Role: m.Role, Content: m.Content} + for _, tc := range m.ToolCalls { + wireMessages[i].ToolCalls = append(wireMessages[i].ToolCalls, ollamaToolCall{ + Function: ollamaFunction{Name: tc.Name, Arguments: tc.Arguments}, + }) + } + } + + var wireTools []ollamaTool + for _, t := range tools { + wireTools = append(wireTools, ollamaTool{ + Type: "function", + Function: ollamaToolFunction{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }, + }) + } + + req := ollamaChatRequest{ + Model: model, + Messages: wireMessages, + Tools: wireTools, + Stream: false, + } + + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, b.baseURL+"/api/chat", bytes.NewReader(data)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := b.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, body) + } + + var wire ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil { + return nil, err + } + + msg := Message{Role: wire.Message.Role, Content: wire.Message.Content} + for _, tc := range wire.Message.ToolCalls { + msg.ToolCalls = append(msg.ToolCalls, ToolCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }) + } + + stopReason := "stop" + if len(msg.ToolCalls) > 0 { + stopReason = "tool_calls" + } + + return &Response{Message: msg, StopReason: stopReason}, nil +} diff --git a/backend/openai.go b/backend/openai.go new file mode 100644 index 0000000..9ac0ff5 --- /dev/null +++ b/backend/openai.go @@ -0,0 +1,162 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// OpenAIBackend speaks the OpenAI /v1/chat/completions wire format. +// Compatible with OpenRouter, OpenAI, and any other OpenAI-compatible API. +type OpenAIBackend struct { + baseURL string + apiKey string + client *http.Client +} + +func NewOpenAI(baseURL, apiKey string) *OpenAIBackend { + if baseURL == "" { + baseURL = "https://api.openai.com" + } + return &OpenAIBackend{baseURL: baseURL, apiKey: apiKey, client: &http.Client{}} +} + +// -- wire types -- + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function openAIFunctionCall `json:"function"` +} + +type openAIFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON string, not object +} + +type openAITool struct { + Type string `json:"type"` + Function openAIToolFunction `json:"function"` +} + +type openAIToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +type openAIChatRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + Tools []openAITool `json:"tools,omitempty"` + Stream bool `json:"stream"` +} + +type openAIChatResponse struct { + Choices []openAIChoice `json:"choices"` +} + +type openAIChoice struct { + Message openAIMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// -- implementation -- + +func (b *OpenAIBackend) Chat(ctx context.Context, model string, messages []Message, tools []Tool) (*Response, error) { + wireMessages := make([]openAIMessage, len(messages)) + for i, m := range messages { + wireMessages[i] = openAIMessage{ + Role: m.Role, + Content: m.Content, + ToolCallID: m.ToolCallID, + } + for _, tc := range m.ToolCalls { + wireMessages[i].ToolCalls = append(wireMessages[i].ToolCalls, openAIToolCall{ + ID: tc.ID, + Type: "function", + Function: openAIFunctionCall{ + Name: tc.Name, + Arguments: string(tc.Arguments), + }, + }) + } + } + + var wireTools []openAITool + for _, t := range tools { + wireTools = append(wireTools, openAITool{ + Type: "function", + Function: openAIToolFunction{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }, + }) + } + + req := openAIChatRequest{ + Model: model, + Messages: wireMessages, + Tools: wireTools, + Stream: false, + } + + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, b.baseURL+"/v1/chat/completions", bytes.NewReader(data)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + if b.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+b.apiKey) + } + + resp, err := b.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, body) + } + + var wire openAIChatResponse + if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil { + return nil, err + } + + if len(wire.Choices) == 0 { + return nil, fmt.Errorf("openai: empty choices in response") + } + + choice := wire.Choices[0] + msg := Message{Role: choice.Message.Role, Content: choice.Message.Content} + for _, tc := range choice.Message.ToolCalls { + // Arguments arrive as a JSON string; convert to RawMessage. + args := json.RawMessage(tc.Function.Arguments) + msg.ToolCalls = append(msg.ToolCalls, ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: args, + }) + } + + return &Response{Message: msg, StopReason: choice.FinishReason}, nil +}