From ce329bfcc74d027206f358672cab1194ba46c812 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 9 Apr 2026 21:42:30 +0200 Subject: [PATCH] backend: internalize model state; remove model param from ChatStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each backend now owns its active model via Model()/SetModel(). ChatStream no longer takes a model string — callers use SetModel to configure it. AgentCoreConfig.ModelName sets the backend model at construction time via SetModel, then the field can be dropped from the live agentCore struct. Co-Authored-By: Claude Sonnet 4.6 --- cmd/ollie/main.go | 48 +----------------------- internal/backend/anthropic.go | 13 ++++++- internal/backend/backend.go | 10 ++++- internal/backend/codewhisperer.go | 14 +++++-- internal/backend/copilot.go | 1 + internal/backend/new.go | 26 ++++++++++++- internal/backend/ollama.go | 13 ++++++- internal/backend/openai.go | 25 +++++++++++-- pkg/agent/core.go | 62 ++++--------------------------- pkg/agent/loop.go | 3 +- pkg/agent/session.go | 4 +- 11 files changed, 103 insertions(+), 116 deletions(-) diff --git a/cmd/ollie/main.go b/cmd/ollie/main.go index 559bee5..dc5d28f 100644 --- a/cmd/ollie/main.go +++ b/cmd/ollie/main.go @@ -7,7 +7,6 @@ import ( "flag" "fmt" "os" - "strings" "time" "ollie/internal/backend" @@ -37,10 +36,8 @@ func main() { os.Exit(1) } - backendName := resolveBackendName() - modelName := os.Getenv("OLLIE_MODEL") - if modelName == "" { - modelName = defaultModelForBackend(backendName) + if modelName := os.Getenv("OLLIE_MODEL"); modelName != "" { + be.SetModel(modelName) } builtinExec := execpkg.New( @@ -88,8 +85,6 @@ func main() { agentCore := agent.NewAgentCore(agent.AgentCoreConfig{ Backend: be, - BackendName: backendName, - ModelName: modelName, AgentName: agentName, AgentsDir: agentsDir, SessionsDir: sessionsDir, @@ -121,45 +116,6 @@ func main() { tui.New(agentCore).Run(context.Background()) } -func resolveBackendName() string { - which := os.Getenv("OLLIE_BACKEND") - if which == "" { - which = "ollama" - } - if which != "openai" { - return which - } - url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL")) - switch { - case strings.Contains(url, "openrouter"): - return "openrouter" - case strings.Contains(url, "together"): - return "together" - case strings.Contains(url, "groq"): - return "groq" - case strings.Contains(url, "mistral"): - return "mistral" - case strings.Contains(url, "anthropic"): - return "anthropic" - case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): - return "local" - default: - return "openai" - } -} - -func defaultModelForBackend(name string) string { - switch name { - case "anthropic": - return "claude-sonnet-4-5" - case "openrouter": - return "deepseek/deepseek-v3.2" - case "kiro", "codewhisperer": - return "auto" - default: - return "qwen3.5:9b" - } -} func newSessionID() string { b := make([]byte, 3) diff --git a/internal/backend/anthropic.go b/internal/backend/anthropic.go index e4ac77e..33321fc 100644 --- a/internal/backend/anthropic.go +++ b/internal/backend/anthropic.go @@ -16,13 +16,21 @@ const anthropicDefaultMaxTokens = 8192 // AnthropicBackend speaks the Anthropic Messages API. type AnthropicBackend struct { apiKey string + model string client *http.Client } func NewAnthropic(apiKey string) *AnthropicBackend { - return &AnthropicBackend{apiKey: apiKey, client: &http.Client{}} + b := &AnthropicBackend{apiKey: apiKey, client: &http.Client{}} + b.model = b.DefaultModel() + return b } +func (b *AnthropicBackend) Name() string { return "anthropic" } +func (b *AnthropicBackend) DefaultModel() string { return "claude-sonnet-4-5" } +func (b *AnthropicBackend) Model() string { return b.model } +func (b *AnthropicBackend) SetModel(m string) { b.model = m } + // -- wire types -- type anthropicRequest struct { @@ -58,7 +66,8 @@ type anthropicTool struct { // -- implementation -- -func (b *AnthropicBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) { +func (b *AnthropicBackend) ChatStream(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) { + model := b.model system, wireMessages := buildAnthropicMessages(messages) maxTokens := params.MaxTokens diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 79573c5..c083dd1 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -77,5 +77,13 @@ type GenerationParams struct { // Streaming is the only supported mode; backends that wrap blocking APIs // should implement ChatStream as a single-event stream. type Backend interface { - ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) + ChatStream(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) + // Name returns a short human-readable label for this backend (e.g. "ollama", "openrouter"). + Name() string + // DefaultModel returns a reasonable default model name for this backend. + DefaultModel() string + // Model returns the currently active model name. + Model() string + // SetModel changes the active model. + SetModel(model string) } diff --git a/internal/backend/codewhisperer.go b/internal/backend/codewhisperer.go index a3a724e..237d8cc 100644 --- a/internal/backend/codewhisperer.go +++ b/internal/backend/codewhisperer.go @@ -30,6 +30,7 @@ import ( type CodeWhispererBackend struct { endpoint string // overrides auth-derived endpoint if non-empty + model string extraHeaders map[string]string authSource kiroAuthSource authInitErr error @@ -42,22 +43,29 @@ func NewCodeWhisperer(apiKey string) (*CodeWhispererBackend, error) { apiKey = kiroDefaultSQLiteKey() } authSource, err := newKiroAuthSource(apiKey) - return &CodeWhispererBackend{ + b := &CodeWhispererBackend{ authSource: authSource, authInitErr: err, httpClient: &http.Client{}, - }, nil + } + b.model = b.DefaultModel() + return b, nil } +func (b *CodeWhispererBackend) Name() string { return "kiro" } +func (b *CodeWhispererBackend) DefaultModel() string { return "auto" } +func (b *CodeWhispererBackend) Model() string { return b.model } +func (b *CodeWhispererBackend) SetModel(m string) { b.model = m } + // ── Backend interface ───────────────────────────────────────────────────────── func (b *CodeWhispererBackend) ChatStream( ctx context.Context, - model string, messages []Message, tools []Tool, params GenerationParams, ) (<-chan StreamEvent, error) { + model := b.model if b.authInitErr != nil { return nil, b.authInitErr } diff --git a/internal/backend/copilot.go b/internal/backend/copilot.go index 5f33676..ae6eee5 100644 --- a/internal/backend/copilot.go +++ b/internal/backend/copilot.go @@ -9,6 +9,7 @@ import "net/http" // injected on every request via the OpenAIBackend's extraHeaders mechanism. func NewCopilot(token string) *OpenAIBackend { return &OpenAIBackend{ + name: "copilot", baseURL: "https://api.githubcopilot.com", apiKey: token, client: &http.Client{}, diff --git a/internal/backend/new.go b/internal/backend/new.go index 5370a59..9def13a 100644 --- a/internal/backend/new.go +++ b/internal/backend/new.go @@ -61,7 +61,9 @@ func New() (Backend, error) { case "ollama": return NewOllama(os.Getenv("OLLIE_OLLAMA_URL")), nil case "openai", "openrouter": - return NewOpenAI(os.Getenv("OLLIE_OPENAI_URL"), os.Getenv("OLLIE_OPENAI_KEY")), nil + url := os.Getenv("OLLIE_OPENAI_URL") + key := os.Getenv("OLLIE_OPENAI_KEY") + return NewOpenAI(openAIName(which, url), url, key), nil case "anthropic": key := os.Getenv("OLLIE_ANTHROPIC_KEY") if key == "" { @@ -80,3 +82,25 @@ func New() (Backend, error) { return nil, fmt.Errorf("unknown OLLIE_BACKEND %q (supported: ollama, openai, openrouter, anthropic, copilot)", which) } } + +// openAIName derives a short backend label from the OLLIE_BACKEND value and +// the base URL, so openai-compatible endpoints self-identify correctly. +func openAIName(which, url string) string { + url = strings.ToLower(url) + switch { + case strings.Contains(url, "openrouter"): + return "openrouter" + case strings.Contains(url, "together"): + return "together" + case strings.Contains(url, "groq"): + return "groq" + case strings.Contains(url, "mistral"): + return "mistral" + case strings.Contains(url, "anthropic"): + return "anthropic" + case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): + return "local" + default: + return which + } +} diff --git a/internal/backend/ollama.go b/internal/backend/ollama.go index 16a94c2..22dd0ca 100644 --- a/internal/backend/ollama.go +++ b/internal/backend/ollama.go @@ -13,6 +13,7 @@ import ( // OllamaBackend speaks the Ollama /api/chat wire format. type OllamaBackend struct { baseURL string + model string client *http.Client } @@ -20,9 +21,16 @@ func NewOllama(baseURL string) *OllamaBackend { if baseURL == "" { baseURL = "http://localhost:11434" } - return &OllamaBackend{baseURL: baseURL, client: &http.Client{}} + b := &OllamaBackend{baseURL: baseURL, client: &http.Client{}} + b.model = b.DefaultModel() + return b } +func (b *OllamaBackend) Name() string { return "ollama" } +func (b *OllamaBackend) DefaultModel() string { return "qwen3.5:9b" } +func (b *OllamaBackend) Model() string { return b.model } +func (b *OllamaBackend) SetModel(m string) { b.model = m } + // -- wire types -- type ollamaMessage struct { @@ -69,7 +77,8 @@ type ollamaChatResponse struct { // -- implementation -- -func (b *OllamaBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, _ GenerationParams) (<-chan StreamEvent, error) { +func (b *OllamaBackend) ChatStream(ctx context.Context, messages []Message, tools []Tool, _ GenerationParams) (<-chan StreamEvent, error) { + model := b.model wireMessages := make([]ollamaMessage, len(messages)) for i, m := range messages { wireMessages[i] = ollamaMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID} diff --git a/internal/backend/openai.go b/internal/backend/openai.go index 7a775c3..41075a8 100644 --- a/internal/backend/openai.go +++ b/internal/backend/openai.go @@ -16,17 +16,35 @@ import ( // OpenAIBackend speaks the OpenAI /v1/chat/completions wire format. // Compatible with OpenRouter, OpenAI, and any other OpenAI-compatible API. type OpenAIBackend struct { + name string baseURL string apiKey string + model string client *http.Client extraHeaders map[string]string // optional; applied after Authorization } -func NewOpenAI(baseURL, apiKey string) *OpenAIBackend { +func NewOpenAI(name, baseURL, apiKey string) *OpenAIBackend { if baseURL == "" { baseURL = "https://api.openai.com" } - return &OpenAIBackend{baseURL: baseURL, apiKey: apiKey, client: &http.Client{}} + b := &OpenAIBackend{name: name, baseURL: baseURL, apiKey: apiKey, client: &http.Client{}} + b.model = b.DefaultModel() + return b +} + +func (b *OpenAIBackend) Name() string { return b.name } +func (b *OpenAIBackend) Model() string { return b.model } +func (b *OpenAIBackend) SetModel(m string) { b.model = m } +func (b *OpenAIBackend) DefaultModel() string { + switch b.name { + case "openrouter": + return "deepseek/deepseek-v3.2" + case "anthropic": + return "claude-sonnet-4-5" + default: + return "qwen3.5:9b" + } } // -- wire types -- @@ -101,7 +119,8 @@ type openAIStreamResponse struct { // -- implementation -- -func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) { +func (b *OpenAIBackend) ChatStream(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) { + model := b.model wireMessages := make([]openAIMessage, len(messages)) for i, m := range messages { wm := openAIMessage{ diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 5303816..0590271 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -401,45 +401,6 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv { } } -func defaultModelForBackend(name string) string { - switch name { - case "anthropic": - return "claude-sonnet-4-5" - case "openrouter": - return "deepseek/deepseek-v3.2" - case "kiro", "codewhisperer": - return "auto" - default: - return "qwen3.5:9b" - } -} - -func resolveBackendName() string { - which := os.Getenv("OLLIE_BACKEND") - if which == "" { - which = "ollama" - } - if which != "openai" { - return which - } - url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL")) - switch { - case strings.Contains(url, "openrouter"): - return "openrouter" - case strings.Contains(url, "together"): - return "together" - case strings.Contains(url, "groq"): - return "groq" - case strings.Contains(url, "mistral"): - return "mistral" - case strings.Contains(url, "anthropic"): - return "anthropic" - case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): - return "local" - default: - return "openai" - } -} // agentConfigPath resolves the config file path for a named agent. func agentConfigPath(agentsDir, name string) string { @@ -475,8 +436,7 @@ type actionHandle struct { // AgentCoreConfig is the configuration for creating an agentCore. type AgentCoreConfig struct { Backend backend.Backend - BackendName string - ModelName string + ModelName string // if non-empty, overrides backend's default model AgentName string AgentsDir string SessionsDir string @@ -492,8 +452,6 @@ type agentCore struct { session *Session loopcfg loopConfig hooks Hooks - modelName string - backendName string agentName string agentsDir string sessionsDir string @@ -510,9 +468,11 @@ var _ Core = (*agentCore)(nil) // compile-time interface check // NewAgentCore creates an agentCore from the given configuration. func NewAgentCore(cfg AgentCoreConfig) Core { + if cfg.ModelName != "" { + cfg.Backend.SetModel(cfg.ModelName) + } loopcfg := loopConfig{ Backend: cfg.Backend, - Model: cfg.ModelName, systemPrompt: cfg.Env.systemPrompt, Tools: cfg.Env.tools, Exec: cfg.Env.exec, @@ -523,8 +483,6 @@ func NewAgentCore(cfg AgentCoreConfig) Core { session: cfg.Session, loopcfg: loopcfg, hooks: cfg.Env.Hooks, - modelName: cfg.ModelName, - backendName: cfg.BackendName, agentName: cfg.AgentName, agentsDir: cfg.AgentsDir, sessionsDir: cfg.SessionsDir, @@ -538,7 +496,7 @@ func NewAgentCore(cfg AgentCoreConfig) Core { } func (s *agentCore) prompt() string { - return fmt.Sprintf("[%s :: %s] ", s.backendName, s.agentName) + return fmt.Sprintf("[%s :: %s] ", s.loopcfg.Backend.Name(), s.agentName) } // Prompt returns the display prompt string for the current session state. @@ -655,10 +613,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve return true } s.loopcfg.Backend = be - s.backendName = resolveBackendName() - s.loopcfg.Model = defaultModelForBackend(s.backendName) - s.modelName = s.loopcfg.Model - handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", s.backendName, s.modelName))) + handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model()))) return true case "/model": @@ -666,8 +621,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve handler(infoEvent("error: /model requires an argument (e.g., /model qwen3:8b)")) return true } - s.loopcfg.Model = args[0] - s.modelName = args[0] + s.loopcfg.Backend.SetModel(args[0]) handler(infoEvent("switched model to: " + args[0])) return true @@ -734,7 +688,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve handler(infoEvent("nothing to compact")) return true } - n, summary, err := s.session.compact(ctx, s.loopcfg.Backend, s.loopcfg.Model) + n, summary, err := s.session.compact(ctx, s.loopcfg.Backend) if err != nil { handler(infoEvent("compact error: " + err.Error())) } else if n == 0 { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index df2a581..8e98711 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -17,7 +17,6 @@ type toolExecutor func(ctx context.Context, name string, args json.RawMessage) ( type loopConfig struct { Backend backend.Backend - Model string Tools []backend.Tool Exec toolExecutor Confirm confirmFn @@ -49,7 +48,7 @@ func run(ctx context.Context, cfg loopConfig, state state) error { var ch <-chan backend.StreamEvent for attempt := range maxRateLimitRetries + 1 { var err error - ch, err = cfg.Backend.ChatStream(ctx, cfg.Model, history, cfg.Tools, cfg.GenerationParams) + ch, err = cfg.Backend.ChatStream(ctx, history, cfg.Tools, cfg.GenerationParams) if err == nil { break } diff --git a/pkg/agent/session.go b/pkg/agent/session.go index 70577ec..58678db 100644 --- a/pkg/agent/session.go +++ b/pkg/agent/session.go @@ -106,7 +106,7 @@ func (s *Session) markComplete() error { // an LLM call, replacing them with a single summary system message. // Unlike the previous eviction-based approach, this works for any session size. // Returns (n compacted, summary text, error); n==0 means nothing to compact. -func (s *Session) compact(ctx context.Context, b backend.Backend, model string) (int, string, error) { +func (s *Session) compact(ctx context.Context, b backend.Backend) (int, string, error) { older := s.ctx.OlderMessages() if len(older) == 0 { return 0, "", nil @@ -119,7 +119,7 @@ func (s *Session) compact(ctx context.Context, b backend.Backend, model string) } prompt := "Summarize the following conversation history concisely, preserving key facts, decisions, and context:\n\n" + sb.String() - ch, err := b.ChatStream(ctx, model, []backend.Message{ + ch, err := b.ChatStream(ctx, []backend.Message{ {Role: "user", Content: prompt}, }, nil, backend.GenerationParams{}) if err != nil {