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 <noreply@anthropic.com>
This commit is contained in:
parent
af34b2f267
commit
127f428620
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Reference in New Issue