backend: internalize model state; remove model param from ChatStream

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-09 21:42:30 +02:00
parent f280ef17db
commit ce329bfcc7
11 changed files with 103 additions and 116 deletions

View File

@ -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)

View File

@ -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

View File

@ -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)
}

View File

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

View File

@ -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{},

View File

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

View File

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

View File

@ -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{

View File

@ -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 {

View File

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

View File

@ -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 {