on main help sign in
chore: move to the new config
commit565ab85eb91102ff7a9b03f950ac13ae4391f6ab authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 27 Jun 2025, 10:55 (1 year ago) parent6e0feda
56 files changed+1096−3237
cmd/root.go+2−1 cmd/schema/main.go+3−18 internal/app/app.go+12−9 internal/config/config.go+565−730 internal/config_v2/config_test.go → internal/config/config_test.go+2−2 internal/config_v2/fs.go → internal/config/fs.go+1−1 internal/config/init.go+6−13 internal/config_v2/provider.go → internal/config/provider.go+1−1 internal/config_v2/config.go−660 internal/db/connect.go+2−3 internal/db/messages.sql.go+10−4 internal/db/migrations/20250627000000_add_provider_to_messages.sql+11 internal/db/models.go+1 internal/db/sql/messages.sql+2−1 internal/fur/client/client.go+1−1 internal/fur/provider/provider.go+7−8 internal/llm/agent/agent-tool.go+10−17 internal/llm/agent/agent.go+206−95 internal/llm/agent/mcp-tools.go+4−4 internal/llm/agent/tools.go−50 internal/llm/models/anthropic.go−111 internal/llm/models/azure.go−168 internal/llm/models/gemini.go−67 internal/llm/models/groq.go−87 internal/llm/models/local.go−206 internal/llm/models/models.go−74 internal/llm/models/openai.go−181 internal/llm/models/openrouter.go−276 internal/llm/models/vertexai.go−38 internal/llm/models/xai.go−61 internal/llm/prompt/coder.go+13−5 internal/llm/prompt/prompt.go+19−35 internal/llm/prompt/prompt_test.go+7−8 internal/llm/prompt/summarizer.go+4−2 internal/llm/prompt/task.go+2−2 internal/llm/prompt/title.go+4−2 internal/llm/provider/anthropic.go+5−5 internal/llm/provider/bedrock.go+4−11 internal/llm/provider/gemini.go+4−4 internal/llm/provider/openai.go+5−5 internal/llm/provider/provider.go+57−77 internal/llm/provider/vertexai.go+4−3 internal/lsp/client.go+14−14 internal/lsp/handlers.go+2−2 internal/lsp/transport.go+19−19 internal/lsp/watcher/watcher.go+25−25 internal/message/content.go+5−4 internal/message/message.go+7−5 internal/tui/components/chat/header/header.go+1−6 internal/tui/components/chat/messages/messages.go+5−3 internal/tui/components/chat/sidebar/sidebar.go+2−6 internal/tui/components/dialogs/commands/loader.go+1−1 internal/tui/components/dialogs/init/init.go+3−3 internal/tui/components/dialogs/models/models.go+31−93 internal/tui/page/chat/chat.go+3−7 internal/tui/tui.go+4−3
cmd/root.go+2−1
@@ -72,7 +72,8 @@ to assist developers in writing, debugging, and understanding code directly from
7272 }
7373 cwd = c
7474 }
75− _, err := config.Load(cwd, debug)
75+
76+ _, err := config.Init(cwd, debug)
7677 if err != nil {
7778 return err
7879 }
cmd/schema/main.go+3−18
@@ -1,3 +1,4 @@
1+// TODO: FIX THIS
12package main
23
34import (
@@ -6,7 +7,6 @@ import (
67 "os"
78
89 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/llm/models"
1010)
1111
1212// JSONSchemaType represents a JSON Schema type
@@ -192,22 +192,10 @@ func generateSchema() map[string]any {
192192 },
193193 }
194194
195− // Add known providers
196− knownProviders := []string{
197− string(models.ProviderAnthropic),
198− string(models.ProviderOpenAI),
199− string(models.ProviderGemini),
200− string(models.ProviderGROQ),
201− string(models.ProviderOpenRouter),
202− string(models.ProviderBedrock),
203− string(models.ProviderAzure),
204− string(models.ProviderVertexAI),
205− }
206−
207195 providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
208196 "type": "string",
209197 "description": "Provider type",
210− "enum": knownProviders,
198+ "enum": []string{},
211199 }
212200
213201 schema["properties"].(map[string]any)["providers"] = providerSchema
@@ -241,9 +229,7 @@ func generateSchema() map[string]any {
241229
242230 // Add model enum
243231 modelEnum := []string{}
244− for modelID := range models.SupportedModels {
245− modelEnum = append(modelEnum, string(modelID))
246− }
232+
247233 agentSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["model"].(map[string]any)["enum"] = modelEnum
248234
249235 // Add specific agent properties
@@ -251,7 +237,6 @@ func generateSchema() map[string]any {
251237 knownAgents := []string{
252238 string(config.AgentCoder),
253239 string(config.AgentTask),
254− string(config.AgentTitle),
255240 }
256241
257242 for _, agentName := range knownAgents {
internal/app/app.go+12−9
@@ -9,7 +9,7 @@ import (
99 "sync"
1010 "time"
1111
12− "github.com/charmbracelet/crush/internal/config"
12+ configv2 "github.com/charmbracelet/crush/internal/config"
1313 "github.com/charmbracelet/crush/internal/db"
1414 "github.com/charmbracelet/crush/internal/format"
1515 "github.com/charmbracelet/crush/internal/history"
@@ -55,18 +55,21 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
5555 // Initialize LSP clients in the background
5656 go app.initLSPClients(ctx)
5757
58+ cfg := configv2.Get()
59+
60+ coderAgentCfg := cfg.Agents[configv2.AgentCoder]
61+ if coderAgentCfg.ID == "" {
62+ return nil, fmt.Errorf("coder agent configuration is missing")
63+ }
64+
5865 var err error
5966 app.CoderAgent, err = agent.NewAgent(
60− config.AgentCoder,
67+ coderAgentCfg,
68+ app.Permissions,
6169 app.Sessions,
6270 app.Messages,
63− agent.CoderAgentTools(
64− app.Permissions,
65− app.Sessions,
66− app.Messages,
67− app.History,
68− app.LSPClients,
69− ),
71+ app.History,
72+ app.LSPClients,
7073 )
7174 if err != nil {
7275 logging.Error("Failed to create coder agent", err)
internal/config/config.go+565−730
@@ -1,67 +1,132 @@
1−// Package config manages application configuration from various sources.
21package config
32
43import (
54 "encoding/json"
5+ "errors"
66 "fmt"
77 "log/slog"
8+ "maps"
89 "os"
910 "path/filepath"
11+ "slices"
1012 "strings"
13+ "sync"
1114
12− "github.com/charmbracelet/crush/internal/llm/models"
15+ "github.com/charmbracelet/crush/internal/fur/provider"
1316 "github.com/charmbracelet/crush/internal/logging"
14− "github.com/spf13/afero"
15− "github.com/spf13/viper"
1617)
1718
18−// MCPType defines the type of MCP (Model Control Protocol) server.
19−type MCPType string
20−
21−// Supported MCP types
2219const (
23− MCPStdio MCPType = "stdio"
24− MCPSse MCPType = "sse"
20+ defaultDataDirectory = ".crush"
21+ defaultLogLevel = "info"
22+ appName = "crush"
23+
24+ MaxTokensFallbackDefault = 4096
2525)
2626
27−// MCPServer defines the configuration for a Model Control Protocol server.
28−type MCPServer struct {
29− Command string `json:"command"`
30− Env []string `json:"env"`
31− Args []string `json:"args"`
32− Type MCPType `json:"type"`
33− URL string `json:"url"`
34− Headers map[string]string `json:"headers"`
27+var defaultContextPaths = []string{
28+ ".github/copilot-instructions.md",
29+ ".cursorrules",
30+ ".cursor/rules/",
31+ "CLAUDE.md",
32+ "CLAUDE.local.md",
33+ "GEMINI.md",
34+ "gemini.md",
35+ "crush.md",
36+ "crush.local.md",
37+ "Crush.md",
38+ "Crush.local.md",
39+ "CRUSH.md",
40+ "CRUSH.local.md",
3541}
3642
37−type AgentName string
43+type AgentID string
3844
3945const (
40− AgentCoder AgentName = "coder"
41− AgentSummarizer AgentName = "summarizer"
42− AgentTask AgentName = "task"
43− AgentTitle AgentName = "title"
46+ AgentCoder AgentID = "coder"
47+ AgentTask AgentID = "task"
4448)
4549
46−// Agent defines configuration for different LLM models and their token limits.
47−type Agent struct {
48− Model models.ModelID `json:"model"`
49− MaxTokens int64 `json:"maxTokens"`
50− ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
50+type Model struct {
51+ ID string `json:"id"`
52+ Name string `json:"model"`
53+ CostPer1MIn float64 `json:"cost_per_1m_in"`
54+ CostPer1MOut float64 `json:"cost_per_1m_out"`
55+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
56+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
57+ ContextWindow int64 `json:"context_window"`
58+ DefaultMaxTokens int64 `json:"default_max_tokens"`
59+ CanReason bool `json:"can_reason"`
60+ ReasoningEffort string `json:"reasoning_effort"`
61+ SupportsImages bool `json:"supports_attachments"`
5162}
5263
53−// Provider defines configuration for an LLM provider.
54−type Provider struct {
55− APIKey string `json:"apiKey"`
56− Disabled bool `json:"disabled"`
64+type VertexAIOptions struct {
65+ APIKey string `json:"api_key,omitempty"`
66+ Project string `json:"project,omitempty"`
67+ Location string `json:"location,omitempty"`
5768}
5869
59−// Data defines storage configuration.
60−type Data struct {
61− Directory string `json:"directory,omitempty"`
70+type ProviderConfig struct {
71+ ID provider.InferenceProvider `json:"id"`
72+ BaseURL string `json:"base_url,omitempty"`
73+ ProviderType provider.Type `json:"provider_type"`
74+ APIKey string `json:"api_key,omitempty"`
75+ Disabled bool `json:"disabled"`
76+ ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
77+ // used for e.x for vertex to set the project
78+ ExtraParams map[string]string `json:"extra_params,omitempty"`
79+
80+ DefaultLargeModel string `json:"default_large_model,omitempty"`
81+ DefaultSmallModel string `json:"default_small_model,omitempty"`
82+
83+ Models []Model `json:"models,omitempty"`
84+}
85+
86+type Agent struct {
87+ ID AgentID `json:"id"`
88+ Name string `json:"name"`
89+ Description string `json:"description,omitempty"`
90+ // This is the id of the system prompt used by the agent
91+ Disabled bool `json:"disabled"`
92+
93+ Provider provider.InferenceProvider `json:"provider"`
94+ Model string `json:"model"`
95+
96+ // The available tools for the agent
97+ // if this is nil, all tools are available
98+ AllowedTools []string `json:"allowed_tools"`
99+
100+ // this tells us which MCPs are available for this agent
101+ // if this is empty all mcps are available
102+ // the string array is the list of tools from the AllowedMCP the agent has available
103+ // if the string array is nil, all tools from the AllowedMCP are available
104+ AllowedMCP map[string][]string `json:"allowed_mcp"`
105+
106+ // The list of LSPs that this agent can use
107+ // if this is nil, all LSPs are available
108+ AllowedLSP []string `json:"allowed_lsp"`
109+
110+ // Overrides the context paths for this agent
111+ ContextPaths []string `json:"context_paths"`
112+}
113+
114+type MCPType string
115+
116+const (
117+ MCPStdio MCPType = "stdio"
118+ MCPSse MCPType = "sse"
119+)
120+
121+type MCP struct {
122+ Command string `json:"command"`
123+ Env []string `json:"env"`
124+ Args []string `json:"args"`
125+ Type MCPType `json:"type"`
126+ URL string `json:"url"`
127+ Headers map[string]string `json:"headers"`
62128}
63129
64−// LSPConfig defines configuration for Language Server Protocol integration.
65130type LSPConfig struct {
66131 Disabled bool `json:"enabled"`
67132 Command string `json:"command"`
@@ -69,98 +134,72 @@ type LSPConfig struct {
69134 Options any `json:"options"`
70135}
71136
72−// TUIConfig defines the configuration for the Terminal User Interface.
73−type TUIConfig struct {
74− Theme string `json:"theme,omitempty"`
137+type TUIOptions struct {
138+ CompactMode bool `json:"compact_mode"`
139+ // Here we can add themes later or any TUI related options
75140}
76141
77−// Config is the main configuration structure for the application.
78−type Config struct {
79− Data Data `json:"data"`
80− WorkingDir string `json:"wd,omitempty"`
81− MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
82− Providers map[models.InferenceProvider]Provider `json:"providers,omitempty"`
83− LSP map[string]LSPConfig `json:"lsp,omitempty"`
84− Agents map[AgentName]Agent `json:"agents,omitempty"`
85− Debug bool `json:"debug,omitempty"`
86− DebugLSP bool `json:"debugLSP,omitempty"`
87− ContextPaths []string `json:"contextPaths,omitempty"`
88− TUI TUIConfig `json:"tui"`
89− AutoCompact bool `json:"autoCompact,omitempty"`
142+type Options struct {
143+ ContextPaths []string `json:"context_paths"`
144+ TUI TUIOptions `json:"tui"`
145+ Debug bool `json:"debug"`
146+ DebugLSP bool `json:"debug_lsp"`
147+ DisableAutoSummarize bool `json:"disable_auto_summarize"`
148+ // Relative to the cwd
149+ DataDirectory string `json:"data_directory"`
90150}
91151
92−// Application constants
93−const (
94− defaultDataDirectory = ".crush"
95− defaultLogLevel = "info"
96− appName = "crush"
97−
98− MaxTokensFallbackDefault = 4096
99−)
152+type PreferredModel struct {
153+ ModelID string `json:"model_id"`
154+ Provider provider.InferenceProvider `json:"provider"`
155+}
100156
101−var defaultContextPaths = []string{
102− ".github/copilot-instructions.md",
103− ".cursorrules",
104− ".cursor/rules/",
105− "CLAUDE.md",
106− "CLAUDE.local.md",
107− "GEMINI.md",
108− "gemini.md",
109− "crush.md",
110− "crush.local.md",
111− "Crush.md",
112− "Crush.local.md",
113− "CRUSH.md",
114− "CRUSH.local.md",
157+type PreferredModels struct {
158+ Large PreferredModel `json:"large"`
159+ Small PreferredModel `json:"small"`
115160}
116161
117−// Global configuration instance
118−var cfg *Config
162+type Config struct {
163+ Models PreferredModels `json:"models"`
164+ // List of configured providers
165+ Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
119166
120−// Load initializes the configuration from environment variables and config files.
121−// If debug is true, debug mode is enabled and log level is set to debug.
122−// It returns an error if configuration loading fails.
123−func Load(workingDir string, debug bool) (*Config, error) {
124− if cfg != nil {
125− return cfg, nil
126− }
167+ // List of configured agents
168+ Agents map[AgentID]Agent `json:"agents,omitempty"`
127169
128− cfg = &Config{
129− WorkingDir: workingDir,
130− MCPServers: make(map[string]MCPServer),
131− Providers: make(map[models.InferenceProvider]Provider),
132− LSP: make(map[string]LSPConfig),
133− }
170+ // List of configured MCPs
171+ MCP map[string]MCP `json:"mcp,omitempty"`
134172
135− configureViper()
136− setDefaults(debug)
173+ // List of configured LSPs
174+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
137175
138− // Read global config
139− if err := readConfig(viper.ReadInConfig()); err != nil {
140− return cfg, err
141− }
176+ // Miscellaneous options
177+ Options Options `json:"options"`
178+}
142179
143− // Load and merge local config
144− mergeLocalConfig(workingDir)
180+var (
181+ instance *Config // The single instance of the Singleton
182+ cwd string
183+ once sync.Once // Ensures the initialization happens only once
145184
146− setProviderDefaults()
185+)
147186
148− // Apply configuration to the struct
149− if err := viper.Unmarshal(cfg); err != nil {
150− return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
151− }
187+func loadConfig(cwd string, debug bool) (*Config, error) {
188+ // First read the global config file
189+ cfgPath := ConfigPath()
152190
153− applyDefaultValues()
191+ cfg := defaultConfigBasedOnEnv()
192+ cfg.Options.Debug = debug
154193 defaultLevel := slog.LevelInfo
155− if cfg.Debug {
194+ if cfg.Options.Debug {
156195 defaultLevel = slog.LevelDebug
157196 }
158197 if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
159− loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
198+ loggingFile := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "debug.log")
160199
161200 // if file does not exist create it
162201 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
163− if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
202+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o755); err != nil {
164203 return cfg, fmt.Errorf("failed to create directory: %w", err)
165204 }
166205 if _, err := os.Create(loggingFile); err != nil {
@@ -184,734 +223,530 @@ func Load(workingDir string, debug bool) (*Config, error) {
184223 }))
185224 slog.SetDefault(logger)
186225 }
187−
188− // Validate configuration
189− if err := Validate(); err != nil {
190− return cfg, fmt.Errorf("config validation failed: %w", err)
191− }
192−
193− if cfg.Agents == nil {
194− cfg.Agents = make(map[AgentName]Agent)
195− }
196−
197− // Override the max tokens for title agent
198− cfg.Agents[AgentTitle] = Agent{
199− Model: cfg.Agents[AgentTitle].Model,
200− MaxTokens: 80,
201− }
202− return cfg, nil
203−}
204−
205−type configFinder struct {
206− appName string
207− dotPrefix bool
208− paths []string
209−}
210−
211−func (f configFinder) Find(fsys afero.Fs) ([]string, error) {
212− var configFiles []string
213− configName := fmt.Sprintf("%s.json", f.appName)
214− if f.dotPrefix {
215− configName = fmt.Sprintf(".%s.json", f.appName)
216− }
217− paths := []string{}
218− for _, p := range f.paths {
219− if p == "" {
220− continue
226+ var globalCfg *Config
227+ if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
228+ // some other error occurred while checking the file
229+ return nil, err
230+ } else if err == nil {
231+ // config file exists, read it
232+ file, err := os.ReadFile(cfgPath)
233+ if err != nil {
234+ return nil, err
221235 }
222− paths = append(paths, os.ExpandEnv(p))
223− }
224−
225− for _, path := range paths {
226− if path == "" {
227− continue
236+ globalCfg = &Config{}
237+ if err := json.Unmarshal(file, globalCfg); err != nil {
238+ return nil, err
228239 }
229−
230− configPath := filepath.Join(path, configName)
231− if exists, err := afero.Exists(fsys, configPath); err == nil && exists {
232− configFiles = append(configFiles, configPath)
240+ } else {
241+ // config file does not exist, create a new one
242+ globalCfg = &Config{}
243+ }
244+
245+ var localConfig *Config
246+ // Global config loaded, now read the local config file
247+ localConfigPath := filepath.Join(cwd, "crush.json")
248+ if _, err := os.Stat(localConfigPath); err != nil && !os.IsNotExist(err) {
249+ // some other error occurred while checking the file
250+ return nil, err
251+ } else if err == nil {
252+ // local config file exists, read it
253+ file, err := os.ReadFile(localConfigPath)
254+ if err != nil {
255+ return nil, err
256+ }
257+ localConfig = &Config{}
258+ if err := json.Unmarshal(file, localConfig); err != nil {
259+ return nil, err
233260 }
234261 }
235− return configFiles, nil
236−}
237262
238−// configureViper sets up viper's configuration paths and environment variables.
239−func configureViper() {
240− viper.SetConfigType("json")
241−
242− // Create the three finders
243− windowsFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
244− "$USERPROFILE",
245− fmt.Sprintf("$APPDATA/%s", appName),
246− fmt.Sprintf("$LOCALAPPDATA/%s", appName),
247− }}
248−
249− unixFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
250− "$HOME",
251− fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName),
252− fmt.Sprintf("$HOME/.config/%s", appName),
253− }}
254−
255− localFinder := configFinder{appName: appName, dotPrefix: true, paths: []string{
256− ".",
257− }}
258−
259− // Use all finders with viper
260− viper.SetOptions(viper.WithFinder(viper.Finders(windowsFinder, unixFinder, localFinder)))
261− viper.SetEnvPrefix(strings.ToUpper(appName))
262− viper.AutomaticEnv()
263−}
263+ // merge options
264+ mergeOptions(cfg, globalCfg, localConfig)
264265
265−// setDefaults configures default values for configuration options.
266−func setDefaults(debug bool) {
267− viper.SetDefault("data.directory", defaultDataDirectory)
268− viper.SetDefault("contextPaths", defaultContextPaths)
269− viper.SetDefault("tui.theme", "crush")
270− viper.SetDefault("autoCompact", true)
271−
272− if debug {
273− viper.SetDefault("debug", true)
274− viper.Set("log.level", "debug")
275− } else {
276− viper.SetDefault("debug", false)
277− viper.SetDefault("log.level", defaultLogLevel)
266+ mergeProviderConfigs(cfg, globalCfg, localConfig)
267+ // no providers found the app is not initialized yet
268+ if len(cfg.Providers) == 0 {
269+ return cfg, nil
278270 }
279−}
271+ preferredProvider := getPreferredProvider(cfg.Providers)
272+ cfg.Models = PreferredModels{
273+ Large: PreferredModel{
274+ ModelID: preferredProvider.DefaultLargeModel,
275+ Provider: preferredProvider.ID,
276+ },
277+ Small: PreferredModel{
278+ ModelID: preferredProvider.DefaultSmallModel,
279+ Provider: preferredProvider.ID,
280+ },
281+ }
282+
283+ mergeModels(cfg, globalCfg, localConfig)
284+
285+ if preferredProvider == nil {
286+ return nil, errors.New("no valid providers configured")
287+ }
288+
289+ agents := map[AgentID]Agent{
290+ AgentCoder: {
291+ ID: AgentCoder,
292+ Name: "Coder",
293+ Description: "An agent that helps with executing coding tasks.",
294+ Provider: cfg.Models.Large.Provider,
295+ Model: cfg.Models.Large.ModelID,
296+ ContextPaths: cfg.Options.ContextPaths,
297+ // All tools allowed
298+ },
299+ AgentTask: {
300+ ID: AgentTask,
301+ Name: "Task",
302+ Description: "An agent that helps with searching for context and finding implementation details.",
303+ Provider: cfg.Models.Large.Provider,
304+ Model: cfg.Models.Large.ModelID,
305+ ContextPaths: cfg.Options.ContextPaths,
306+ AllowedTools: []string{
307+ "glob",
308+ "grep",
309+ "ls",
310+ "sourcegraph",
311+ "view",
312+ },
313+ // NO MCPs or LSPs by default
314+ AllowedMCP: map[string][]string{},
315+ AllowedLSP: []string{},
316+ },
317+ }
318+ cfg.Agents = agents
319+ mergeAgents(cfg, globalCfg, localConfig)
320+ mergeMCPs(cfg, globalCfg, localConfig)
321+ mergeLSPs(cfg, globalCfg, localConfig)
280322
281−// setProviderDefaults configures LLM provider defaults based on provider provided by
282−// environment variables and configuration file.
283−func setProviderDefaults() {
284− // Set all API keys we can find in the environment
285− if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
286− viper.SetDefault("providers.anthropic.apiKey", apiKey)
287− }
288− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
289− viper.SetDefault("providers.openai.apiKey", apiKey)
290− }
291− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
292− viper.SetDefault("providers.gemini.apiKey", apiKey)
293− }
294− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
295− viper.SetDefault("providers.groq.apiKey", apiKey)
296− }
297− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
298− viper.SetDefault("providers.openrouter.apiKey", apiKey)
299− }
300− if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
301− viper.SetDefault("providers.xai.apiKey", apiKey)
302− }
303− if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" {
304− // api-key may be empty when using Entra ID credentials – that's okay
305− viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
306− }
307−
308− // Use this order to set the default models
309− // 1. Anthropic
310− // 2. OpenAI
311− // 3. Google Gemini
312− // 4. Groq
313− // 5. OpenRouter
314− // 6. AWS Bedrock
315− // 7. Azure
316− // 8. Google Cloud VertexAI
317−
318− // Anthropic configuration
319− if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
320− viper.SetDefault("agents.coder.model", models.Claude4Sonnet)
321− viper.SetDefault("agents.summarizer.model", models.Claude4Sonnet)
322− viper.SetDefault("agents.task.model", models.Claude4Sonnet)
323− viper.SetDefault("agents.title.model", models.Claude4Sonnet)
324− return
325− }
326−
327− // OpenAI configuration
328− if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
329− viper.SetDefault("agents.coder.model", models.GPT41)
330− viper.SetDefault("agents.summarizer.model", models.GPT41)
331− viper.SetDefault("agents.task.model", models.GPT41Mini)
332− viper.SetDefault("agents.title.model", models.GPT41Mini)
333− return
334− }
335−
336− // Google Gemini configuration
337− if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
338− viper.SetDefault("agents.coder.model", models.Gemini25)
339− viper.SetDefault("agents.summarizer.model", models.Gemini25)
340− viper.SetDefault("agents.task.model", models.Gemini25Flash)
341− viper.SetDefault("agents.title.model", models.Gemini25Flash)
342− return
343− }
344−
345− // Groq configuration
346− if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
347− viper.SetDefault("agents.coder.model", models.QWENQwq)
348− viper.SetDefault("agents.summarizer.model", models.QWENQwq)
349− viper.SetDefault("agents.task.model", models.QWENQwq)
350− viper.SetDefault("agents.title.model", models.QWENQwq)
351− return
352− }
353−
354− // OpenRouter configuration
355− if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
356− viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
357− viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
358− viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
359− viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
360− return
361− }
362−
363− // XAI configuration
364− if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
365− viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
366− viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
367− viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
368− viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
369− return
370− }
371−
372− // AWS Bedrock configuration
373− if hasAWSCredentials() {
374− viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
375− viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
376− viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
377− viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
378− return
379− }
380−
381− // Azure OpenAI configuration
382− if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
383− viper.SetDefault("agents.coder.model", models.AzureGPT41)
384− viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
385− viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
386− viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
387− return
388− }
389−
390− // Google Cloud VertexAI configuration
391− if hasVertexAICredentials() {
392− viper.SetDefault("agents.coder.model", models.VertexAIGemini25)
393− viper.SetDefault("agents.summarizer.model", models.VertexAIGemini25)
394− viper.SetDefault("agents.task.model", models.VertexAIGemini25Flash)
395− viper.SetDefault("agents.title.model", models.VertexAIGemini25Flash)
396− return
397− }
323+ return cfg, nil
398324}
399325
400−// hasAWSCredentials checks if AWS credentials are available in the environment.
401−func hasAWSCredentials() bool {
402− // Check for explicit AWS credentials
403− if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
404− return true
405− }
406−
407− // Check for AWS profile
408− if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
409− return true
410− }
326+func Init(workingDir string, debug bool) (*Config, error) {
327+ var err error
328+ once.Do(func() {
329+ cwd = workingDir
330+ instance, err = loadConfig(cwd, debug)
331+ if err != nil {
332+ logging.Error("Failed to load config", "error", err)
333+ }
334+ })
411335
412− // Check for AWS region
413− if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
414− return true
415− }
336+ return instance, err
337+}
416338
417− // Check if running on EC2 with instance profile
418− if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
419− os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
420− return true
339+func Get() *Config {
340+ if instance == nil {
341+ // TODO: Handle this better
342+ panic("Config not initialized. Call InitConfig first.")
421343 }
422−
423− return false
344+ return instance
424345}
425346
426−// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
427−func hasVertexAICredentials() bool {
428− // Check for explicit VertexAI parameters
429− if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
430− return true
347+func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
348+ providers := Providers()
349+ for _, p := range providers {
350+ if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
351+ return &providerConfig
352+ }
431353 }
432− // Check for Google Cloud project and location
433− if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
434− return true
354+ // if none found return the first configured provider
355+ for _, providerConfig := range configuredProviders {
356+ if !providerConfig.Disabled {
357+ return &providerConfig
358+ }
435359 }
436− return false
360+ return nil
437361}
438362
439−// readConfig handles the result of reading a configuration file.
440−func readConfig(err error) error {
441− if err == nil {
442− return nil
363+func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
364+ if other.APIKey != "" {
365+ base.APIKey = other.APIKey
443366 }
444−
445− // It's okay if the config file doesn't exist
446− if _, ok := err.(viper.ConfigFileNotFoundError); ok {
447− return nil
367+ // Only change these options if the provider is not a known provider
368+ if !slices.Contains(provider.KnownProviders(), p) {
369+ if other.BaseURL != "" {
370+ base.BaseURL = other.BaseURL
371+ }
372+ if other.ProviderType != "" {
373+ base.ProviderType = other.ProviderType
374+ }
375+ if len(base.ExtraHeaders) > 0 {
376+ if base.ExtraHeaders == nil {
377+ base.ExtraHeaders = make(map[string]string)
378+ }
379+ maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
380+ }
381+ if len(other.ExtraParams) > 0 {
382+ if base.ExtraParams == nil {
383+ base.ExtraParams = make(map[string]string)
384+ }
385+ maps.Copy(base.ExtraParams, other.ExtraParams)
386+ }
448387 }
449388
450− return fmt.Errorf("failed to read config: %w", err)
451−}
452−
453−// mergeLocalConfig loads and merges configuration from the local directory.
454−func mergeLocalConfig(workingDir string) {
455− local := viper.New()
456− local.SetConfigName(fmt.Sprintf(".%s", appName))
457− local.SetConfigType("json")
458− local.AddConfigPath(workingDir)
459−
460− // Merge local config if it exists
461− if err := local.ReadInConfig(); err == nil {
462− viper.MergeConfigMap(local.AllSettings())
389+ if other.Disabled {
390+ base.Disabled = other.Disabled
463391 }
464−}
465392
466−// applyDefaultValues sets default values for configuration fields that need processing.
467−func applyDefaultValues() {
468− // Set default MCP type if not specified
469− for k, v := range cfg.MCPServers {
470− if v.Type == "" {
471− v.Type = MCPStdio
472− cfg.MCPServers[k] = v
473− }
393+ if other.DefaultLargeModel != "" {
394+ base.DefaultLargeModel = other.DefaultLargeModel
474395 }
475−}
476−
477−// It validates model IDs and providers, ensuring they are supported.
478−func validateAgent(cfg *Config, name AgentName, agent Agent) error {
479− // Check if model exists
480− model, modelExists := models.SupportedModels[agent.Model]
481− if !modelExists {
482− logging.Warn("unsupported model configured, reverting to default",
483− "agent", name,
484− "configured_model", agent.Model)
485−
486− // Set default model based on available providers
487− if setDefaultModelForAgent(name) {
488− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
489− } else {
490− return fmt.Errorf("no valid provider available for agent %s", name)
491− }
492− return nil
493− }
494−
495− // Check if provider for the model is configured
496− provider := model.Provider
497− providerCfg, providerExists := cfg.Providers[provider]
498−
499− if !providerExists {
500− // Provider not configured, check if we have environment variables
501− apiKey := getProviderAPIKey(provider)
502− if apiKey == "" {
503− logging.Warn("provider not configured for model, reverting to default",
504− "agent", name,
505− "model", agent.Model,
506− "provider", provider)
507−
508− // Set default model based on available providers
509− if setDefaultModelForAgent(name) {
510− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
511− } else {
512− return fmt.Errorf("no valid provider available for agent %s", name)
396+ // Add new models if they don't exist
397+ if other.Models != nil {
398+ for _, model := range other.Models {
399+ // check if the model already exists
400+ exists := false
401+ for _, existingModel := range base.Models {
402+ if existingModel.ID == model.ID {
403+ exists = true
404+ break
405+ }
513406 }
514− } else {
515− // Add provider with API key from environment
516− cfg.Providers[provider] = Provider{
517− APIKey: apiKey,
518− }
519− logging.Info("added provider from environment", "provider", provider)
520− }
521− } else if providerCfg.Disabled || providerCfg.APIKey == "" {
522− // Provider is disabled or has no API key
523− logging.Warn("provider is disabled or has no API key, reverting to default",
524− "agent", name,
525− "model", agent.Model,
526− "provider", provider)
527−
528− // Set default model based on available providers
529− if setDefaultModelForAgent(name) {
530− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
531− } else {
532− return fmt.Errorf("no valid provider available for agent %s", name)
533− }
534− }
535−
536− // Validate max tokens
537− if agent.MaxTokens <= 0 {
538− logging.Warn("invalid max tokens, setting to default",
539− "agent", name,
540− "model", agent.Model,
541− "max_tokens", agent.MaxTokens)
542−
543− // Update the agent with default max tokens
544− updatedAgent := cfg.Agents[name]
545− if model.DefaultMaxTokens > 0 {
546− updatedAgent.MaxTokens = model.DefaultMaxTokens
547− } else {
548− updatedAgent.MaxTokens = MaxTokensFallbackDefault
549− }
550− cfg.Agents[name] = updatedAgent
551− } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
552− // Ensure max tokens doesn't exceed half the context window (reasonable limit)
553− logging.Warn("max tokens exceeds half the context window, adjusting",
554− "agent", name,
555− "model", agent.Model,
556− "max_tokens", agent.MaxTokens,
557− "context_window", model.ContextWindow)
558−
559− // Update the agent with adjusted max tokens
560− updatedAgent := cfg.Agents[name]
561− updatedAgent.MaxTokens = model.ContextWindow / 2
562− cfg.Agents[name] = updatedAgent
563− }
564−
565− // Validate reasoning effort for models that support reasoning
566− if model.CanReason && provider == models.ProviderOpenAI || provider == models.ProviderLocal {
567− if agent.ReasoningEffort == "" {
568− // Set default reasoning effort for models that support it
569− logging.Info("setting default reasoning effort for model that supports reasoning",
570− "agent", name,
571− "model", agent.Model)
572−
573− // Update the agent with default reasoning effort
574− updatedAgent := cfg.Agents[name]
575− updatedAgent.ReasoningEffort = "medium"
576− cfg.Agents[name] = updatedAgent
577− } else {
578− // Check if reasoning effort is valid (low, medium, high)
579− effort := strings.ToLower(agent.ReasoningEffort)
580− if effort != "low" && effort != "medium" && effort != "high" {
581− logging.Warn("invalid reasoning effort, setting to medium",
582− "agent", name,
583− "model", agent.Model,
584− "reasoning_effort", agent.ReasoningEffort)
585−
586− // Update the agent with valid reasoning effort
587− updatedAgent := cfg.Agents[name]
588− updatedAgent.ReasoningEffort = "medium"
589− cfg.Agents[name] = updatedAgent
407+ if !exists {
408+ base.Models = append(base.Models, model)
590409 }
591410 }
592− } else if !model.CanReason && agent.ReasoningEffort != "" {
593− // Model doesn't support reasoning but reasoning effort is set
594− logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
595− "agent", name,
596− "model", agent.Model,
597− "reasoning_effort", agent.ReasoningEffort)
598−
599− // Update the agent to remove reasoning effort
600− updatedAgent := cfg.Agents[name]
601− updatedAgent.ReasoningEffort = ""
602− cfg.Agents[name] = updatedAgent
603411 }
604412
605− return nil
413+ return base
606414}
607415
608−// Validate checks if the configuration is valid and applies defaults where needed.
609−func Validate() error {
610− if cfg == nil {
611− return fmt.Errorf("config not loaded")
612− }
613−
614− // Validate agent models
615− for name, agent := range cfg.Agents {
616− if err := validateAgent(cfg, name, agent); err != nil {
617− return err
416+func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
417+ if !slices.Contains(provider.KnownProviders(), p) {
418+ if providerConfig.ProviderType != provider.TypeOpenAI {
419+ return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
618420 }
619− }
620−
621− // Validate providers
622− for provider, providerCfg := range cfg.Providers {
623− if providerCfg.APIKey == "" && !providerCfg.Disabled {
624− logging.Warn("provider has no API key, marking as disabled", "provider", provider)
625− providerCfg.Disabled = true
626− cfg.Providers[provider] = providerCfg
421+ if providerConfig.BaseURL == "" {
422+ return errors.New("base URL must be set for custom providers")
627423 }
628− }
629−
630− // Validate LSP configurations
631− for language, lspConfig := range cfg.LSP {
632− if lspConfig.Command == "" && !lspConfig.Disabled {
633− logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
634− lspConfig.Disabled = true
635− cfg.LSP[language] = lspConfig
424+ if providerConfig.APIKey == "" {
425+ return errors.New("API key must be set for custom providers")
636426 }
637427 }
638−
639428 return nil
640429}
641430
642−// getProviderAPIKey gets the API key for a provider from environment variables
643−func getProviderAPIKey(provider models.InferenceProvider) string {
644− switch provider {
645− case models.ProviderAnthropic:
646− return os.Getenv("ANTHROPIC_API_KEY")
647− case models.ProviderOpenAI:
648− return os.Getenv("OPENAI_API_KEY")
649− case models.ProviderGemini:
650− return os.Getenv("GEMINI_API_KEY")
651− case models.ProviderGROQ:
652− return os.Getenv("GROQ_API_KEY")
653− case models.ProviderAzure:
654− return os.Getenv("AZURE_OPENAI_API_KEY")
655− case models.ProviderOpenRouter:
656− return os.Getenv("OPENROUTER_API_KEY")
657− case models.ProviderBedrock:
658− if hasAWSCredentials() {
659− return "aws-credentials-available"
660− }
661− case models.ProviderVertexAI:
662− if hasVertexAICredentials() {
663− return "vertex-ai-credentials-available"
664− }
665− }
666− return ""
667−}
668−
669−// setDefaultModelForAgent sets a default model for an agent based on available providers
670−func setDefaultModelForAgent(agent AgentName) bool {
671− // Check providers in order of preference
672− if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
673− maxTokens := int64(5000)
674− if agent == AgentTitle {
675− maxTokens = 80
431+func mergeModels(base, global, local *Config) {
432+ for _, cfg := range []*Config{global, local} {
433+ if cfg == nil {
434+ continue
676435 }
677− cfg.Agents[agent] = Agent{
678− Model: models.Claude37Sonnet,
679− MaxTokens: maxTokens,
436+ if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
437+ base.Models.Large = cfg.Models.Large
680438 }
681− return true
682− }
683439
684− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
685− var model models.ModelID
686− maxTokens := int64(5000)
687− reasoningEffort := ""
688−
689− switch agent {
690− case AgentTitle:
691− model = models.GPT41Mini
692− maxTokens = 80
693− case AgentTask:
694− model = models.GPT41Mini
695− default:
696− model = models.GPT41
440+ if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
441+ base.Models.Small = cfg.Models.Small
697442 }
443+ }
444+}
698445
699− // Check if model supports reasoning
700− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
701− reasoningEffort = "medium"
446+func mergeOptions(base, global, local *Config) {
447+ for _, cfg := range []*Config{global, local} {
448+ if cfg == nil {
449+ continue
450+ }
451+ baseOptions := base.Options
452+ other := cfg.Options
453+ if len(other.ContextPaths) > 0 {
454+ baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
702455 }
703456
704− cfg.Agents[agent] = Agent{
705− Model: model,
706− MaxTokens: maxTokens,
707− ReasoningEffort: reasoningEffort,
457+ if other.TUI.CompactMode {
458+ baseOptions.TUI.CompactMode = other.TUI.CompactMode
708459 }
709− return true
710− }
711460
712− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
713− var model models.ModelID
714− maxTokens := int64(5000)
715− reasoningEffort := ""
461+ if other.Debug {
462+ baseOptions.Debug = other.Debug
463+ }
716464
717− switch agent {
718− case AgentTitle:
719− model = models.OpenRouterClaude35Haiku
720− maxTokens = 80
721− case AgentTask:
722− model = models.OpenRouterClaude37Sonnet
723− default:
724− model = models.OpenRouterClaude37Sonnet
465+ if other.DebugLSP {
466+ baseOptions.DebugLSP = other.DebugLSP
725467 }
726468
727− // Check if model supports reasoning
728− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
729− reasoningEffort = "medium"
469+ if other.DisableAutoSummarize {
470+ baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
730471 }
731472
732− cfg.Agents[agent] = Agent{
733− Model: model,
734− MaxTokens: maxTokens,
735− ReasoningEffort: reasoningEffort,
473+ if other.DataDirectory != "" {
474+ baseOptions.DataDirectory = other.DataDirectory
736475 }
737− return true
476+ base.Options = baseOptions
738477 }
478+}
739479
740− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
741− var model models.ModelID
742− maxTokens := int64(5000)
743−
744− if agent == AgentTitle {
745− model = models.Gemini25Flash
746− maxTokens = 80
747− } else {
748− model = models.Gemini25
480+func mergeAgents(base, global, local *Config) {
481+ for _, cfg := range []*Config{global, local} {
482+ if cfg == nil {
483+ continue
749484 }
750−
751− cfg.Agents[agent] = Agent{
752− Model: model,
753− MaxTokens: maxTokens,
485+ for agentID, newAgent := range cfg.Agents {
486+ if _, ok := base.Agents[agentID]; !ok {
487+ newAgent.ID = agentID // Ensure the ID is set correctly
488+ base.Agents[agentID] = newAgent
489+ } else {
490+ switch agentID {
491+ case AgentCoder:
492+ baseAgent := base.Agents[agentID]
493+ if newAgent.Model != "" && newAgent.Provider != "" {
494+ baseAgent.Model = newAgent.Model
495+ baseAgent.Provider = newAgent.Provider
496+ }
497+ baseAgent.AllowedMCP = newAgent.AllowedMCP
498+ baseAgent.AllowedLSP = newAgent.AllowedLSP
499+ base.Agents[agentID] = baseAgent
500+ default:
501+ baseAgent := base.Agents[agentID]
502+ baseAgent.Name = newAgent.Name
503+ baseAgent.Description = newAgent.Description
504+ baseAgent.Disabled = newAgent.Disabled
505+ if newAgent.Model == "" || newAgent.Provider == "" {
506+ baseAgent.Provider = base.Models.Large.Provider
507+ baseAgent.Model = base.Models.Large.ModelID
508+ }
509+ baseAgent.AllowedTools = newAgent.AllowedTools
510+ baseAgent.AllowedMCP = newAgent.AllowedMCP
511+ baseAgent.AllowedLSP = newAgent.AllowedLSP
512+ base.Agents[agentID] = baseAgent
513+ }
514+ }
754515 }
755− return true
756516 }
517+}
757518
758− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
759− maxTokens := int64(5000)
760− if agent == AgentTitle {
761− maxTokens = 80
519+func mergeMCPs(base, global, local *Config) {
520+ for _, cfg := range []*Config{global, local} {
521+ if cfg == nil {
522+ continue
762523 }
524+ maps.Copy(base.MCP, cfg.MCP)
525+ }
526+}
763527
764− cfg.Agents[agent] = Agent{
765− Model: models.QWENQwq,
766− MaxTokens: maxTokens,
528+func mergeLSPs(base, global, local *Config) {
529+ for _, cfg := range []*Config{global, local} {
530+ if cfg == nil {
531+ continue
767532 }
768− return true
533+ maps.Copy(base.LSP, cfg.LSP)
769534 }
535+}
770536
771− if hasAWSCredentials() {
772− maxTokens := int64(5000)
773− if agent == AgentTitle {
774− maxTokens = 80
537+func mergeProviderConfigs(base, global, local *Config) {
538+ for _, cfg := range []*Config{global, local} {
539+ if cfg == nil {
540+ continue
775541 }
776−
777− cfg.Agents[agent] = Agent{
778− Model: models.BedrockClaude37Sonnet,
779− MaxTokens: maxTokens,
780− ReasoningEffort: "medium", // Claude models support reasoning
542+ for providerName, globalProvider := range cfg.Providers {
543+ if _, ok := base.Providers[providerName]; !ok {
544+ base.Providers[providerName] = globalProvider
545+ } else {
546+ base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], globalProvider)
547+ }
781548 }
782− return true
783549 }
784550
785− if hasVertexAICredentials() {
786− var model models.ModelID
787− maxTokens := int64(5000)
788−
789− if agent == AgentTitle {
790− model = models.VertexAIGemini25Flash
791− maxTokens = 80
792− } else {
793− model = models.VertexAIGemini25
551+ finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
552+ for providerName, providerConfig := range base.Providers {
553+ err := validateProvider(providerName, providerConfig)
554+ if err != nil {
555+ logging.Warn("Skipping provider", "name", providerName, "error", err)
794556 }
557+ finalProviders[providerName] = providerConfig
558+ }
559+ base.Providers = finalProviders
560+}
795561
796− cfg.Agents[agent] = Agent{
797− Model: model,
798− MaxTokens: maxTokens,
562+func providerDefaultConfig(providerId provider.InferenceProvider) ProviderConfig {
563+ switch providerId {
564+ case provider.InferenceProviderAnthropic:
565+ return ProviderConfig{
566+ ID: providerId,
567+ ProviderType: provider.TypeAnthropic,
568+ }
569+ case provider.InferenceProviderOpenAI:
570+ return ProviderConfig{
571+ ID: providerId,
572+ ProviderType: provider.TypeOpenAI,
573+ }
574+ case provider.InferenceProviderGemini:
575+ return ProviderConfig{
576+ ID: providerId,
577+ ProviderType: provider.TypeGemini,
578+ }
579+ case provider.InferenceProviderBedrock:
580+ return ProviderConfig{
581+ ID: providerId,
582+ ProviderType: provider.TypeBedrock,
583+ }
584+ case provider.InferenceProviderAzure:
585+ return ProviderConfig{
586+ ID: providerId,
587+ ProviderType: provider.TypeAzure,
588+ }
589+ case provider.InferenceProviderOpenRouter:
590+ return ProviderConfig{
591+ ID: providerId,
592+ ProviderType: provider.TypeOpenAI,
593+ BaseURL: "https://openrouter.ai/api/v1",
594+ ExtraHeaders: map[string]string{
595+ "HTTP-Referer": "crush.charm.land",
596+ "X-Title": "Crush",
597+ },
598+ }
599+ case provider.InferenceProviderXAI:
600+ return ProviderConfig{
601+ ID: providerId,
602+ ProviderType: provider.TypeXAI,
603+ BaseURL: "https://api.x.ai/v1",
604+ }
605+ case provider.InferenceProviderVertexAI:
606+ return ProviderConfig{
607+ ID: providerId,
608+ ProviderType: provider.TypeVertexAI,
609+ }
610+ default:
611+ return ProviderConfig{
612+ ID: providerId,
613+ ProviderType: provider.TypeOpenAI,
799614 }
800− return true
801615 }
802−
803− return false
804616}
805617
806−func updateCfgFile(updateCfg func(config *Config)) error {
807− if cfg == nil {
808− return fmt.Errorf("config not loaded")
618+func defaultConfigBasedOnEnv() *Config {
619+ cfg := &Config{
620+ Options: Options{
621+ DataDirectory: defaultDataDirectory,
622+ ContextPaths: defaultContextPaths,
623+ },
624+ Providers: make(map[provider.InferenceProvider]ProviderConfig),
625+ }
626+
627+ providers := Providers()
628+
629+ for _, p := range providers {
630+ if strings.HasPrefix(p.APIKey, "$") {
631+ envVar := strings.TrimPrefix(p.APIKey, "$")
632+ if apiKey := os.Getenv(envVar); apiKey != "" {
633+ providerConfig := providerDefaultConfig(p.ID)
634+ providerConfig.APIKey = apiKey
635+ providerConfig.DefaultLargeModel = p.DefaultLargeModelID
636+ providerConfig.DefaultSmallModel = p.DefaultSmallModelID
637+ baseURL := p.APIEndpoint
638+ if strings.HasPrefix(baseURL, "$") {
639+ envVar := strings.TrimPrefix(baseURL, "$")
640+ if url := os.Getenv(envVar); url != "" {
641+ baseURL = url
642+ }
643+ }
644+ providerConfig.BaseURL = baseURL
645+ for _, model := range p.Models {
646+ providerConfig.Models = append(providerConfig.Models, Model{
647+ ID: model.ID,
648+ Name: model.Name,
649+ CostPer1MIn: model.CostPer1MIn,
650+ CostPer1MOut: model.CostPer1MOut,
651+ CostPer1MInCached: model.CostPer1MInCached,
652+ CostPer1MOutCached: model.CostPer1MOutCached,
653+ ContextWindow: model.ContextWindow,
654+ DefaultMaxTokens: model.DefaultMaxTokens,
655+ CanReason: model.CanReason,
656+ SupportsImages: model.SupportsImages,
657+ })
658+ }
659+ cfg.Providers[p.ID] = providerConfig
660+ }
661+ }
809662 }
663+ // TODO: support local models
810664
811− // Get the config file path
812− configFile := viper.ConfigFileUsed()
813− var configData []byte
814− if configFile == "" {
815− homeDir, err := os.UserHomeDir()
816− if err != nil {
817− return fmt.Errorf("failed to get home directory: %w", err)
665+ if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
666+ providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
667+ providerConfig.ExtraParams = map[string]string{
668+ "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
669+ "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
818670 }
819− configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
820− logging.Info("config file not found, creating new one", "path", configFile)
821− configData = []byte(`{}`)
822− } else {
823− // Read the existing config file
824− data, err := os.ReadFile(configFile)
825− if err != nil {
826− return fmt.Errorf("failed to read config file: %w", err)
827− }
828− configData = data
671+ cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
829672 }
830673
831− // Parse the JSON
832− var userCfg *Config
833− if err := json.Unmarshal(configData, &userCfg); err != nil {
834− return fmt.Errorf("failed to parse config file: %w", err)
674+ if hasAWSCredentials() {
675+ providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
676+ providerConfig.ExtraParams = map[string]string{
677+ "region": os.Getenv("AWS_DEFAULT_REGION"),
678+ }
679+ if providerConfig.ExtraParams["region"] == "" {
680+ providerConfig.ExtraParams["region"] = os.Getenv("AWS_REGION")
681+ }
682+ cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
835683 }
684+ return cfg
685+}
836686
837− updateCfg(userCfg)
687+func hasAWSCredentials() bool {
688+ if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
689+ return true
690+ }
838691
839− // Write the updated config back to file
840− updatedData, err := json.MarshalIndent(userCfg, "", " ")
841− if err != nil {
842− return fmt.Errorf("failed to marshal config: %w", err)
692+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
693+ return true
843694 }
844695
845− if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
846− return fmt.Errorf("failed to write config file: %w", err)
696+ if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
697+ return true
847698 }
848699
849− return nil
850−}
700+ if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
701+ os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
702+ return true
703+ }
851704
852−// Get returns the current configuration.
853−// It's safe to call this function multiple times.
854−func Get() *Config {
855− return cfg
705+ return false
856706}
857707
858−// WorkingDirectory returns the current working directory from the configuration.
859708func WorkingDirectory() string {
860− if cfg == nil {
861− panic("config not loaded")
862− }
863− return cfg.WorkingDir
709+ return cwd
864710}
865711
866−func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
867− if cfg == nil {
868− panic("config not loaded")
712+func GetAgentModel(agentID AgentID) Model {
713+ cfg := Get()
714+ agent, ok := cfg.Agents[agentID]
715+ if !ok {
716+ logging.Error("Agent not found", "agent_id", agentID)
717+ return Model{}
869718 }
870719
871− existingAgentCfg := cfg.Agents[agentName]
872−
873− model, ok := models.SupportedModels[modelID]
720+ providerConfig, ok := cfg.Providers[agent.Provider]
874721 if !ok {
875− return fmt.Errorf("model %s not supported", modelID)
722+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", agent.Provider)
723+ return Model{}
876724 }
877725
878− maxTokens := existingAgentCfg.MaxTokens
879− if model.DefaultMaxTokens > 0 {
880− maxTokens = model.DefaultMaxTokens
726+ for _, model := range providerConfig.Models {
727+ if model.ID == agent.Model {
728+ return model
729+ }
881730 }
882731
883− newAgentCfg := Agent{
884− Model: modelID,
885− MaxTokens: maxTokens,
886− ReasoningEffort: existingAgentCfg.ReasoningEffort,
887− }
888− cfg.Agents[agentName] = newAgentCfg
732+ logging.Error("Model not found for agent", "agent_id", agentID, "model", agent.Model)
733+ return Model{}
734+}
889735
890− if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
891− // revert config update on failure
892− cfg.Agents[agentName] = existingAgentCfg
893− return fmt.Errorf("failed to update agent model: %w", err)
736+func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
737+ cfg := Get()
738+ providerConfig, ok := cfg.Providers[provider]
739+ if !ok {
740+ logging.Error("Provider not found", "provider", provider)
741+ return Model{}
894742 }
895743
896− return updateCfgFile(func(config *Config) {
897− if config.Agents == nil {
898− config.Agents = make(map[AgentName]Agent)
744+ for _, model := range providerConfig.Models {
745+ if model.ID == modelID {
746+ return model
899747 }
900− config.Agents[agentName] = newAgentCfg
901− })
902−}
903−
904−// UpdateTheme updates the theme in the configuration and writes it to the config file.
905−func UpdateTheme(themeName string) error {
906− if cfg == nil {
907− return fmt.Errorf("config not loaded")
908748 }
909749
910− // Update the in-memory config
911− cfg.TUI.Theme = themeName
912−
913− // Update the file config
914− return updateCfgFile(func(config *Config) {
915− config.TUI.Theme = themeName
916− })
750+ logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
751+ return Model{}
917752}
internal/config_v2/config_test.go → internal/config/config_test.gorenamed+2−2
@@ -1,4 +1,4 @@
1−package configv2
1+package config
22
33import (
44 "encoding/json"
@@ -28,7 +28,7 @@ func TestConfigWithEnv(t *testing.T) {
2828 os.Setenv("GEMINI_API_KEY", "test-gemini-key")
2929 os.Setenv("XAI_API_KEY", "test-xai-key")
3030 os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
31− cfg := InitConfig(cwdDir)
31+ cfg, _ := Init(cwdDir, false)
3232 data, _ := json.MarshalIndent(cfg, "", " ")
3333 fmt.Println(string(data))
3434 assert.Len(t, cfg.Providers, 5)
internal/config_v2/fs.go → internal/config/fs.gorenamed+1−1
@@ -1,4 +1,4 @@
1−package configv2
1+package config
22
33import (
44 "fmt"
internal/config/init.go+6−13
@@ -17,23 +17,20 @@ type ProjectInitFlag struct {
1717 Initialized bool `json:"initialized"`
1818}
1919
20−// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory
21−func ShouldShowInitDialog() (bool, error) {
22− if cfg == nil {
20+// ProjectNeedsInitialization checks if the current project needs initialization
21+func ProjectNeedsInitialization() (bool, error) {
22+ if instance == nil {
2323 return false, fmt.Errorf("config not loaded")
2424 }
2525
26− // Create the flag file path
27− flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
26+ flagFilePath := filepath.Join(instance.Options.DataDirectory, InitFlagFilename)
2827
2928 // Check if the flag file exists
3029 _, err := os.Stat(flagFilePath)
3130 if err == nil {
32− // File exists, don't show the dialog
3331 return false, nil
3432 }
3533
36− // If the error is not "file not found", return the error
3734 if !os.IsNotExist(err) {
3835 return false, fmt.Errorf("failed to check init flag file: %w", err)
3936 }
@@ -44,11 +41,9 @@ func ShouldShowInitDialog() (bool, error) {
4441 return false, fmt.Errorf("failed to check for CRUSH.md files: %w", err)
4542 }
4643 if crushExists {
47− // CRUSH.md already exists, don't show the dialog
4844 return false, nil
4945 }
5046
51− // File doesn't exist, show the dialog
5247 return true, nil
5348}
5449
@@ -75,13 +70,11 @@ func crushMdExists(dir string) (bool, error) {
7570
7671// MarkProjectInitialized marks the current project as initialized
7772func MarkProjectInitialized() error {
78− if cfg == nil {
73+ if instance == nil {
7974 return fmt.Errorf("config not loaded")
8075 }
81− // Create the flag file path
82− flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
76+ flagFilePath := filepath.Join(instance.Options.DataDirectory, InitFlagFilename)
8377
84− // Create an empty file to mark the project as initialized
8578 file, err := os.Create(flagFilePath)
8679 if err != nil {
8780 return fmt.Errorf("failed to create init flag file: %w", err)
internal/config_v2/provider.go → internal/config/provider.gorenamed+1−1
@@ -1,4 +1,4 @@
1−package configv2
1+package config
22
33import (
44 "encoding/json"
internal/config_v2/config.godeleted−660
@@ -1,660 +0,0 @@
1−package configv2
2−
3−import (
4− "encoding/json"
5− "errors"
6− "maps"
7− "os"
8− "path/filepath"
9− "slices"
10− "strings"
11− "sync"
12−
13− "github.com/charmbracelet/crush/internal/fur/provider"
14− "github.com/charmbracelet/crush/internal/logging"
15−)
16−
17−const (
18− defaultDataDirectory = ".crush"
19− defaultLogLevel = "info"
20− appName = "crush"
21−
22− MaxTokensFallbackDefault = 4096
23−)
24−
25−var defaultContextPaths = []string{
26− ".github/copilot-instructions.md",
27− ".cursorrules",
28− ".cursor/rules/",
29− "CLAUDE.md",
30− "CLAUDE.local.md",
31− "crush.md",
32− "crush.local.md",
33− "Crush.md",
34− "Crush.local.md",
35− "CRUSH.md",
36− "CRUSH.local.md",
37−}
38−
39−type AgentID string
40−
41−const (
42− AgentCoder AgentID = "coder"
43− AgentTask AgentID = "task"
44− AgentTitle AgentID = "title"
45− AgentSummarize AgentID = "summarize"
46−)
47−
48−type Model struct {
49− ID string `json:"id"`
50− Name string `json:"model"`
51− CostPer1MIn float64 `json:"cost_per_1m_in"`
52− CostPer1MOut float64 `json:"cost_per_1m_out"`
53− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
54− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
55− ContextWindow int64 `json:"context_window"`
56− DefaultMaxTokens int64 `json:"default_max_tokens"`
57− CanReason bool `json:"can_reason"`
58− ReasoningEffort string `json:"reasoning_effort"`
59− SupportsImages bool `json:"supports_attachments"`
60−}
61−
62−type VertexAIOptions struct {
63− APIKey string `json:"api_key,omitempty"`
64− Project string `json:"project,omitempty"`
65− Location string `json:"location,omitempty"`
66−}
67−
68−type ProviderConfig struct {
69− ID provider.InferenceProvider `json:"id"`
70− BaseURL string `json:"base_url,omitempty"`
71− ProviderType provider.Type `json:"provider_type"`
72− APIKey string `json:"api_key,omitempty"`
73− Disabled bool `json:"disabled"`
74− ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
75− // used for e.x for vertex to set the project
76− ExtraParams map[string]string `json:"extra_params,omitempty"`
77−
78− DefaultLargeModel string `json:"default_large_model,omitempty"`
79− DefaultSmallModel string `json:"default_small_model,omitempty"`
80−
81− Models []Model `json:"models,omitempty"`
82−}
83−
84−type Agent struct {
85− ID AgentID `json:"id"`
86− Name string `json:"name"`
87− Description string `json:"description,omitempty"`
88− // This is the id of the system prompt used by the agent
89− Disabled bool `json:"disabled"`
90−
91− Provider provider.InferenceProvider `json:"provider"`
92− Model string `json:"model"`
93−
94− // The available tools for the agent
95− // if this is nil, all tools are available
96− AllowedTools []string `json:"allowed_tools"`
97−
98− // this tells us which MCPs are available for this agent
99− // if this is empty all mcps are available
100− // the string array is the list of tools from the AllowedMCP the agent has available
101− // if the string array is nil, all tools from the AllowedMCP are available
102− AllowedMCP map[string][]string `json:"allowed_mcp"`
103−
104− // The list of LSPs that this agent can use
105− // if this is nil, all LSPs are available
106− AllowedLSP []string `json:"allowed_lsp"`
107−
108− // Overrides the context paths for this agent
109− ContextPaths []string `json:"context_paths"`
110−}
111−
112−type MCPType string
113−
114−const (
115− MCPStdio MCPType = "stdio"
116− MCPSse MCPType = "sse"
117−)
118−
119−type MCP struct {
120− Command string `json:"command"`
121− Env []string `json:"env"`
122− Args []string `json:"args"`
123− Type MCPType `json:"type"`
124− URL string `json:"url"`
125− Headers map[string]string `json:"headers"`
126−}
127−
128−type LSPConfig struct {
129− Disabled bool `json:"enabled"`
130− Command string `json:"command"`
131− Args []string `json:"args"`
132− Options any `json:"options"`
133−}
134−
135−type TUIOptions struct {
136− CompactMode bool `json:"compact_mode"`
137− // Here we can add themes later or any TUI related options
138−}
139−
140−type Options struct {
141− ContextPaths []string `json:"context_paths"`
142− TUI TUIOptions `json:"tui"`
143− Debug bool `json:"debug"`
144− DebugLSP bool `json:"debug_lsp"`
145− DisableAutoSummarize bool `json:"disable_auto_summarize"`
146− // Relative to the cwd
147− DataDirectory string `json:"data_directory"`
148−}
149−
150−type Config struct {
151− // List of configured providers
152− Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
153−
154− // List of configured agents
155− Agents map[AgentID]Agent `json:"agents,omitempty"`
156−
157− // List of configured MCPs
158− MCP map[string]MCP `json:"mcp,omitempty"`
159−
160− // List of configured LSPs
161− LSP map[string]LSPConfig `json:"lsp,omitempty"`
162−
163− // Miscellaneous options
164− Options Options `json:"options"`
165−}
166−
167−var (
168− instance *Config // The single instance of the Singleton
169− cwd string
170− once sync.Once // Ensures the initialization happens only once
171−
172−)
173−
174−func loadConfig(cwd string) (*Config, error) {
175− // First read the global config file
176− cfgPath := ConfigPath()
177−
178− cfg := defaultConfigBasedOnEnv()
179−
180− var globalCfg *Config
181− if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
182− // some other error occurred while checking the file
183− return nil, err
184− } else if err == nil {
185− // config file exists, read it
186− file, err := os.ReadFile(cfgPath)
187− if err != nil {
188− return nil, err
189− }
190− globalCfg = &Config{}
191− if err := json.Unmarshal(file, globalCfg); err != nil {
192− return nil, err
193− }
194− } else {
195− // config file does not exist, create a new one
196− globalCfg = &Config{}
197− }
198−
199− var localConfig *Config
200− // Global config loaded, now read the local config file
201− localConfigPath := filepath.Join(cwd, "crush.json")
202− if _, err := os.Stat(localConfigPath); err != nil && !os.IsNotExist(err) {
203− // some other error occurred while checking the file
204− return nil, err
205− } else if err == nil {
206− // local config file exists, read it
207− file, err := os.ReadFile(localConfigPath)
208− if err != nil {
209− return nil, err
210− }
211− localConfig = &Config{}
212− if err := json.Unmarshal(file, localConfig); err != nil {
213− return nil, err
214− }
215− }
216−
217− // merge options
218− mergeOptions(cfg, globalCfg, localConfig)
219−
220− mergeProviderConfigs(cfg, globalCfg, localConfig)
221− // no providers found the app is not initialized yet
222− if len(cfg.Providers) == 0 {
223− return cfg, nil
224− }
225− preferredProvider := getPreferredProvider(cfg.Providers)
226−
227− if preferredProvider == nil {
228− return nil, errors.New("no valid providers configured")
229− }
230−
231− agents := map[AgentID]Agent{
232− AgentCoder: {
233− ID: AgentCoder,
234− Name: "Coder",
235− Description: "An agent that helps with executing coding tasks.",
236− Provider: preferredProvider.ID,
237− Model: preferredProvider.DefaultLargeModel,
238− ContextPaths: cfg.Options.ContextPaths,
239− // All tools allowed
240− },
241− AgentTask: {
242− ID: AgentTask,
243− Name: "Task",
244− Description: "An agent that helps with searching for context and finding implementation details.",
245− Provider: preferredProvider.ID,
246− Model: preferredProvider.DefaultLargeModel,
247− ContextPaths: cfg.Options.ContextPaths,
248− AllowedTools: []string{
249− "glob",
250− "grep",
251− "ls",
252− "sourcegraph",
253− "view",
254− },
255− // NO MCPs or LSPs by default
256− AllowedMCP: map[string][]string{},
257− AllowedLSP: []string{},
258− },
259− AgentTitle: {
260− ID: AgentTitle,
261− Name: "Title",
262− Description: "An agent that helps with generating titles for sessions.",
263− Provider: preferredProvider.ID,
264− Model: preferredProvider.DefaultSmallModel,
265− ContextPaths: cfg.Options.ContextPaths,
266− AllowedTools: []string{},
267− // NO MCPs or LSPs by default
268− AllowedMCP: map[string][]string{},
269− AllowedLSP: []string{},
270− },
271− AgentSummarize: {
272− ID: AgentSummarize,
273− Name: "Summarize",
274− Description: "An agent that helps with summarizing sessions.",
275− Provider: preferredProvider.ID,
276− Model: preferredProvider.DefaultSmallModel,
277− ContextPaths: cfg.Options.ContextPaths,
278− AllowedTools: []string{},
279− // NO MCPs or LSPs by default
280− AllowedMCP: map[string][]string{},
281− AllowedLSP: []string{},
282− },
283− }
284− cfg.Agents = agents
285− mergeAgents(cfg, globalCfg, localConfig)
286− mergeMCPs(cfg, globalCfg, localConfig)
287− mergeLSPs(cfg, globalCfg, localConfig)
288−
289− return cfg, nil
290−}
291−
292−func InitConfig(workingDir string) *Config {
293− once.Do(func() {
294− cwd = workingDir
295− cfg, err := loadConfig(cwd)
296− if err != nil {
297− // TODO: Handle this better
298− panic("Failed to load config: " + err.Error())
299− }
300− instance = cfg
301− })
302−
303− return instance
304−}
305−
306−func GetConfig() *Config {
307− if instance == nil {
308− // TODO: Handle this better
309− panic("Config not initialized. Call InitConfig first.")
310− }
311− return instance
312−}
313−
314−func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
315− providers := Providers()
316− for _, p := range providers {
317− if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
318− return &providerConfig
319− }
320− }
321− // if none found return the first configured provider
322− for _, providerConfig := range configuredProviders {
323− if !providerConfig.Disabled {
324− return &providerConfig
325− }
326− }
327− return nil
328−}
329−
330−func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
331− if other.APIKey != "" {
332− base.APIKey = other.APIKey
333− }
334− // Only change these options if the provider is not a known provider
335− if !slices.Contains(provider.KnownProviders(), p) {
336− if other.BaseURL != "" {
337− base.BaseURL = other.BaseURL
338− }
339− if other.ProviderType != "" {
340− base.ProviderType = other.ProviderType
341− }
342− if len(base.ExtraHeaders) > 0 {
343− if base.ExtraHeaders == nil {
344− base.ExtraHeaders = make(map[string]string)
345− }
346− maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
347− }
348− if len(other.ExtraParams) > 0 {
349− if base.ExtraParams == nil {
350− base.ExtraParams = make(map[string]string)
351− }
352− maps.Copy(base.ExtraParams, other.ExtraParams)
353− }
354− }
355−
356− if other.Disabled {
357− base.Disabled = other.Disabled
358− }
359−
360− if other.DefaultLargeModel != "" {
361− base.DefaultLargeModel = other.DefaultLargeModel
362− }
363− // Add new models if they don't exist
364− if other.Models != nil {
365− for _, model := range other.Models {
366− // check if the model already exists
367− exists := false
368− for _, existingModel := range base.Models {
369− if existingModel.ID == model.ID {
370− exists = true
371− break
372− }
373− }
374− if !exists {
375− base.Models = append(base.Models, model)
376− }
377− }
378− }
379−
380− return base
381−}
382−
383−func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
384− if !slices.Contains(provider.KnownProviders(), p) {
385− if providerConfig.ProviderType != provider.TypeOpenAI {
386− return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
387− }
388− if providerConfig.BaseURL == "" {
389− return errors.New("base URL must be set for custom providers")
390− }
391− if providerConfig.APIKey == "" {
392− return errors.New("API key must be set for custom providers")
393− }
394− }
395− return nil
396−}
397−
398−func mergeOptions(base, global, local *Config) {
399− for _, cfg := range []*Config{global, local} {
400− if cfg == nil {
401− continue
402− }
403− baseOptions := base.Options
404− other := cfg.Options
405− if len(other.ContextPaths) > 0 {
406− baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
407− }
408−
409− if other.TUI.CompactMode {
410− baseOptions.TUI.CompactMode = other.TUI.CompactMode
411− }
412−
413− if other.Debug {
414− baseOptions.Debug = other.Debug
415− }
416−
417− if other.DebugLSP {
418− baseOptions.DebugLSP = other.DebugLSP
419− }
420−
421− if other.DisableAutoSummarize {
422− baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
423− }
424−
425− if other.DataDirectory != "" {
426− baseOptions.DataDirectory = other.DataDirectory
427− }
428− base.Options = baseOptions
429− }
430−}
431−
432−func mergeAgents(base, global, local *Config) {
433− for _, cfg := range []*Config{global, local} {
434− if cfg == nil {
435− continue
436− }
437− for agentID, newAgent := range cfg.Agents {
438− if _, ok := base.Agents[agentID]; !ok {
439− newAgent.ID = agentID // Ensure the ID is set correctly
440− base.Agents[agentID] = newAgent
441− } else {
442− switch agentID {
443− case AgentCoder:
444− baseAgent := base.Agents[agentID]
445− baseAgent.Model = newAgent.Model
446− baseAgent.Provider = newAgent.Provider
447− baseAgent.AllowedMCP = newAgent.AllowedMCP
448− baseAgent.AllowedLSP = newAgent.AllowedLSP
449− base.Agents[agentID] = baseAgent
450− case AgentTask:
451− baseAgent := base.Agents[agentID]
452− baseAgent.Model = newAgent.Model
453− baseAgent.Provider = newAgent.Provider
454− base.Agents[agentID] = baseAgent
455− case AgentTitle:
456− baseAgent := base.Agents[agentID]
457− baseAgent.Model = newAgent.Model
458− baseAgent.Provider = newAgent.Provider
459− base.Agents[agentID] = baseAgent
460− case AgentSummarize:
461− baseAgent := base.Agents[agentID]
462− baseAgent.Model = newAgent.Model
463− baseAgent.Provider = newAgent.Provider
464− base.Agents[agentID] = baseAgent
465− default:
466− baseAgent := base.Agents[agentID]
467− baseAgent.Name = newAgent.Name
468− baseAgent.Description = newAgent.Description
469− baseAgent.Disabled = newAgent.Disabled
470− baseAgent.Provider = newAgent.Provider
471− baseAgent.Model = newAgent.Model
472− baseAgent.AllowedTools = newAgent.AllowedTools
473− baseAgent.AllowedMCP = newAgent.AllowedMCP
474− baseAgent.AllowedLSP = newAgent.AllowedLSP
475− base.Agents[agentID] = baseAgent
476−
477− }
478− }
479− }
480− }
481−}
482−
483−func mergeMCPs(base, global, local *Config) {
484− for _, cfg := range []*Config{global, local} {
485− if cfg == nil {
486− continue
487− }
488− maps.Copy(base.MCP, cfg.MCP)
489− }
490−}
491−
492−func mergeLSPs(base, global, local *Config) {
493− for _, cfg := range []*Config{global, local} {
494− if cfg == nil {
495− continue
496− }
497− maps.Copy(base.LSP, cfg.LSP)
498− }
499−}
500−
501−func mergeProviderConfigs(base, global, local *Config) {
502− for _, cfg := range []*Config{global, local} {
503− if cfg == nil {
504− continue
505− }
506− for providerName, globalProvider := range cfg.Providers {
507− if _, ok := base.Providers[providerName]; !ok {
508− base.Providers[providerName] = globalProvider
509− } else {
510− base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], globalProvider)
511− }
512− }
513− }
514−
515− finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
516− for providerName, providerConfig := range base.Providers {
517− err := validateProvider(providerName, providerConfig)
518− if err != nil {
519− logging.Warn("Skipping provider", "name", providerName, "error", err)
520− }
521− finalProviders[providerName] = providerConfig
522− }
523− base.Providers = finalProviders
524−}
525−
526−func providerDefaultConfig(providerId provider.InferenceProvider) ProviderConfig {
527− switch providerId {
528− case provider.InferenceProviderAnthropic:
529− return ProviderConfig{
530− ID: providerId,
531− ProviderType: provider.TypeAnthropic,
532− }
533− case provider.InferenceProviderOpenAI:
534− return ProviderConfig{
535− ID: providerId,
536− ProviderType: provider.TypeOpenAI,
537− }
538− case provider.InferenceProviderGemini:
539− return ProviderConfig{
540− ID: providerId,
541− ProviderType: provider.TypeGemini,
542− }
543− case provider.InferenceProviderBedrock:
544− return ProviderConfig{
545− ID: providerId,
546− ProviderType: provider.TypeBedrock,
547− }
548− case provider.InferenceProviderAzure:
549− return ProviderConfig{
550− ID: providerId,
551− ProviderType: provider.TypeAzure,
552− }
553− case provider.InferenceProviderOpenRouter:
554− return ProviderConfig{
555− ID: providerId,
556− ProviderType: provider.TypeOpenAI,
557− BaseURL: "https://openrouter.ai/api/v1",
558− ExtraHeaders: map[string]string{
559− "HTTP-Referer": "crush.charm.land",
560− "X-Title": "Crush",
561− },
562− }
563− case provider.InferenceProviderXAI:
564− return ProviderConfig{
565− ID: providerId,
566− ProviderType: provider.TypeXAI,
567− BaseURL: "https://api.x.ai/v1",
568− }
569− case provider.InferenceProviderVertexAI:
570− return ProviderConfig{
571− ID: providerId,
572− ProviderType: provider.TypeVertexAI,
573− }
574− default:
575− return ProviderConfig{
576− ID: providerId,
577− ProviderType: provider.TypeOpenAI,
578− }
579− }
580−}
581−
582−func defaultConfigBasedOnEnv() *Config {
583− cfg := &Config{
584− Options: Options{
585− DataDirectory: defaultDataDirectory,
586− ContextPaths: defaultContextPaths,
587− },
588− Providers: make(map[provider.InferenceProvider]ProviderConfig),
589− }
590−
591− providers := Providers()
592−
593− for _, p := range providers {
594− if strings.HasPrefix(p.APIKey, "$") {
595− envVar := strings.TrimPrefix(p.APIKey, "$")
596− if apiKey := os.Getenv(envVar); apiKey != "" {
597− providerConfig := providerDefaultConfig(p.ID)
598− providerConfig.APIKey = apiKey
599− providerConfig.DefaultLargeModel = p.DefaultLargeModelID
600− providerConfig.DefaultSmallModel = p.DefaultSmallModelID
601− for _, model := range p.Models {
602− providerConfig.Models = append(providerConfig.Models, Model{
603− ID: model.ID,
604− Name: model.Name,
605− CostPer1MIn: model.CostPer1MIn,
606− CostPer1MOut: model.CostPer1MOut,
607− CostPer1MInCached: model.CostPer1MInCached,
608− CostPer1MOutCached: model.CostPer1MOutCached,
609− ContextWindow: model.ContextWindow,
610− DefaultMaxTokens: model.DefaultMaxTokens,
611− CanReason: model.CanReason,
612− SupportsImages: model.SupportsImages,
613− })
614− }
615− cfg.Providers[p.ID] = providerConfig
616− }
617− }
618− }
619− // TODO: support local models
620−
621− if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
622− providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
623− providerConfig.ExtraParams = map[string]string{
624− "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
625− "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
626− }
627− cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
628− }
629−
630− if hasAWSCredentials() {
631− providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
632− cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
633− }
634− return cfg
635−}
636−
637−func hasAWSCredentials() bool {
638− if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
639− return true
640− }
641−
642− if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
643− return true
644− }
645−
646− if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
647− return true
648− }
649−
650− if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
651− os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
652− return true
653− }
654−
655− return false
656−}
657−
658−func WorkingDirectory() string {
659− return cwd
660−}
internal/db/connect.go+2−3
@@ -1,7 +1,6 @@
11package db
22
33import (
4− "context"
54 "database/sql"
65 "fmt"
76 "os"
@@ -16,8 +15,8 @@ import (
1615 "github.com/pressly/goose/v3"
1716)
1817
19−func Connect(ctx context.Context) (*sql.DB, error) {
20− dataDir := config.Get().Data.Directory
18+func Connect() (*sql.DB, error) {
19+ dataDir := config.Get().Options.DataDirectory
2120 if dataDir == "" {
2221 return nil, fmt.Errorf("data.dir is not set")
2322 }
internal/db/messages.sql.go+10−4
@@ -17,12 +17,13 @@ INSERT INTO messages (
1717 role,
1818 parts,
1919 model,
20+ provider,
2021 created_at,
2122 updated_at
2223) VALUES (
23− ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24+ ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
2425)
25−RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at
26+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
2627`
2728
2829type CreateMessageParams struct {
@@ -31,6 +32,7 @@ type CreateMessageParams struct {
3132 Role string `json:"role"`
3233 Parts string `json:"parts"`
3334 Model sql.NullString `json:"model"`
35+ Provider sql.NullString `json:"provider"`
3436}
3537
3638func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -40,6 +42,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
4042 arg.Role,
4143 arg.Parts,
4244 arg.Model,
45+ arg.Provider,
4346 )
4447 var i Message
4548 err := row.Scan(
@@ -51,6 +54,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
5154 &i.CreatedAt,
5255 &i.UpdatedAt,
5356 &i.FinishedAt,
57+ &i.Provider,
5458 )
5559 return i, err
5660}
@@ -76,7 +80,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
7680}
7781
7882const getMessage = `-- name: GetMessage :one
79−SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
83+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
8084FROM messages
8185WHERE id = ? LIMIT 1
8286`
@@ -93,12 +97,13 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
9397 &i.CreatedAt,
9498 &i.UpdatedAt,
9599 &i.FinishedAt,
100+ &i.Provider,
96101 )
97102 return i, err
98103}
99104
100105const listMessagesBySession = `-- name: ListMessagesBySession :many
101−SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
106+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
102107FROM messages
103108WHERE session_id = ?
104109ORDER BY created_at ASC
@@ -122,6 +127,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
122127 &i.CreatedAt,
123128 &i.UpdatedAt,
124129 &i.FinishedAt,
130+ &i.Provider,
125131 ); err != nil {
126132 return nil, err
127133 }
internal/db/migrations/20250627000000_add_provider_to_messages.sqlnew file+11
@@ -0,0 +1,11 @@
1+-- +goose Up
2+-- +goose StatementBegin
3+-- Add provider column to messages table
4+ALTER TABLE messages ADD COLUMN provider TEXT;
5+-- +goose StatementEnd
6+
7+-- +goose Down
8+-- +goose StatementBegin
9+-- Remove provider column from messages table
10+ALTER TABLE messages DROP COLUMN provider;
11+-- +goose StatementEnd
internal/db/models.go+1
@@ -27,6 +27,7 @@ type Message struct {
2727 CreatedAt int64 `json:"created_at"`
2828 UpdatedAt int64 `json:"updated_at"`
2929 FinishedAt sql.NullInt64 `json:"finished_at"`
30+ Provider sql.NullString `json:"provider"`
3031}
3132
3233type Session struct {
internal/db/sql/messages.sql+2−1
@@ -16,10 +16,11 @@ INSERT INTO messages (
1616 role,
1717 parts,
1818 model,
19+ provider,
1920 created_at,
2021 updated_at
2122) VALUES (
22− ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23+ ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
2324)
2425RETURNING *;
2526
internal/fur/client/client.go+1−1
@@ -10,7 +10,7 @@ import (
1010 "github.com/charmbracelet/crush/internal/fur/provider"
1111)
1212
13−const defaultURL = "http://localhost:8080"
13+const defaultURL = "https://fur.charmcli.dev"
1414
1515// Client represents a client for the fur service.
1616type Client struct {
internal/fur/provider/provider.go+7−8
@@ -6,14 +6,13 @@ type Type string
66
77// All the supported AI provider types.
88const (
9− TypeOpenAI Type = "openai"
10− TypeAnthropic Type = "anthropic"
11− TypeGemini Type = "gemini"
12− TypeAzure Type = "azure"
13− TypeBedrock Type = "bedrock"
14− TypeVertexAI Type = "vertexai"
15− TypeXAI Type = "xai"
16− TypeOpenRouter Type = "openrouter"
9+ TypeOpenAI Type = "openai"
10+ TypeAnthropic Type = "anthropic"
11+ TypeGemini Type = "gemini"
12+ TypeAzure Type = "azure"
13+ TypeBedrock Type = "bedrock"
14+ TypeVertexAI Type = "vertexai"
15+ TypeXAI Type = "xai"
1716)
1817
1918// InferenceProvider represents the inference provider identifier.
internal/llm/agent/agent-tool.go+10−17
@@ -5,17 +5,15 @@ import (
55 "encoding/json"
66 "fmt"
77
8− "github.com/charmbracelet/crush/internal/config"
98 "github.com/charmbracelet/crush/internal/llm/tools"
10− "github.com/charmbracelet/crush/internal/lsp"
119 "github.com/charmbracelet/crush/internal/message"
1210 "github.com/charmbracelet/crush/internal/session"
1311)
1412
1513type agentTool struct {
16− sessions session.Service
17− messages message.Service
18− lspClients map[string]*lsp.Client
14+ agent Service
15+ sessions session.Service
16+ messages message.Service
1917}
2018
2119const (
@@ -58,17 +56,12 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
5856 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
5957 }
6058
61− agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
62− if err != nil {
63− return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
64− }
65−
6659 session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
6760 if err != nil {
6861 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
6962 }
7063
71− done, err := agent.Run(ctx, session.ID, params.Prompt)
64+ done, err := b.agent.Run(ctx, session.ID, params.Prompt)
7265 if err != nil {
7366 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
7467 }
@@ -101,13 +94,13 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
10194}
10295
10396func NewAgentTool(
104− Sessions session.Service,
105− Messages message.Service,
106− LspClients map[string]*lsp.Client,
97+ agent Service,
98+ sessions session.Service,
99+ messages message.Service,
107100) tools.BaseTool {
108101 return &agentTool{
109− sessions: Sessions,
110− messages: Messages,
111− lspClients: LspClients,
102+ sessions: sessions,
103+ messages: messages,
104+ agent: agent,
112105 }
113106}
internal/llm/agent/agent.go+206−95
@@ -4,16 +4,18 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "slices"
78 "strings"
89 "sync"
910 "time"
1011
11− "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
12+ configv2 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/history"
1314 "github.com/charmbracelet/crush/internal/llm/prompt"
1415 "github.com/charmbracelet/crush/internal/llm/provider"
1516 "github.com/charmbracelet/crush/internal/llm/tools"
1617 "github.com/charmbracelet/crush/internal/logging"
18+ "github.com/charmbracelet/crush/internal/lsp"
1719 "github.com/charmbracelet/crush/internal/message"
1820 "github.com/charmbracelet/crush/internal/permission"
1921 "github.com/charmbracelet/crush/internal/pubsub"
@@ -47,71 +49,198 @@ type AgentEvent struct {
4749
4850type Service interface {
4951 pubsub.Suscriber[AgentEvent]
50− Model() models.Model
52+ Model() configv2.Model
5153 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
5254 Cancel(sessionID string)
5355 CancelAll()
5456 IsSessionBusy(sessionID string) bool
5557 IsBusy() bool
56− Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
58+ Update(model configv2.PreferredModel) (configv2.Model, error)
5759 Summarize(ctx context.Context, sessionID string) error
5860}
5961
6062type agent struct {
6163 *pubsub.Broker[AgentEvent]
64+ agentCfg configv2.Agent
6265 sessions session.Service
6366 messages message.Service
6467
65− tools []tools.BaseTool
66− provider provider.Provider
68+ tools []tools.BaseTool
69+ provider provider.Provider
70+ providerID string
6771
68− titleProvider provider.Provider
69− summarizeProvider provider.Provider
72+ titleProvider provider.Provider
73+ summarizeProvider provider.Provider
74+ summarizeProviderID string
7075
7176 activeRequests sync.Map
7277}
7378
79+var agentPromptMap = map[configv2.AgentID]prompt.PromptID{
80+ configv2.AgentCoder: prompt.PromptCoder,
81+ configv2.AgentTask: prompt.PromptTask,
82+}
83+
7484func NewAgent(
75− agentName config.AgentName,
85+ agentCfg configv2.Agent,
86+ // These services are needed in the tools
87+ permissions permission.Service,
7688 sessions session.Service,
7789 messages message.Service,
78− agentTools []tools.BaseTool,
90+ history history.Service,
91+ lspClients map[string]*lsp.Client,
7992) (Service, error) {
80− agentProvider, err := createAgentProvider(agentName)
93+ ctx := context.Background()
94+ cfg := configv2.Get()
95+ otherTools := GetMcpTools(ctx, permissions)
96+ if len(lspClients) > 0 {
97+ otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
98+ }
99+
100+ allTools := []tools.BaseTool{
101+ tools.NewBashTool(permissions),
102+ tools.NewEditTool(lspClients, permissions, history),
103+ tools.NewFetchTool(permissions),
104+ tools.NewGlobTool(),
105+ tools.NewGrepTool(),
106+ tools.NewLsTool(),
107+ tools.NewSourcegraphTool(),
108+ tools.NewViewTool(lspClients),
109+ tools.NewWriteTool(lspClients, permissions, history),
110+ }
111+
112+ if agentCfg.ID == configv2.AgentCoder {
113+ taskAgentCfg := configv2.Get().Agents[configv2.AgentTask]
114+ if taskAgentCfg.ID == "" {
115+ return nil, fmt.Errorf("task agent not found in config")
116+ }
117+ taskAgent, err := NewAgent(taskAgentCfg, permissions, sessions, messages, history, lspClients)
118+ if err != nil {
119+ return nil, fmt.Errorf("failed to create task agent: %w", err)
120+ }
121+
122+ allTools = append(
123+ allTools,
124+ NewAgentTool(
125+ taskAgent,
126+ sessions,
127+ messages,
128+ ),
129+ )
130+ }
131+
132+ allTools = append(allTools, otherTools...)
133+ var providerCfg configv2.ProviderConfig
134+ for _, p := range cfg.Providers {
135+ if p.ID == agentCfg.Provider {
136+ providerCfg = p
137+ break
138+ }
139+ }
140+ if providerCfg.ID == "" {
141+ return nil, fmt.Errorf("provider %s not found in config", agentCfg.Provider)
142+ }
143+
144+ var model configv2.Model
145+ for _, m := range providerCfg.Models {
146+ if m.ID == agentCfg.Model {
147+ model = m
148+ break
149+ }
150+ }
151+ if model.ID == "" {
152+ return nil, fmt.Errorf("model %s not found in provider %s", agentCfg.Model, agentCfg.Provider)
153+ }
154+
155+ promptID := agentPromptMap[agentCfg.ID]
156+ if promptID == "" {
157+ promptID = prompt.PromptDefault
158+ }
159+ opts := []provider.ProviderClientOption{
160+ provider.WithModel(model),
161+ provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID)),
162+ provider.WithMaxTokens(model.DefaultMaxTokens),
163+ }
164+ agentProvider, err := provider.NewProviderV2(providerCfg, opts...)
81165 if err != nil {
82166 return nil, err
83167 }
84− var titleProvider provider.Provider
85− // Only generate titles for the coder agent
86− if agentName == config.AgentCoder {
87− titleProvider, err = createAgentProvider(config.AgentTitle)
88− if err != nil {
89− return nil, err
168+
169+ smallModelCfg := cfg.Models.Small
170+ var smallModel configv2.Model
171+
172+ var smallModelProviderCfg configv2.ProviderConfig
173+ if smallModelCfg.Provider == providerCfg.ID {
174+ smallModelProviderCfg = providerCfg
175+ } else {
176+ for _, p := range cfg.Providers {
177+ if p.ID == smallModelCfg.Provider {
178+ smallModelProviderCfg = p
179+ break
180+ }
181+ }
182+ if smallModelProviderCfg.ID == "" {
183+ return nil, fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
90184 }
91185 }
92− var summarizeProvider provider.Provider
93− if agentName == config.AgentCoder {
94− summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
95− if err != nil {
96− return nil, err
186+ for _, m := range smallModelProviderCfg.Models {
187+ if m.ID == smallModelCfg.ModelID {
188+ smallModel = m
189+ break
190+ }
191+ }
192+ if smallModel.ID == "" {
193+ return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.ModelID, smallModelProviderCfg.ID)
194+ }
195+
196+ titleOpts := []provider.ProviderClientOption{
197+ provider.WithModel(smallModel),
198+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
199+ provider.WithMaxTokens(40),
200+ }
201+ titleProvider, err := provider.NewProviderV2(smallModelProviderCfg, titleOpts...)
202+ if err != nil {
203+ return nil, err
204+ }
205+ summarizeOpts := []provider.ProviderClientOption{
206+ provider.WithModel(smallModel),
207+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
208+ provider.WithMaxTokens(smallModel.DefaultMaxTokens),
209+ }
210+ summarizeProvider, err := provider.NewProviderV2(smallModelProviderCfg, summarizeOpts...)
211+ if err != nil {
212+ return nil, err
213+ }
214+
215+ agentTools := []tools.BaseTool{}
216+ if agentCfg.AllowedTools == nil {
217+ agentTools = allTools
218+ } else {
219+ for _, tool := range allTools {
220+ if slices.Contains(agentCfg.AllowedTools, tool.Name()) {
221+ agentTools = append(agentTools, tool)
222+ }
97223 }
98224 }
99225
100226 agent := &agent{
101− Broker: pubsub.NewBroker[AgentEvent](),
102− provider: agentProvider,
103− messages: messages,
104− sessions: sessions,
105− tools: agentTools,
106− titleProvider: titleProvider,
107− summarizeProvider: summarizeProvider,
108− activeRequests: sync.Map{},
227+ Broker: pubsub.NewBroker[AgentEvent](),
228+ agentCfg: agentCfg,
229+ provider: agentProvider,
230+ providerID: string(providerCfg.ID),
231+ messages: messages,
232+ sessions: sessions,
233+ tools: agentTools,
234+ titleProvider: titleProvider,
235+ summarizeProvider: summarizeProvider,
236+ summarizeProviderID: string(smallModelProviderCfg.ID),
237+ activeRequests: sync.Map{},
109238 }
110239
111240 return agent, nil
112241}
113242
114−func (a *agent) Model() models.Model {
243+func (a *agent) Model() configv2.Model {
115244 return a.provider.Model()
116245}
117246
@@ -207,7 +336,7 @@ func (a *agent) err(err error) AgentEvent {
207336}
208337
209338func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
210− if !a.provider.Model().SupportsAttachments && attachments != nil {
339+ if !a.provider.Model().SupportsImages && attachments != nil {
211340 attachments = nil
212341 }
213342 events := make(chan AgentEvent)
@@ -327,9 +456,10 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
327456 eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
328457
329458 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
330− Role: message.Assistant,
331− Parts: []message.ContentPart{},
332− Model: a.provider.Model().ID,
459+ Role: message.Assistant,
460+ Parts: []message.ContentPart{},
461+ Model: a.provider.Model().ID,
462+ Provider: a.providerID,
333463 })
334464 if err != nil {
335465 return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
@@ -424,8 +554,9 @@ out:
424554 parts = append(parts, tr)
425555 }
426556 msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
427− Role: message.Tool,
428− Parts: parts,
557+ Role: message.Tool,
558+ Parts: parts,
559+ Provider: a.providerID,
429560 })
430561 if err != nil {
431562 return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
@@ -484,7 +615,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
484615 return nil
485616}
486617
487−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
618+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model configv2.Model, usage provider.TokenUsage) error {
488619 sess, err := a.sessions.Get(ctx, sessionID)
489620 if err != nil {
490621 return fmt.Errorf("failed to get session: %w", err)
@@ -506,21 +637,48 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
506637 return nil
507638}
508639
509−func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) {
640+func (a *agent) Update(modelCfg configv2.PreferredModel) (configv2.Model, error) {
510641 if a.IsBusy() {
511− return models.Model{}, fmt.Errorf("cannot change model while processing requests")
642+ return configv2.Model{}, fmt.Errorf("cannot change model while processing requests")
512643 }
513644
514− if err := config.UpdateAgentModel(agentName, modelID); err != nil {
515− return models.Model{}, fmt.Errorf("failed to update config: %w", err)
645+ cfg := configv2.Get()
646+ var providerCfg configv2.ProviderConfig
647+ for _, p := range cfg.Providers {
648+ if p.ID == modelCfg.Provider {
649+ providerCfg = p
650+ break
651+ }
652+ }
653+ if providerCfg.ID == "" {
654+ return configv2.Model{}, fmt.Errorf("provider %s not found in config", modelCfg.Provider)
516655 }
517656
518− provider, err := createAgentProvider(agentName)
519− if err != nil {
520− return models.Model{}, fmt.Errorf("failed to create provider for model %s: %w", modelID, err)
657+ var model configv2.Model
658+ for _, m := range providerCfg.Models {
659+ if m.ID == modelCfg.ModelID {
660+ model = m
661+ break
662+ }
663+ }
664+ if model.ID == "" {
665+ return configv2.Model{}, fmt.Errorf("model %s not found in provider %s", modelCfg.ModelID, modelCfg.Provider)
521666 }
522667
523− a.provider = provider
668+ promptID := agentPromptMap[a.agentCfg.ID]
669+ if promptID == "" {
670+ promptID = prompt.PromptDefault
671+ }
672+ opts := []provider.ProviderClientOption{
673+ provider.WithModel(model),
674+ provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID)),
675+ provider.WithMaxTokens(model.DefaultMaxTokens),
676+ }
677+ agentProvider, err := provider.NewProviderV2(providerCfg, opts...)
678+ if err != nil {
679+ return configv2.Model{}, err
680+ }
681+ a.provider = agentProvider
524682
525683 return a.provider.Model(), nil
526684}
@@ -654,7 +812,8 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
654812 Time: time.Now().Unix(),
655813 },
656814 },
657− Model: a.summarizeProvider.Model().ID,
815+ Model: a.summarizeProvider.Model().ID,
816+ Provider: a.summarizeProviderID,
658817 })
659818 if err != nil {
660819 event = AgentEvent{
@@ -705,51 +864,3 @@ func (a *agent) CancelAll() {
705864 return true
706865 })
707866}
708−
709−func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
710− cfg := config.Get()
711− agentConfig, ok := cfg.Agents[agentName]
712− if !ok {
713− return nil, fmt.Errorf("agent %s not found", agentName)
714− }
715− model, ok := models.SupportedModels[agentConfig.Model]
716− if !ok {
717− return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
718− }
719−
720− providerCfg, ok := cfg.Providers[model.Provider]
721− if !ok {
722− return nil, fmt.Errorf("provider %s not supported", model.Provider)
723− }
724− if providerCfg.Disabled {
725− return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
726− }
727− maxTokens := model.DefaultMaxTokens
728− if agentConfig.MaxTokens > 0 {
729− maxTokens = agentConfig.MaxTokens
730− }
731− opts := []provider.ProviderClientOption{
732− provider.WithAPIKey(providerCfg.APIKey),
733− provider.WithModel(model),
734− provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
735− provider.WithMaxTokens(maxTokens),
736− }
737− // TODO: reimplement
738− // if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason {
739− // opts = append(
740− // opts,
741− // provider.WithOpenAIOptions(
742− // provider.WithReasoningEffort(agentConfig.ReasoningEffort),
743− // ),
744− // )
745− // }
746− agentProvider, err := provider.NewProvider(
747− model.Provider,
748− opts...,
749− )
750− if err != nil {
751− return nil, fmt.Errorf("could not create provider: %v", err)
752− }
753−
754− return agentProvider, nil
755−}
internal/llm/agent/mcp-tools.go+4−4
@@ -18,7 +18,7 @@ import (
1818type mcpTool struct {
1919 mcpName string
2020 tool mcp.Tool
21− mcpConfig config.MCPServer
21+ mcpConfig config.MCP
2222 permissions permission.Service
2323}
2424
@@ -128,7 +128,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
128128 return tools.NewTextErrorResponse("invalid mcp type"), nil
129129}
130130
131−func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCPServer) tools.BaseTool {
131+func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCP) tools.BaseTool {
132132 return &mcpTool{
133133 mcpName: name,
134134 tool: tool,
@@ -139,7 +139,7 @@ func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpC
139139
140140var mcpTools []tools.BaseTool
141141
142−func getTools(ctx context.Context, name string, m config.MCPServer, permissions permission.Service, c MCPClient) []tools.BaseTool {
142+func getTools(ctx context.Context, name string, m config.MCP, permissions permission.Service, c MCPClient) []tools.BaseTool {
143143 var stdioTools []tools.BaseTool
144144 initRequest := mcp.InitializeRequest{}
145145 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
@@ -170,7 +170,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
170170 if len(mcpTools) > 0 {
171171 return mcpTools
172172 }
173− for name, m := range config.Get().MCPServers {
173+ for name, m := range config.Get().MCP {
174174 switch m.Type {
175175 case config.MCPStdio:
176176 c, err := client.NewStdioMCPClient(
internal/llm/agent/tools.godeleted−50
@@ -1,50 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5−
6− "github.com/charmbracelet/crush/internal/history"
7− "github.com/charmbracelet/crush/internal/llm/tools"
8− "github.com/charmbracelet/crush/internal/lsp"
9− "github.com/charmbracelet/crush/internal/message"
10− "github.com/charmbracelet/crush/internal/permission"
11− "github.com/charmbracelet/crush/internal/session"
12−)
13−
14−func CoderAgentTools(
15− permissions permission.Service,
16− sessions session.Service,
17− messages message.Service,
18− history history.Service,
19− lspClients map[string]*lsp.Client,
20−) []tools.BaseTool {
21− ctx := context.Background()
22− otherTools := GetMcpTools(ctx, permissions)
23− if len(lspClients) > 0 {
24− otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
25− }
26− return append(
27− []tools.BaseTool{
28− tools.NewBashTool(permissions),
29− tools.NewEditTool(lspClients, permissions, history),
30− tools.NewFetchTool(permissions),
31− tools.NewGlobTool(),
32− tools.NewGrepTool(),
33− tools.NewLsTool(),
34− tools.NewSourcegraphTool(),
35− tools.NewViewTool(lspClients),
36− tools.NewWriteTool(lspClients, permissions, history),
37− NewAgentTool(sessions, messages, lspClients),
38− }, otherTools...,
39− )
40−}
41−
42−func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
43− return []tools.BaseTool{
44− tools.NewGlobTool(),
45− tools.NewGrepTool(),
46− tools.NewLsTool(),
47− tools.NewSourcegraphTool(),
48− tools.NewViewTool(lspClients),
49− }
50−}
internal/llm/models/anthropic.godeleted−111
@@ -1,111 +0,0 @@
1−package models
2−
3−const (
4− ProviderAnthropic InferenceProvider = "anthropic"
5−
6− // Models
7− Claude35Sonnet ModelID = "claude-3.5-sonnet"
8− Claude3Haiku ModelID = "claude-3-haiku"
9− Claude37Sonnet ModelID = "claude-3.7-sonnet"
10− Claude35Haiku ModelID = "claude-3.5-haiku"
11− Claude3Opus ModelID = "claude-3-opus"
12− Claude4Opus ModelID = "claude-4-opus"
13− Claude4Sonnet ModelID = "claude-4-sonnet"
14−)
15−
16−// https://docs.anthropic.com/en/docs/about-claude/models/all-models
17−var AnthropicModels = map[ModelID]Model{
18− Claude35Sonnet: {
19− ID: Claude35Sonnet,
20− Name: "Claude 3.5 Sonnet",
21− Provider: ProviderAnthropic,
22− APIModel: "claude-3-5-sonnet-latest",
23− CostPer1MIn: 3.0,
24− CostPer1MInCached: 3.75,
25− CostPer1MOutCached: 0.30,
26− CostPer1MOut: 15.0,
27− ContextWindow: 200000,
28− DefaultMaxTokens: 5000,
29− SupportsAttachments: true,
30− },
31− Claude3Haiku: {
32− ID: Claude3Haiku,
33− Name: "Claude 3 Haiku",
34− Provider: ProviderAnthropic,
35− APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
36− CostPer1MIn: 0.25,
37− CostPer1MInCached: 0.30,
38− CostPer1MOutCached: 0.03,
39− CostPer1MOut: 1.25,
40− ContextWindow: 200000,
41− DefaultMaxTokens: 4096,
42− SupportsAttachments: true,
43− },
44− Claude37Sonnet: {
45− ID: Claude37Sonnet,
46− Name: "Claude 3.7 Sonnet",
47− Provider: ProviderAnthropic,
48− APIModel: "claude-3-7-sonnet-latest",
49− CostPer1MIn: 3.0,
50− CostPer1MInCached: 3.75,
51− CostPer1MOutCached: 0.30,
52− CostPer1MOut: 15.0,
53− ContextWindow: 200000,
54− DefaultMaxTokens: 50000,
55− CanReason: true,
56− SupportsAttachments: true,
57− },
58− Claude35Haiku: {
59− ID: Claude35Haiku,
60− Name: "Claude 3.5 Haiku",
61− Provider: ProviderAnthropic,
62− APIModel: "claude-3-5-haiku-latest",
63− CostPer1MIn: 0.80,
64− CostPer1MInCached: 1.0,
65− CostPer1MOutCached: 0.08,
66− CostPer1MOut: 4.0,
67− ContextWindow: 200000,
68− DefaultMaxTokens: 4096,
69− SupportsAttachments: true,
70− },
71− Claude3Opus: {
72− ID: Claude3Opus,
73− Name: "Claude 3 Opus",
74− Provider: ProviderAnthropic,
75− APIModel: "claude-3-opus-latest",
76− CostPer1MIn: 15.0,
77− CostPer1MInCached: 18.75,
78− CostPer1MOutCached: 1.50,
79− CostPer1MOut: 75.0,
80− ContextWindow: 200000,
81− DefaultMaxTokens: 4096,
82− SupportsAttachments: true,
83− },
84− Claude4Sonnet: {
85− ID: Claude4Sonnet,
86− Name: "Claude 4 Sonnet",
87− Provider: ProviderAnthropic,
88− APIModel: "claude-sonnet-4-20250514",
89− CostPer1MIn: 3.0,
90− CostPer1MInCached: 3.75,
91− CostPer1MOutCached: 0.30,
92− CostPer1MOut: 15.0,
93− ContextWindow: 200000,
94− DefaultMaxTokens: 50000,
95− CanReason: true,
96− SupportsAttachments: true,
97− },
98− Claude4Opus: {
99− ID: Claude4Opus,
100− Name: "Claude 4 Opus",
101− Provider: ProviderAnthropic,
102− APIModel: "claude-opus-4-20250514",
103− CostPer1MIn: 15.0,
104− CostPer1MInCached: 18.75,
105− CostPer1MOutCached: 1.50,
106− CostPer1MOut: 75.0,
107− ContextWindow: 200000,
108− DefaultMaxTokens: 4096,
109− SupportsAttachments: true,
110− },
111−}
internal/llm/models/azure.godeleted−168
@@ -1,168 +0,0 @@
1−package models
2−
3−const ProviderAzure InferenceProvider = "azure"
4−
5−const (
6− AzureGPT41 ModelID = "azure.gpt-4.1"
7− AzureGPT41Mini ModelID = "azure.gpt-4.1-mini"
8− AzureGPT41Nano ModelID = "azure.gpt-4.1-nano"
9− AzureGPT45Preview ModelID = "azure.gpt-4.5-preview"
10− AzureGPT4o ModelID = "azure.gpt-4o"
11− AzureGPT4oMini ModelID = "azure.gpt-4o-mini"
12− AzureO1 ModelID = "azure.o1"
13− AzureO1Mini ModelID = "azure.o1-mini"
14− AzureO3 ModelID = "azure.o3"
15− AzureO3Mini ModelID = "azure.o3-mini"
16− AzureO4Mini ModelID = "azure.o4-mini"
17−)
18−
19−var AzureModels = map[ModelID]Model{
20− AzureGPT41: {
21− ID: AzureGPT41,
22− Name: "Azure OpenAI – GPT 4.1",
23− Provider: ProviderAzure,
24− APIModel: "gpt-4.1",
25− CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
26− CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
27− CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
28− CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
29− ContextWindow: OpenAIModels[GPT41].ContextWindow,
30− DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
31− SupportsAttachments: true,
32− },
33− AzureGPT41Mini: {
34− ID: AzureGPT41Mini,
35− Name: "Azure OpenAI – GPT 4.1 mini",
36− Provider: ProviderAzure,
37− APIModel: "gpt-4.1-mini",
38− CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
39− CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
40− CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
41− CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
42− ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
43− DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
44− SupportsAttachments: true,
45− },
46− AzureGPT41Nano: {
47− ID: AzureGPT41Nano,
48− Name: "Azure OpenAI – GPT 4.1 nano",
49− Provider: ProviderAzure,
50− APIModel: "gpt-4.1-nano",
51− CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
52− CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
53− CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
54− CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
55− ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
56− DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
57− SupportsAttachments: true,
58− },
59− AzureGPT45Preview: {
60− ID: AzureGPT45Preview,
61− Name: "Azure OpenAI – GPT 4.5 preview",
62− Provider: ProviderAzure,
63− APIModel: "gpt-4.5-preview",
64− CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
65− CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
66− CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
67− CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
68− ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
69− DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
70− SupportsAttachments: true,
71− },
72− AzureGPT4o: {
73− ID: AzureGPT4o,
74− Name: "Azure OpenAI – GPT-4o",
75− Provider: ProviderAzure,
76− APIModel: "gpt-4o",
77− CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
78− CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
79− CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
80− CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
81− ContextWindow: OpenAIModels[GPT4o].ContextWindow,
82− DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
83− SupportsAttachments: true,
84− },
85− AzureGPT4oMini: {
86− ID: AzureGPT4oMini,
87− Name: "Azure OpenAI – GPT-4o mini",
88− Provider: ProviderAzure,
89− APIModel: "gpt-4o-mini",
90− CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
91− CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
92− CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
93− CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
94− ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
95− DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
96− SupportsAttachments: true,
97− },
98− AzureO1: {
99− ID: AzureO1,
100− Name: "Azure OpenAI – O1",
101− Provider: ProviderAzure,
102− APIModel: "o1",
103− CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
104− CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
105− CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
106− CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
107− ContextWindow: OpenAIModels[O1].ContextWindow,
108− DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
109− CanReason: OpenAIModels[O1].CanReason,
110− SupportsAttachments: true,
111− },
112− AzureO1Mini: {
113− ID: AzureO1Mini,
114− Name: "Azure OpenAI – O1 mini",
115− Provider: ProviderAzure,
116− APIModel: "o1-mini",
117− CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
118− CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
119− CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
120− CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
121− ContextWindow: OpenAIModels[O1Mini].ContextWindow,
122− DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
123− CanReason: OpenAIModels[O1Mini].CanReason,
124− SupportsAttachments: true,
125− },
126− AzureO3: {
127− ID: AzureO3,
128− Name: "Azure OpenAI – O3",
129− Provider: ProviderAzure,
130− APIModel: "o3",
131− CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
132− CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
133− CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
134− CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
135− ContextWindow: OpenAIModels[O3].ContextWindow,
136− DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
137− CanReason: OpenAIModels[O3].CanReason,
138− SupportsAttachments: true,
139− },
140− AzureO3Mini: {
141− ID: AzureO3Mini,
142− Name: "Azure OpenAI – O3 mini",
143− Provider: ProviderAzure,
144− APIModel: "o3-mini",
145− CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
146− CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
147− CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
148− CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
149− ContextWindow: OpenAIModels[O3Mini].ContextWindow,
150− DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
151− CanReason: OpenAIModels[O3Mini].CanReason,
152− SupportsAttachments: false,
153− },
154− AzureO4Mini: {
155− ID: AzureO4Mini,
156− Name: "Azure OpenAI – O4 mini",
157− Provider: ProviderAzure,
158− APIModel: "o4-mini",
159− CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
160− CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
161− CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
162− CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
163− ContextWindow: OpenAIModels[O4Mini].ContextWindow,
164− DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
165− CanReason: OpenAIModels[O4Mini].CanReason,
166− SupportsAttachments: true,
167− },
168−}
internal/llm/models/gemini.godeleted−67
@@ -1,67 +0,0 @@
1−package models
2−
3−const (
4− ProviderGemini InferenceProvider = "gemini"
5−
6− // Models
7− Gemini25Flash ModelID = "gemini-2.5-flash"
8− Gemini25 ModelID = "gemini-2.5"
9− Gemini20Flash ModelID = "gemini-2.0-flash"
10− Gemini20FlashLite ModelID = "gemini-2.0-flash-lite"
11−)
12−
13−var GeminiModels = map[ModelID]Model{
14− Gemini25Flash: {
15− ID: Gemini25Flash,
16− Name: "Gemini 2.5 Flash",
17− Provider: ProviderGemini,
18− APIModel: "gemini-2.5-flash-preview-04-17",
19− CostPer1MIn: 0.15,
20− CostPer1MInCached: 0,
21− CostPer1MOutCached: 0,
22− CostPer1MOut: 0.60,
23− ContextWindow: 1000000,
24− DefaultMaxTokens: 50000,
25− SupportsAttachments: true,
26− },
27− Gemini25: {
28− ID: Gemini25,
29− Name: "Gemini 2.5 Pro",
30− Provider: ProviderGemini,
31− APIModel: "gemini-2.5-pro-preview-05-06",
32− CostPer1MIn: 1.25,
33− CostPer1MInCached: 0,
34− CostPer1MOutCached: 0,
35− CostPer1MOut: 10,
36− ContextWindow: 1000000,
37− DefaultMaxTokens: 50000,
38− SupportsAttachments: true,
39− },
40−
41− Gemini20Flash: {
42− ID: Gemini20Flash,
43− Name: "Gemini 2.0 Flash",
44− Provider: ProviderGemini,
45− APIModel: "gemini-2.0-flash",
46− CostPer1MIn: 0.10,
47− CostPer1MInCached: 0,
48− CostPer1MOutCached: 0,
49− CostPer1MOut: 0.40,
50− ContextWindow: 1000000,
51− DefaultMaxTokens: 6000,
52− SupportsAttachments: true,
53− },
54− Gemini20FlashLite: {
55− ID: Gemini20FlashLite,
56− Name: "Gemini 2.0 Flash Lite",
57− Provider: ProviderGemini,
58− APIModel: "gemini-2.0-flash-lite",
59− CostPer1MIn: 0.05,
60− CostPer1MInCached: 0,
61− CostPer1MOutCached: 0,
62− CostPer1MOut: 0.30,
63− ContextWindow: 1000000,
64− DefaultMaxTokens: 6000,
65− SupportsAttachments: true,
66− },
67−}
internal/llm/models/groq.godeleted−87
@@ -1,87 +0,0 @@
1−package models
2−
3−const (
4− ProviderGROQ InferenceProvider = "groq"
5−
6− // GROQ
7− QWENQwq ModelID = "qwen-qwq"
8−
9− // GROQ preview models
10− Llama4Scout ModelID = "meta-llama/llama-4-scout-17b-16e-instruct"
11− Llama4Maverick ModelID = "meta-llama/llama-4-maverick-17b-128e-instruct"
12− Llama3_3_70BVersatile ModelID = "llama-3.3-70b-versatile"
13− DeepseekR1DistillLlama70b ModelID = "deepseek-r1-distill-llama-70b"
14−)
15−
16−var GroqModels = map[ModelID]Model{
17− //
18− // GROQ
19− QWENQwq: {
20− ID: QWENQwq,
21− Name: "Qwen Qwq",
22− Provider: ProviderGROQ,
23− APIModel: "qwen-qwq-32b",
24− CostPer1MIn: 0.29,
25− CostPer1MInCached: 0.275,
26− CostPer1MOutCached: 0.0,
27− CostPer1MOut: 0.39,
28− ContextWindow: 128_000,
29− DefaultMaxTokens: 50000,
30− // for some reason, the groq api doesn't like the reasoningEffort parameter
31− CanReason: false,
32− SupportsAttachments: false,
33− },
34−
35− Llama4Scout: {
36− ID: Llama4Scout,
37− Name: "Llama4Scout",
38− Provider: ProviderGROQ,
39− APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
40− CostPer1MIn: 0.11,
41− CostPer1MInCached: 0,
42− CostPer1MOutCached: 0,
43− CostPer1MOut: 0.34,
44− ContextWindow: 128_000, // 10M when?
45− SupportsAttachments: true,
46− },
47−
48− Llama4Maverick: {
49− ID: Llama4Maverick,
50− Name: "Llama4Maverick",
51− Provider: ProviderGROQ,
52− APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
53− CostPer1MIn: 0.20,
54− CostPer1MInCached: 0,
55− CostPer1MOutCached: 0,
56− CostPer1MOut: 0.20,
57− ContextWindow: 128_000,
58− SupportsAttachments: true,
59− },
60−
61− Llama3_3_70BVersatile: {
62− ID: Llama3_3_70BVersatile,
63− Name: "Llama3_3_70BVersatile",
64− Provider: ProviderGROQ,
65− APIModel: "llama-3.3-70b-versatile",
66− CostPer1MIn: 0.59,
67− CostPer1MInCached: 0,
68− CostPer1MOutCached: 0,
69− CostPer1MOut: 0.79,
70− ContextWindow: 128_000,
71− SupportsAttachments: false,
72− },
73−
74− DeepseekR1DistillLlama70b: {
75− ID: DeepseekR1DistillLlama70b,
76− Name: "DeepseekR1DistillLlama70b",
77− Provider: ProviderGROQ,
78− APIModel: "deepseek-r1-distill-llama-70b",
79− CostPer1MIn: 0.75,
80− CostPer1MInCached: 0,
81− CostPer1MOutCached: 0,
82− CostPer1MOut: 0.99,
83− ContextWindow: 128_000,
84− CanReason: true,
85− SupportsAttachments: false,
86− },
87−}
internal/llm/models/local.godeleted−206
@@ -1,206 +0,0 @@
1−package models
2−
3−import (
4− "cmp"
5− "context"
6− "encoding/json"
7− "net/http"
8− "net/url"
9− "os"
10− "regexp"
11− "strings"
12− "unicode"
13−
14− "github.com/charmbracelet/crush/internal/logging"
15− "github.com/spf13/viper"
16−)
17−
18−const (
19− ProviderLocal InferenceProvider = "local"
20−
21− localModelsPath = "v1/models"
22− lmStudioBetaModelsPath = "api/v0/models"
23−)
24−
25−func init() {
26− if endpoint := os.Getenv("LOCAL_ENDPOINT"); endpoint != "" {
27− localEndpoint, err := url.Parse(endpoint)
28− if err != nil {
29− logging.Debug("Failed to parse local endpoint",
30− "error", err,
31− "endpoint", endpoint,
32− )
33− return
34− }
35−
36− load := func(url *url.URL, path string) []localModel {
37− url.Path = path
38− return listLocalModels(url.String())
39− }
40−
41− models := load(localEndpoint, lmStudioBetaModelsPath)
42−
43− if len(models) == 0 {
44− models = load(localEndpoint, localModelsPath)
45− }
46−
47− if len(models) == 0 {
48− logging.Debug("No local models found",
49− "endpoint", endpoint,
50− )
51− return
52− }
53−
54− loadLocalModels(models)
55−
56− viper.SetDefault("providers.local.apiKey", "dummy")
57− }
58−}
59−
60−type localModelList struct {
61− Data []localModel `json:"data"`
62−}
63−
64−type localModel struct {
65− ID string `json:"id"`
66− Object string `json:"object"`
67− Type string `json:"type"`
68− Publisher string `json:"publisher"`
69− Arch string `json:"arch"`
70− CompatibilityType string `json:"compatibility_type"`
71− Quantization string `json:"quantization"`
72− State string `json:"state"`
73− MaxContextLength int64 `json:"max_context_length"`
74− LoadedContextLength int64 `json:"loaded_context_length"`
75−}
76−
77−func listLocalModels(modelsEndpoint string) []localModel {
78− res, err := http.NewRequestWithContext(context.Background(), http.MethodGet, modelsEndpoint, nil)
79− if err != nil {
80− logging.Debug("Failed to list local models",
81− "error", err,
82− "endpoint", modelsEndpoint,
83− )
84− }
85− defer res.Body.Close()
86−
87− if res.Response.StatusCode != http.StatusOK {
88− logging.Debug("Failed to list local models",
89− "status", res.Response.Status,
90− "endpoint", modelsEndpoint,
91− )
92− }
93−
94− var modelList localModelList
95− if err = json.NewDecoder(res.Body).Decode(&modelList); err != nil {
96− logging.Debug("Failed to list local models",
97− "error", err,
98− "endpoint", modelsEndpoint,
99− )
100− }
101−
102− var supportedModels []localModel
103− for _, model := range modelList.Data {
104− if strings.HasSuffix(modelsEndpoint, lmStudioBetaModelsPath) {
105− if model.Object != "model" || model.Type != "llm" {
106− logging.Debug("Skipping unsupported LMStudio model",
107− "endpoint", modelsEndpoint,
108− "id", model.ID,
109− "object", model.Object,
110− "type", model.Type,
111− )
112−
113− continue
114− }
115− }
116−
117− supportedModels = append(supportedModels, model)
118− }
119−
120− return supportedModels
121−}
122−
123−func loadLocalModels(models []localModel) {
124− for i, m := range models {
125− model := convertLocalModel(m)
126− SupportedModels[model.ID] = model
127−
128− if i == 0 || m.State == "loaded" {
129− viper.SetDefault("agents.coder.model", model.ID)
130− viper.SetDefault("agents.summarizer.model", model.ID)
131− viper.SetDefault("agents.task.model", model.ID)
132− viper.SetDefault("agents.title.model", model.ID)
133− }
134− }
135−}
136−
137−func convertLocalModel(model localModel) Model {
138− return Model{
139− ID: ModelID("local." + model.ID),
140− Name: friendlyModelName(model.ID),
141− Provider: ProviderLocal,
142− APIModel: model.ID,
143− ContextWindow: cmp.Or(model.LoadedContextLength, 4096),
144− DefaultMaxTokens: cmp.Or(model.LoadedContextLength, 4096),
145− CanReason: true,
146− SupportsAttachments: true,
147− }
148−}
149−
150−var modelInfoRegex = regexp.MustCompile(`(?i)^([a-z0-9]+)(?:[-_]?([rv]?\d[\.\d]*))?(?:[-_]?([a-z]+))?.*`)
151−
152−func friendlyModelName(modelID string) string {
153− mainID := modelID
154− tag := ""
155−
156− if slash := strings.LastIndex(mainID, "/"); slash != -1 {
157− mainID = mainID[slash+1:]
158− }
159−
160− if at := strings.Index(modelID, "@"); at != -1 {
161− mainID = modelID[:at]
162− tag = modelID[at+1:]
163− }
164−
165− match := modelInfoRegex.FindStringSubmatch(mainID)
166− if match == nil {
167− return modelID
168− }
169−
170− capitalize := func(s string) string {
171− if s == "" {
172− return ""
173− }
174− runes := []rune(s)
175− runes[0] = unicode.ToUpper(runes[0])
176− return string(runes)
177− }
178−
179− family := capitalize(match[1])
180− version := ""
181− label := ""
182−
183− if len(match) > 2 && match[2] != "" {
184− version = strings.ToUpper(match[2])
185− }
186−
187− if len(match) > 3 && match[3] != "" {
188− label = capitalize(match[3])
189− }
190−
191− var parts []string
192− if family != "" {
193− parts = append(parts, family)
194− }
195− if version != "" {
196− parts = append(parts, version)
197− }
198− if label != "" {
199− parts = append(parts, label)
200− }
201− if tag != "" {
202− parts = append(parts, tag)
203− }
204−
205− return strings.Join(parts, " ")
206−}
internal/llm/models/models.godeleted−74
@@ -1,74 +0,0 @@
1−package models
2−
3−import "maps"
4−
5−type (
6− ModelID string
7− InferenceProvider string
8−)
9−
10−type Model struct {
11− ID ModelID `json:"id"`
12− Name string `json:"name"`
13− Provider InferenceProvider `json:"provider"`
14− APIModel string `json:"api_model"`
15− CostPer1MIn float64 `json:"cost_per_1m_in"`
16− CostPer1MOut float64 `json:"cost_per_1m_out"`
17− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19− ContextWindow int64 `json:"context_window"`
20− DefaultMaxTokens int64 `json:"default_max_tokens"`
21− CanReason bool `json:"can_reason"`
22− SupportsAttachments bool `json:"supports_attachments"`
23−}
24−
25−// Model IDs
26−const ( // GEMINI
27− // Bedrock
28− BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
29−)
30−
31−const (
32− ProviderBedrock InferenceProvider = "bedrock"
33− // ForTests
34− ProviderMock InferenceProvider = "__mock"
35−)
36−
37−var SupportedModels = map[ModelID]Model{
38− // Bedrock
39− BedrockClaude37Sonnet: {
40− ID: BedrockClaude37Sonnet,
41− Name: "Bedrock: Claude 3.7 Sonnet",
42− Provider: ProviderBedrock,
43− APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
44− CostPer1MIn: 3.0,
45− CostPer1MInCached: 3.75,
46− CostPer1MOutCached: 0.30,
47− CostPer1MOut: 15.0,
48− },
49−}
50−
51−var KnownProviders = []InferenceProvider{
52− ProviderAnthropic,
53− ProviderOpenAI,
54− ProviderGemini,
55− ProviderAzure,
56− ProviderGROQ,
57− ProviderLocal,
58− ProviderOpenRouter,
59− ProviderVertexAI,
60− ProviderBedrock,
61− ProviderXAI,
62− ProviderMock,
63−}
64−
65−func init() {
66− maps.Copy(SupportedModels, AnthropicModels)
67− maps.Copy(SupportedModels, OpenAIModels)
68− maps.Copy(SupportedModels, GeminiModels)
69− maps.Copy(SupportedModels, GroqModels)
70− maps.Copy(SupportedModels, AzureModels)
71− maps.Copy(SupportedModels, OpenRouterModels)
72− maps.Copy(SupportedModels, XAIModels)
73− maps.Copy(SupportedModels, VertexAIGeminiModels)
74−}
internal/llm/models/openai.godeleted−181
@@ -1,181 +0,0 @@
1−package models
2−
3−const (
4− ProviderOpenAI InferenceProvider = "openai"
5−
6− GPT41 ModelID = "gpt-4.1"
7− GPT41Mini ModelID = "gpt-4.1-mini"
8− GPT41Nano ModelID = "gpt-4.1-nano"
9− GPT45Preview ModelID = "gpt-4.5-preview"
10− GPT4o ModelID = "gpt-4o"
11− GPT4oMini ModelID = "gpt-4o-mini"
12− O1 ModelID = "o1"
13− O1Pro ModelID = "o1-pro"
14− O1Mini ModelID = "o1-mini"
15− O3 ModelID = "o3"
16− O3Mini ModelID = "o3-mini"
17− O4Mini ModelID = "o4-mini"
18−)
19−
20−var OpenAIModels = map[ModelID]Model{
21− GPT41: {
22− ID: GPT41,
23− Name: "GPT 4.1",
24− Provider: ProviderOpenAI,
25− APIModel: "gpt-4.1",
26− CostPer1MIn: 2.00,
27− CostPer1MInCached: 0.50,
28− CostPer1MOutCached: 0.0,
29− CostPer1MOut: 8.00,
30− ContextWindow: 1_047_576,
31− DefaultMaxTokens: 20000,
32− SupportsAttachments: true,
33− },
34− GPT41Mini: {
35− ID: GPT41Mini,
36− Name: "GPT 4.1 mini",
37− Provider: ProviderOpenAI,
38− APIModel: "gpt-4.1",
39− CostPer1MIn: 0.40,
40− CostPer1MInCached: 0.10,
41− CostPer1MOutCached: 0.0,
42− CostPer1MOut: 1.60,
43− ContextWindow: 200_000,
44− DefaultMaxTokens: 20000,
45− SupportsAttachments: true,
46− },
47− GPT41Nano: {
48− ID: GPT41Nano,
49− Name: "GPT 4.1 nano",
50− Provider: ProviderOpenAI,
51− APIModel: "gpt-4.1-nano",
52− CostPer1MIn: 0.10,
53− CostPer1MInCached: 0.025,
54− CostPer1MOutCached: 0.0,
55− CostPer1MOut: 0.40,
56− ContextWindow: 1_047_576,
57− DefaultMaxTokens: 20000,
58− SupportsAttachments: true,
59− },
60− GPT45Preview: {
61− ID: GPT45Preview,
62− Name: "GPT 4.5 preview",
63− Provider: ProviderOpenAI,
64− APIModel: "gpt-4.5-preview",
65− CostPer1MIn: 75.00,
66− CostPer1MInCached: 37.50,
67− CostPer1MOutCached: 0.0,
68− CostPer1MOut: 150.00,
69− ContextWindow: 128_000,
70− DefaultMaxTokens: 15000,
71− SupportsAttachments: true,
72− },
73− GPT4o: {
74− ID: GPT4o,
75− Name: "GPT 4o",
76− Provider: ProviderOpenAI,
77− APIModel: "gpt-4o",
78− CostPer1MIn: 2.50,
79− CostPer1MInCached: 1.25,
80− CostPer1MOutCached: 0.0,
81− CostPer1MOut: 10.00,
82− ContextWindow: 128_000,
83− DefaultMaxTokens: 4096,
84− SupportsAttachments: true,
85− },
86− GPT4oMini: {
87− ID: GPT4oMini,
88− Name: "GPT 4o mini",
89− Provider: ProviderOpenAI,
90− APIModel: "gpt-4o-mini",
91− CostPer1MIn: 0.15,
92− CostPer1MInCached: 0.075,
93− CostPer1MOutCached: 0.0,
94− CostPer1MOut: 0.60,
95− ContextWindow: 128_000,
96− SupportsAttachments: true,
97− },
98− O1: {
99− ID: O1,
100− Name: "O1",
101− Provider: ProviderOpenAI,
102− APIModel: "o1",
103− CostPer1MIn: 15.00,
104− CostPer1MInCached: 7.50,
105− CostPer1MOutCached: 0.0,
106− CostPer1MOut: 60.00,
107− ContextWindow: 200_000,
108− DefaultMaxTokens: 50000,
109− CanReason: true,
110− SupportsAttachments: true,
111− },
112− O1Pro: {
113− ID: O1Pro,
114− Name: "o1 pro",
115− Provider: ProviderOpenAI,
116− APIModel: "o1-pro",
117− CostPer1MIn: 150.00,
118− CostPer1MInCached: 0.0,
119− CostPer1MOutCached: 0.0,
120− CostPer1MOut: 600.00,
121− ContextWindow: 200_000,
122− DefaultMaxTokens: 50000,
123− CanReason: true,
124− SupportsAttachments: true,
125− },
126− O1Mini: {
127− ID: O1Mini,
128− Name: "o1 mini",
129− Provider: ProviderOpenAI,
130− APIModel: "o1-mini",
131− CostPer1MIn: 1.10,
132− CostPer1MInCached: 0.55,
133− CostPer1MOutCached: 0.0,
134− CostPer1MOut: 4.40,
135− ContextWindow: 128_000,
136− DefaultMaxTokens: 50000,
137− CanReason: true,
138− SupportsAttachments: true,
139− },
140− O3: {
141− ID: O3,
142− Name: "o3",
143− Provider: ProviderOpenAI,
144− APIModel: "o3",
145− CostPer1MIn: 10.00,
146− CostPer1MInCached: 2.50,
147− CostPer1MOutCached: 0.0,
148− CostPer1MOut: 40.00,
149− ContextWindow: 200_000,
150− CanReason: true,
151− SupportsAttachments: true,
152− },
153− O3Mini: {
154− ID: O3Mini,
155− Name: "o3 mini",
156− Provider: ProviderOpenAI,
157− APIModel: "o3-mini",
158− CostPer1MIn: 1.10,
159− CostPer1MInCached: 0.55,
160− CostPer1MOutCached: 0.0,
161− CostPer1MOut: 4.40,
162− ContextWindow: 200_000,
163− DefaultMaxTokens: 50000,
164− CanReason: true,
165− SupportsAttachments: false,
166− },
167− O4Mini: {
168− ID: O4Mini,
169− Name: "o4 mini",
170− Provider: ProviderOpenAI,
171− APIModel: "o4-mini",
172− CostPer1MIn: 1.10,
173− CostPer1MInCached: 0.275,
174− CostPer1MOutCached: 0.0,
175− CostPer1MOut: 4.40,
176− ContextWindow: 128_000,
177− DefaultMaxTokens: 50000,
178− CanReason: true,
179− SupportsAttachments: true,
180− },
181−}
internal/llm/models/openrouter.godeleted−276
@@ -1,276 +0,0 @@
1−package models
2−
3−const (
4− ProviderOpenRouter InferenceProvider = "openrouter"
5−
6− OpenRouterGPT41 ModelID = "openrouter.gpt-4.1"
7− OpenRouterGPT41Mini ModelID = "openrouter.gpt-4.1-mini"
8− OpenRouterGPT41Nano ModelID = "openrouter.gpt-4.1-nano"
9− OpenRouterGPT45Preview ModelID = "openrouter.gpt-4.5-preview"
10− OpenRouterGPT4o ModelID = "openrouter.gpt-4o"
11− OpenRouterGPT4oMini ModelID = "openrouter.gpt-4o-mini"
12− OpenRouterO1 ModelID = "openrouter.o1"
13− OpenRouterO1Pro ModelID = "openrouter.o1-pro"
14− OpenRouterO1Mini ModelID = "openrouter.o1-mini"
15− OpenRouterO3 ModelID = "openrouter.o3"
16− OpenRouterO3Mini ModelID = "openrouter.o3-mini"
17− OpenRouterO4Mini ModelID = "openrouter.o4-mini"
18− OpenRouterGemini25Flash ModelID = "openrouter.gemini-2.5-flash"
19− OpenRouterGemini25 ModelID = "openrouter.gemini-2.5"
20− OpenRouterClaude35Sonnet ModelID = "openrouter.claude-3.5-sonnet"
21− OpenRouterClaude3Haiku ModelID = "openrouter.claude-3-haiku"
22− OpenRouterClaude37Sonnet ModelID = "openrouter.claude-3.7-sonnet"
23− OpenRouterClaude35Haiku ModelID = "openrouter.claude-3.5-haiku"
24− OpenRouterClaude3Opus ModelID = "openrouter.claude-3-opus"
25− OpenRouterDeepSeekR1Free ModelID = "openrouter.deepseek-r1-free"
26−)
27−
28−var OpenRouterModels = map[ModelID]Model{
29− OpenRouterGPT41: {
30− ID: OpenRouterGPT41,
31− Name: "OpenRouter – GPT 4.1",
32− Provider: ProviderOpenRouter,
33− APIModel: "openai/gpt-4.1",
34− CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
35− CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
36− CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
37− CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
38− ContextWindow: OpenAIModels[GPT41].ContextWindow,
39− DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
40− },
41− OpenRouterGPT41Mini: {
42− ID: OpenRouterGPT41Mini,
43− Name: "OpenRouter – GPT 4.1 mini",
44− Provider: ProviderOpenRouter,
45− APIModel: "openai/gpt-4.1-mini",
46− CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
47− CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
48− CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
49− CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
50− ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
51− DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
52− },
53− OpenRouterGPT41Nano: {
54− ID: OpenRouterGPT41Nano,
55− Name: "OpenRouter – GPT 4.1 nano",
56− Provider: ProviderOpenRouter,
57− APIModel: "openai/gpt-4.1-nano",
58− CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
59− CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
60− CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
61− CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
62− ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
63− DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
64− },
65− OpenRouterGPT45Preview: {
66− ID: OpenRouterGPT45Preview,
67− Name: "OpenRouter – GPT 4.5 preview",
68− Provider: ProviderOpenRouter,
69− APIModel: "openai/gpt-4.5-preview",
70− CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
71− CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
72− CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
73− CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
74− ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
75− DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
76− },
77− OpenRouterGPT4o: {
78− ID: OpenRouterGPT4o,
79− Name: "OpenRouter – GPT 4o",
80− Provider: ProviderOpenRouter,
81− APIModel: "openai/gpt-4o",
82− CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
83− CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
84− CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
85− CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
86− ContextWindow: OpenAIModels[GPT4o].ContextWindow,
87− DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
88− },
89− OpenRouterGPT4oMini: {
90− ID: OpenRouterGPT4oMini,
91− Name: "OpenRouter – GPT 4o mini",
92− Provider: ProviderOpenRouter,
93− APIModel: "openai/gpt-4o-mini",
94− CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
95− CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
96− CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
97− CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
98− ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
99− },
100− OpenRouterO1: {
101− ID: OpenRouterO1,
102− Name: "OpenRouter – O1",
103− Provider: ProviderOpenRouter,
104− APIModel: "openai/o1",
105− CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
106− CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
107− CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
108− CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
109− ContextWindow: OpenAIModels[O1].ContextWindow,
110− DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
111− CanReason: OpenAIModels[O1].CanReason,
112− },
113− OpenRouterO1Pro: {
114− ID: OpenRouterO1Pro,
115− Name: "OpenRouter – o1 pro",
116− Provider: ProviderOpenRouter,
117− APIModel: "openai/o1-pro",
118− CostPer1MIn: OpenAIModels[O1Pro].CostPer1MIn,
119− CostPer1MInCached: OpenAIModels[O1Pro].CostPer1MInCached,
120− CostPer1MOut: OpenAIModels[O1Pro].CostPer1MOut,
121− CostPer1MOutCached: OpenAIModels[O1Pro].CostPer1MOutCached,
122− ContextWindow: OpenAIModels[O1Pro].ContextWindow,
123− DefaultMaxTokens: OpenAIModels[O1Pro].DefaultMaxTokens,
124− CanReason: OpenAIModels[O1Pro].CanReason,
125− },
126− OpenRouterO1Mini: {
127− ID: OpenRouterO1Mini,
128− Name: "OpenRouter – o1 mini",
129− Provider: ProviderOpenRouter,
130− APIModel: "openai/o1-mini",
131− CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
132− CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
133− CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
134− CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
135− ContextWindow: OpenAIModels[O1Mini].ContextWindow,
136− DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
137− CanReason: OpenAIModels[O1Mini].CanReason,
138− },
139− OpenRouterO3: {
140− ID: OpenRouterO3,
141− Name: "OpenRouter – o3",
142− Provider: ProviderOpenRouter,
143− APIModel: "openai/o3",
144− CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
145− CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
146− CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
147− CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
148− ContextWindow: OpenAIModels[O3].ContextWindow,
149− DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
150− CanReason: OpenAIModels[O3].CanReason,
151− },
152− OpenRouterO3Mini: {
153− ID: OpenRouterO3Mini,
154− Name: "OpenRouter – o3 mini",
155− Provider: ProviderOpenRouter,
156− APIModel: "openai/o3-mini-high",
157− CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
158− CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
159− CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
160− CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
161− ContextWindow: OpenAIModels[O3Mini].ContextWindow,
162− DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
163− CanReason: OpenAIModels[O3Mini].CanReason,
164− },
165− OpenRouterO4Mini: {
166− ID: OpenRouterO4Mini,
167− Name: "OpenRouter – o4 mini",
168− Provider: ProviderOpenRouter,
169− APIModel: "openai/o4-mini-high",
170− CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
171− CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
172− CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
173− CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
174− ContextWindow: OpenAIModels[O4Mini].ContextWindow,
175− DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
176− CanReason: OpenAIModels[O4Mini].CanReason,
177− },
178− OpenRouterGemini25Flash: {
179− ID: OpenRouterGemini25Flash,
180− Name: "OpenRouter – Gemini 2.5 Flash",
181− Provider: ProviderOpenRouter,
182− APIModel: "google/gemini-2.5-flash-preview:thinking",
183− CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
184− CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
185− CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
186− CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
187− ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
188− DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
189− },
190− OpenRouterGemini25: {
191− ID: OpenRouterGemini25,
192− Name: "OpenRouter – Gemini 2.5 Pro",
193− Provider: ProviderOpenRouter,
194− APIModel: "google/gemini-2.5-pro-preview-03-25",
195− CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
196− CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
197− CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
198− CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
199− ContextWindow: GeminiModels[Gemini25].ContextWindow,
200− DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
201− },
202− OpenRouterClaude35Sonnet: {
203− ID: OpenRouterClaude35Sonnet,
204− Name: "OpenRouter – Claude 3.5 Sonnet",
205− Provider: ProviderOpenRouter,
206− APIModel: "anthropic/claude-3.5-sonnet",
207− CostPer1MIn: AnthropicModels[Claude35Sonnet].CostPer1MIn,
208− CostPer1MInCached: AnthropicModels[Claude35Sonnet].CostPer1MInCached,
209− CostPer1MOut: AnthropicModels[Claude35Sonnet].CostPer1MOut,
210− CostPer1MOutCached: AnthropicModels[Claude35Sonnet].CostPer1MOutCached,
211− ContextWindow: AnthropicModels[Claude35Sonnet].ContextWindow,
212− DefaultMaxTokens: AnthropicModels[Claude35Sonnet].DefaultMaxTokens,
213− },
214− OpenRouterClaude3Haiku: {
215− ID: OpenRouterClaude3Haiku,
216− Name: "OpenRouter – Claude 3 Haiku",
217− Provider: ProviderOpenRouter,
218− APIModel: "anthropic/claude-3-haiku",
219− CostPer1MIn: AnthropicModels[Claude3Haiku].CostPer1MIn,
220− CostPer1MInCached: AnthropicModels[Claude3Haiku].CostPer1MInCached,
221− CostPer1MOut: AnthropicModels[Claude3Haiku].CostPer1MOut,
222− CostPer1MOutCached: AnthropicModels[Claude3Haiku].CostPer1MOutCached,
223− ContextWindow: AnthropicModels[Claude3Haiku].ContextWindow,
224− DefaultMaxTokens: AnthropicModels[Claude3Haiku].DefaultMaxTokens,
225− },
226− OpenRouterClaude37Sonnet: {
227− ID: OpenRouterClaude37Sonnet,
228− Name: "OpenRouter – Claude 3.7 Sonnet",
229− Provider: ProviderOpenRouter,
230− APIModel: "anthropic/claude-3.7-sonnet",
231− CostPer1MIn: AnthropicModels[Claude37Sonnet].CostPer1MIn,
232− CostPer1MInCached: AnthropicModels[Claude37Sonnet].CostPer1MInCached,
233− CostPer1MOut: AnthropicModels[Claude37Sonnet].CostPer1MOut,
234− CostPer1MOutCached: AnthropicModels[Claude37Sonnet].CostPer1MOutCached,
235− ContextWindow: AnthropicModels[Claude37Sonnet].ContextWindow,
236− DefaultMaxTokens: AnthropicModels[Claude37Sonnet].DefaultMaxTokens,
237− CanReason: AnthropicModels[Claude37Sonnet].CanReason,
238− },
239− OpenRouterClaude35Haiku: {
240− ID: OpenRouterClaude35Haiku,
241− Name: "OpenRouter – Claude 3.5 Haiku",
242− Provider: ProviderOpenRouter,
243− APIModel: "anthropic/claude-3.5-haiku",
244− CostPer1MIn: AnthropicModels[Claude35Haiku].CostPer1MIn,
245− CostPer1MInCached: AnthropicModels[Claude35Haiku].CostPer1MInCached,
246− CostPer1MOut: AnthropicModels[Claude35Haiku].CostPer1MOut,
247− CostPer1MOutCached: AnthropicModels[Claude35Haiku].CostPer1MOutCached,
248− ContextWindow: AnthropicModels[Claude35Haiku].ContextWindow,
249− DefaultMaxTokens: AnthropicModels[Claude35Haiku].DefaultMaxTokens,
250− },
251− OpenRouterClaude3Opus: {
252− ID: OpenRouterClaude3Opus,
253− Name: "OpenRouter – Claude 3 Opus",
254− Provider: ProviderOpenRouter,
255− APIModel: "anthropic/claude-3-opus",
256− CostPer1MIn: AnthropicModels[Claude3Opus].CostPer1MIn,
257− CostPer1MInCached: AnthropicModels[Claude3Opus].CostPer1MInCached,
258− CostPer1MOut: AnthropicModels[Claude3Opus].CostPer1MOut,
259− CostPer1MOutCached: AnthropicModels[Claude3Opus].CostPer1MOutCached,
260− ContextWindow: AnthropicModels[Claude3Opus].ContextWindow,
261− DefaultMaxTokens: AnthropicModels[Claude3Opus].DefaultMaxTokens,
262− },
263−
264− OpenRouterDeepSeekR1Free: {
265− ID: OpenRouterDeepSeekR1Free,
266− Name: "OpenRouter – DeepSeek R1 Free",
267− Provider: ProviderOpenRouter,
268− APIModel: "deepseek/deepseek-r1-0528:free",
269− CostPer1MIn: 0,
270− CostPer1MInCached: 0,
271− CostPer1MOut: 0,
272− CostPer1MOutCached: 0,
273− ContextWindow: 163_840,
274− DefaultMaxTokens: 10000,
275− },
276−}
internal/llm/models/vertexai.godeleted−38
@@ -1,38 +0,0 @@
1−package models
2−
3−const (
4− ProviderVertexAI InferenceProvider = "vertexai"
5−
6− // Models
7− VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
8− VertexAIGemini25 ModelID = "vertexai.gemini-2.5"
9−)
10−
11−var VertexAIGeminiModels = map[ModelID]Model{
12− VertexAIGemini25Flash: {
13− ID: VertexAIGemini25Flash,
14− Name: "VertexAI: Gemini 2.5 Flash",
15− Provider: ProviderVertexAI,
16− APIModel: "gemini-2.5-flash-preview-04-17",
17− CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
18− CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
19− CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
20− CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
21− ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
22− DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
23− SupportsAttachments: true,
24− },
25− VertexAIGemini25: {
26− ID: VertexAIGemini25,
27− Name: "VertexAI: Gemini 2.5 Pro",
28− Provider: ProviderVertexAI,
29− APIModel: "gemini-2.5-pro-preview-03-25",
30− CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
31− CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
32− CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
33− CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
34− ContextWindow: GeminiModels[Gemini25].ContextWindow,
35− DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
36− SupportsAttachments: true,
37− },
38−}
internal/llm/models/xai.godeleted−61
@@ -1,61 +0,0 @@
1−package models
2−
3−const (
4− ProviderXAI InferenceProvider = "xai"
5−
6− XAIGrok3Beta ModelID = "grok-3-beta"
7− XAIGrok3MiniBeta ModelID = "grok-3-mini-beta"
8− XAIGrok3FastBeta ModelID = "grok-3-fast-beta"
9− XAiGrok3MiniFastBeta ModelID = "grok-3-mini-fast-beta"
10−)
11−
12−var XAIModels = map[ModelID]Model{
13− XAIGrok3Beta: {
14− ID: XAIGrok3Beta,
15− Name: "Grok3 Beta",
16− Provider: ProviderXAI,
17− APIModel: "grok-3-beta",
18− CostPer1MIn: 3.0,
19− CostPer1MInCached: 0,
20− CostPer1MOut: 15,
21− CostPer1MOutCached: 0,
22− ContextWindow: 131_072,
23− DefaultMaxTokens: 20_000,
24− },
25− XAIGrok3MiniBeta: {
26− ID: XAIGrok3MiniBeta,
27− Name: "Grok3 Mini Beta",
28− Provider: ProviderXAI,
29− APIModel: "grok-3-mini-beta",
30− CostPer1MIn: 0.3,
31− CostPer1MInCached: 0,
32− CostPer1MOut: 0.5,
33− CostPer1MOutCached: 0,
34− ContextWindow: 131_072,
35− DefaultMaxTokens: 20_000,
36− },
37− XAIGrok3FastBeta: {
38− ID: XAIGrok3FastBeta,
39− Name: "Grok3 Fast Beta",
40− Provider: ProviderXAI,
41− APIModel: "grok-3-fast-beta",
42− CostPer1MIn: 5,
43− CostPer1MInCached: 0,
44− CostPer1MOut: 25,
45− CostPer1MOutCached: 0,
46− ContextWindow: 131_072,
47− DefaultMaxTokens: 20_000,
48− },
49− XAiGrok3MiniFastBeta: {
50− ID: XAiGrok3MiniFastBeta,
51− Name: "Grok3 Mini Fast Beta",
52− Provider: ProviderXAI,
53− APIModel: "grok-3-mini-fast-beta",
54− CostPer1MIn: 0.6,
55− CostPer1MInCached: 0,
56− CostPer1MOut: 4.0,
57− CostPer1MOutCached: 0,
58− ContextWindow: 131_072,
59− DefaultMaxTokens: 20_000,
60− },
61−}
internal/llm/prompt/coder.go+13−5
@@ -9,19 +9,27 @@ import (
99 "time"
1010
1111 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/fur/provider"
1313 "github.com/charmbracelet/crush/internal/llm/tools"
14+ "github.com/charmbracelet/crush/internal/logging"
1415)
1516
16−func CoderPrompt(provider models.InferenceProvider) string {
17+func CoderPrompt(p provider.InferenceProvider, contextFiles ...string) string {
1718 basePrompt := baseAnthropicCoderPrompt
18− switch provider {
19− case models.ProviderOpenAI:
19+ switch p {
20+ case provider.InferenceProviderOpenAI:
2021 basePrompt = baseOpenAICoderPrompt
2122 }
2223 envInfo := getEnvironmentInfo()
2324
24− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
25+ basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
26+
27+ contextContent := getContextFromPaths(contextFiles)
28+ logging.Debug("Context content", "Context", contextContent)
29+ if contextContent != "" {
30+ return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
31+ }
32+ return basePrompt
2533}
2634
2735const baseOpenAICoderPrompt = `
internal/llm/prompt/prompt.go+19−35
@@ -1,60 +1,44 @@
11package prompt
22
33import (
4− "fmt"
54 "os"
65 "path/filepath"
76 "strings"
87 "sync"
98
109 "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/llm/models"
12− "github.com/charmbracelet/crush/internal/logging"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
1311)
1412
15−func GetAgentPrompt(agentName config.AgentName, provider models.InferenceProvider) string {
13+type PromptID string
14+
15+const (
16+ PromptCoder PromptID = "coder"
17+ PromptTitle PromptID = "title"
18+ PromptTask PromptID = "task"
19+ PromptSummarizer PromptID = "summarizer"
20+ PromptDefault PromptID = "default"
21+)
22+
23+func GetPrompt(promptID PromptID, provider provider.InferenceProvider, contextPaths ...string) string {
1624 basePrompt := ""
17− switch agentName {
18− case config.AgentCoder:
25+ switch promptID {
26+ case PromptCoder:
1927 basePrompt = CoderPrompt(provider)
20− case config.AgentTitle:
28+ case PromptTitle:
2129 basePrompt = TitlePrompt(provider)
22− case config.AgentTask:
30+ case PromptTask:
2331 basePrompt = TaskPrompt(provider)
24− case config.AgentSummarizer:
32+ case PromptSummarizer:
2533 basePrompt = SummarizerPrompt(provider)
2634 default:
2735 basePrompt = "You are a helpful assistant"
2836 }
29−
30− if agentName == config.AgentCoder || agentName == config.AgentTask {
31− // Add context from project-specific instruction files if they exist
32− contextContent := getContextFromPaths()
33− logging.Debug("Context content", "Context", contextContent)
34− if contextContent != "" {
35− return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
36− }
37− }
3837 return basePrompt
3938}
4039
41−var (
42− onceContext sync.Once
43− contextContent string
44−)
45−
46−func getContextFromPaths() string {
47− onceContext.Do(func() {
48− var (
49− cfg = config.Get()
50− workDir = cfg.WorkingDir
51− contextPaths = cfg.ContextPaths
52− )
53−
54− contextContent = processContextPaths(workDir, contextPaths)
55− })
56−
57− return contextContent
40+func getContextFromPaths(contextPaths []string) string {
41+ return processContextPaths(config.WorkingDirectory(), contextPaths)
5842}
5943
6044func processContextPaths(workDir string, paths []string) string {
internal/llm/prompt/prompt_test.go+7−8
@@ -15,16 +15,10 @@ func TestGetContextFromPaths(t *testing.T) {
1515 t.Parallel()
1616
1717 tmpDir := t.TempDir()
18− _, err := config.Load(tmpDir, false)
18+ _, err := config.Init(tmpDir, false)
1919 if err != nil {
2020 t.Fatalf("Failed to load config: %v", err)
2121 }
22− cfg := config.Get()
23− cfg.WorkingDir = tmpDir
24− cfg.ContextPaths = []string{
25− "file.txt",
26− "directory/",
27− }
2822 testFiles := []string{
2923 "file.txt",
3024 "directory/file_a.txt",
@@ -34,7 +28,12 @@ func TestGetContextFromPaths(t *testing.T) {
3428
3529 createTestFiles(t, tmpDir, testFiles)
3630
37− context := getContextFromPaths()
31+ context := getContextFromPaths(
32+ []string{
33+ "file.txt",
34+ "directory/",
35+ },
36+ )
3837 expectedContext := fmt.Sprintf("# From:%s/file.txt\nfile.txt: test content\n# From:%s/directory/file_a.txt\ndirectory/file_a.txt: test content\n# From:%s/directory/file_b.txt\ndirectory/file_b.txt: test content\n# From:%s/directory/file_c.txt\ndirectory/file_c.txt: test content", tmpDir, tmpDir, tmpDir, tmpDir)
3938 assert.Equal(t, expectedContext, context)
4039}
internal/llm/prompt/summarizer.go+4−2
@@ -1,8 +1,10 @@
11package prompt
22
3−import "github.com/charmbracelet/crush/internal/llm/models"
3+import (
4+ "github.com/charmbracelet/crush/internal/fur/provider"
5+)
46
5−func SummarizerPrompt(_ models.InferenceProvider) string {
7+func SummarizerPrompt(_ provider.InferenceProvider) string {
68 return `You are a helpful AI assistant tasked with summarizing conversations.
79
810When asked to summarize, provide a detailed but concise summary of the conversation.
internal/llm/prompt/task.go+2−2
@@ -3,10 +3,10 @@ package prompt
33import (
44 "fmt"
55
6− "github.com/charmbracelet/crush/internal/llm/models"
6+ "github.com/charmbracelet/crush/internal/fur/provider"
77)
88
9−func TaskPrompt(_ models.InferenceProvider) string {
9+func TaskPrompt(_ provider.InferenceProvider) string {
1010 agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
1111Notes:
12121. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
internal/llm/prompt/title.go+4−2
@@ -1,8 +1,10 @@
11package prompt
22
3−import "github.com/charmbracelet/crush/internal/llm/models"
3+import (
4+ "github.com/charmbracelet/crush/internal/fur/provider"
5+)
46
5−func TitlePrompt(_ models.InferenceProvider) string {
7+func TitlePrompt(_ provider.InferenceProvider) string {
68 return `you will generate a short title based on the first message a user begins a conversation with
79- ensure it is not more than 50 characters long
810- the title should be a summary of the user's message
internal/llm/provider/anthropic.go+5−5
@@ -13,7 +13,7 @@ import (
1313 "github.com/anthropics/anthropic-sdk-go/bedrock"
1414 "github.com/anthropics/anthropic-sdk-go/option"
1515 "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/llm/models"
16+ "github.com/charmbracelet/crush/internal/fur/provider"
1717 "github.com/charmbracelet/crush/internal/llm/tools"
1818 "github.com/charmbracelet/crush/internal/logging"
1919 "github.com/charmbracelet/crush/internal/message"
@@ -59,7 +59,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
5959 var contentBlocks []anthropic.ContentBlockParamUnion
6060 contentBlocks = append(contentBlocks, content)
6161 for _, binaryContent := range msg.BinaryContent() {
62− base64Image := binaryContent.String(models.ProviderAnthropic)
62+ base64Image := binaryContent.String(provider.InferenceProviderAnthropic)
6363 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
6464 contentBlocks = append(contentBlocks, imageBlock)
6565 }
@@ -164,7 +164,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
164164 // }
165165
166166 return anthropic.MessageNewParams{
167− Model: anthropic.Model(a.providerOptions.model.APIModel),
167+ Model: anthropic.Model(a.providerOptions.model.ID),
168168 MaxTokens: a.providerOptions.maxTokens,
169169 Temperature: temperature,
170170 Messages: messages,
@@ -184,7 +184,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
184184func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
185185 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
186186 cfg := config.Get()
187− if cfg.Debug {
187+ if cfg.Options.Debug {
188188 jsonData, _ := json.Marshal(preparedMessages)
189189 logging.Debug("Prepared messages", "messages", string(jsonData))
190190 }
@@ -233,7 +233,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
233233func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
234234 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
235235 cfg := config.Get()
236− if cfg.Debug {
236+ if cfg.Options.Debug {
237237 // jsonData, _ := json.Marshal(preparedMessages)
238238 // logging.Debug("Prepared messages", "messages", string(jsonData))
239239 }
internal/llm/provider/bedrock.go+4−11
@@ -4,7 +4,6 @@ import (
44 "context"
55 "errors"
66 "fmt"
7− "os"
87 "strings"
98
109 "github.com/charmbracelet/crush/internal/llm/tools"
@@ -19,14 +18,8 @@ type bedrockClient struct {
1918type BedrockClient ProviderClient
2019
2120func newBedrockClient(opts providerClientOptions) BedrockClient {
22− // Apply bedrock specific options if they are added in the future
23−
2421 // Get AWS region from environment
25− region := os.Getenv("AWS_REGION")
26− if region == "" {
27− region = os.Getenv("AWS_DEFAULT_REGION")
28− }
29−
22+ region := opts.extraParams["region"]
3023 if region == "" {
3124 region = "us-east-1" // default region
3225 }
@@ -39,11 +32,11 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
3932
4033 // Prefix the model name with region
4134 regionPrefix := region[:2]
42− modelName := opts.model.APIModel
43− opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
35+ modelName := opts.model.ID
36+ opts.model.ID = fmt.Sprintf("%s.%s", regionPrefix, modelName)
4437
4538 // Determine which provider to use based on the model
46− if strings.Contains(string(opts.model.APIModel), "anthropic") {
39+ if strings.Contains(string(opts.model.ID), "anthropic") {
4740 // Create Anthropic client with Bedrock configuration
4841 anthropicOpts := opts
4942 // TODO: later find a way to check if the AWS account has caching enabled
internal/llm/provider/gemini.go+4−4
@@ -157,7 +157,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
157157 geminiMessages := g.convertMessages(messages)
158158
159159 cfg := config.Get()
160− if cfg.Debug {
160+ if cfg.Options.Debug {
161161 jsonData, _ := json.Marshal(geminiMessages)
162162 logging.Debug("Prepared messages", "messages", string(jsonData))
163163 }
@@ -173,7 +173,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
173173 if len(tools) > 0 {
174174 config.Tools = g.convertTools(tools)
175175 }
176− chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
176+ chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.ID, config, history)
177177
178178 attempts := 0
179179 for {
@@ -245,7 +245,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
245245 geminiMessages := g.convertMessages(messages)
246246
247247 cfg := config.Get()
248− if cfg.Debug {
248+ if cfg.Options.Debug {
249249 jsonData, _ := json.Marshal(geminiMessages)
250250 logging.Debug("Prepared messages", "messages", string(jsonData))
251251 }
@@ -261,7 +261,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
261261 if len(tools) > 0 {
262262 config.Tools = g.convertTools(tools)
263263 }
264− chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
264+ chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.ID, config, history)
265265
266266 attempts := 0
267267 eventChan := make(chan ProviderEvent)
internal/llm/provider/openai.go+5−5
@@ -9,7 +9,7 @@ import (
99 "time"
1010
1111 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/fur/provider"
1313 "github.com/charmbracelet/crush/internal/llm/tools"
1414 "github.com/charmbracelet/crush/internal/logging"
1515 "github.com/charmbracelet/crush/internal/message"
@@ -68,7 +68,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
6868 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
6969 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
7070 for _, binaryContent := range msg.BinaryContent() {
71− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
71+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(provider.InferenceProviderOpenAI)}
7272 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
7373
7474 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
@@ -153,7 +153,7 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
153153
154154func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
155155 params := openai.ChatCompletionNewParams{
156− Model: openai.ChatModel(o.providerOptions.model.APIModel),
156+ Model: openai.ChatModel(o.providerOptions.model.ID),
157157 Messages: messages,
158158 Tools: tools,
159159 }
@@ -180,7 +180,7 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
180180func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
181181 params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
182182 cfg := config.Get()
183− if cfg.Debug {
183+ if cfg.Options.Debug {
184184 jsonData, _ := json.Marshal(params)
185185 logging.Debug("Prepared messages", "messages", string(jsonData))
186186 }
@@ -237,7 +237,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
237237 }
238238
239239 cfg := config.Get()
240− if cfg.Debug {
240+ if cfg.Options.Debug {
241241 jsonData, _ := json.Marshal(params)
242242 logging.Debug("Prepared messages", "messages", string(jsonData))
243243 }
internal/llm/provider/provider.go+57−77
@@ -3,9 +3,9 @@ package provider
33import (
44 "context"
55 "fmt"
6− "os"
76
8− "github.com/charmbracelet/crush/internal/llm/models"
7+ configv2 "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/fur/provider"
99 "github.com/charmbracelet/crush/internal/llm/tools"
1010 "github.com/charmbracelet/crush/internal/message"
1111)
@@ -55,17 +55,18 @@ type Provider interface {
5555
5656 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
5757
58− Model() models.Model
58+ Model() configv2.Model
5959}
6060
6161type providerClientOptions struct {
6262 baseURL string
6363 apiKey string
64− model models.Model
64+ model configv2.Model
6565 disableCache bool
6666 maxTokens int64
6767 systemMessage string
6868 extraHeaders map[string]string
69+ extraParams map[string]string
6970}
7071
7172type ProviderClientOption func(*providerClientOptions)
@@ -80,77 +81,6 @@ type baseProvider[C ProviderClient] struct {
8081 client C
8182}
8283
83−func NewProvider(providerName models.InferenceProvider, opts ...ProviderClientOption) (Provider, error) {
84− clientOptions := providerClientOptions{}
85− for _, o := range opts {
86− o(&clientOptions)
87− }
88− switch providerName {
89− case models.ProviderAnthropic:
90− return &baseProvider[AnthropicClient]{
91− options: clientOptions,
92− client: newAnthropicClient(clientOptions, false),
93− }, nil
94− case models.ProviderOpenAI:
95− return &baseProvider[OpenAIClient]{
96− options: clientOptions,
97− client: newOpenAIClient(clientOptions),
98− }, nil
99− case models.ProviderGemini:
100− return &baseProvider[GeminiClient]{
101− options: clientOptions,
102− client: newGeminiClient(clientOptions),
103− }, nil
104− case models.ProviderBedrock:
105− return &baseProvider[BedrockClient]{
106− options: clientOptions,
107− client: newBedrockClient(clientOptions),
108− }, nil
109− case models.ProviderGROQ:
110− clientOptions.baseURL = "https://api.groq.com/openai/v1"
111− return &baseProvider[OpenAIClient]{
112− options: clientOptions,
113− client: newOpenAIClient(clientOptions),
114− }, nil
115− case models.ProviderAzure:
116− return &baseProvider[AzureClient]{
117− options: clientOptions,
118− client: newAzureClient(clientOptions),
119− }, nil
120− case models.ProviderVertexAI:
121− return &baseProvider[VertexAIClient]{
122− options: clientOptions,
123− client: newVertexAIClient(clientOptions),
124− }, nil
125− case models.ProviderOpenRouter:
126− clientOptions.baseURL = "https://openrouter.ai/api/v1"
127− clientOptions.extraHeaders = map[string]string{
128− "HTTP-Referer": "crush.charm.land",
129− "X-Title": "Crush",
130− }
131− return &baseProvider[OpenAIClient]{
132− options: clientOptions,
133− client: newOpenAIClient(clientOptions),
134− }, nil
135− case models.ProviderXAI:
136− clientOptions.baseURL = "https://api.x.ai/v1"
137− return &baseProvider[OpenAIClient]{
138− options: clientOptions,
139− client: newOpenAIClient(clientOptions),
140− }, nil
141− case models.ProviderLocal:
142− clientOptions.baseURL = os.Getenv("LOCAL_ENDPOINT")
143− return &baseProvider[OpenAIClient]{
144− options: clientOptions,
145− client: newOpenAIClient(clientOptions),
146− }, nil
147− case models.ProviderMock:
148− // TODO: implement mock client for test
149− panic("not implemented")
150− }
151− return nil, fmt.Errorf("provider not supported: %s", providerName)
152−}
153−
15484func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
15585 for _, msg := range messages {
15686 // The message has no content
@@ -167,7 +97,7 @@ func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.M
16797 return p.client.send(ctx, messages, tools)
16898}
16999
170−func (p *baseProvider[C]) Model() models.Model {
100+func (p *baseProvider[C]) Model() configv2.Model {
171101 return p.options.model
172102}
173103
@@ -176,7 +106,7 @@ func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message
176106 return p.client.stream(ctx, messages, tools)
177107}
178108
179−func WithModel(model models.Model) ProviderClientOption {
109+func WithModel(model configv2.Model) ProviderClientOption {
180110 return func(options *providerClientOptions) {
181111 options.model = model
182112 }
@@ -199,3 +129,53 @@ func WithSystemMessage(systemMessage string) ProviderClientOption {
199129 options.systemMessage = systemMessage
200130 }
201131}
132+
133+func NewProviderV2(cfg configv2.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
134+ clientOptions := providerClientOptions{
135+ baseURL: cfg.BaseURL,
136+ apiKey: cfg.APIKey,
137+ extraHeaders: cfg.ExtraHeaders,
138+ }
139+ for _, o := range opts {
140+ o(&clientOptions)
141+ }
142+ switch cfg.ProviderType {
143+ case provider.TypeAnthropic:
144+ return &baseProvider[AnthropicClient]{
145+ options: clientOptions,
146+ client: newAnthropicClient(clientOptions, false),
147+ }, nil
148+ case provider.TypeOpenAI:
149+ return &baseProvider[OpenAIClient]{
150+ options: clientOptions,
151+ client: newOpenAIClient(clientOptions),
152+ }, nil
153+ case provider.TypeGemini:
154+ return &baseProvider[GeminiClient]{
155+ options: clientOptions,
156+ client: newGeminiClient(clientOptions),
157+ }, nil
158+ case provider.TypeBedrock:
159+ return &baseProvider[BedrockClient]{
160+ options: clientOptions,
161+ client: newBedrockClient(clientOptions),
162+ }, nil
163+ case provider.TypeAzure:
164+ return &baseProvider[AzureClient]{
165+ options: clientOptions,
166+ client: newAzureClient(clientOptions),
167+ }, nil
168+ case provider.TypeVertexAI:
169+ return &baseProvider[VertexAIClient]{
170+ options: clientOptions,
171+ client: newVertexAIClient(clientOptions),
172+ }, nil
173+ case provider.TypeXAI:
174+ clientOptions.baseURL = "https://api.x.ai/v1"
175+ return &baseProvider[OpenAIClient]{
176+ options: clientOptions,
177+ client: newOpenAIClient(clientOptions),
178+ }, nil
179+ }
180+ return nil, fmt.Errorf("provider not supported: %s", cfg.ProviderType)
181+}
internal/llm/provider/vertexai.go+4−3
@@ -2,7 +2,6 @@ package provider
22
33import (
44 "context"
5− "os"
65
76 "github.com/charmbracelet/crush/internal/logging"
87 "google.golang.org/genai"
@@ -11,9 +10,11 @@ import (
1110type VertexAIClient ProviderClient
1211
1312func newVertexAIClient(opts providerClientOptions) VertexAIClient {
13+ project := opts.extraHeaders["project"]
14+ location := opts.extraHeaders["location"]
1415 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
15− Project: os.Getenv("GOOGLE_CLOUD_PROJECT"),
16− Location: os.Getenv("GOOGLE_CLOUD_LOCATION"),
16+ Project: project,
17+ Location: location,
1718 Backend: genai.BackendVertexAI,
1819 })
1920 if err != nil {
internal/lsp/client.go+14−14
@@ -286,7 +286,7 @@ func (c *Client) SetServerState(state ServerState) {
286286// WaitForServerReady waits for the server to be ready by polling the server
287287// with a simple request until it responds successfully or times out
288288func (c *Client) WaitForServerReady(ctx context.Context) error {
289− cnf := config.Get()
289+ cfg := config.Get()
290290
291291 // Set initial state
292292 c.SetServerState(StateStarting)
@@ -299,7 +299,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
299299 ticker := time.NewTicker(500 * time.Millisecond)
300300 defer ticker.Stop()
301301
302− if cnf.DebugLSP {
302+ if cfg.Options.DebugLSP {
303303 logging.Debug("Waiting for LSP server to be ready...")
304304 }
305305
@@ -308,7 +308,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
308308
309309 // For TypeScript-like servers, we need to open some key files first
310310 if serverType == ServerTypeTypeScript {
311− if cnf.DebugLSP {
311+ if cfg.Options.DebugLSP {
312312 logging.Debug("TypeScript-like server detected, opening key configuration files")
313313 }
314314 c.openKeyConfigFiles(ctx)
@@ -325,7 +325,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
325325 if err == nil {
326326 // Server responded successfully
327327 c.SetServerState(StateReady)
328− if cnf.DebugLSP {
328+ if cfg.Options.DebugLSP {
329329 logging.Debug("LSP server is ready")
330330 }
331331 return nil
@@ -333,7 +333,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
333333 logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
334334 }
335335
336− if cnf.DebugLSP {
336+ if cfg.Options.DebugLSP {
337337 logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
338338 }
339339 }
@@ -496,7 +496,7 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
496496
497497// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
498498func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
499− cnf := config.Get()
499+ cfg := config.Get()
500500 filesOpened := 0
501501 maxFilesToOpen := 5 // Limit to a reasonable number of files
502502
@@ -526,7 +526,7 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
526526 // Try to open the file
527527 if err := c.OpenFile(ctx, path); err == nil {
528528 filesOpened++
529− if cnf.DebugLSP {
529+ if cfg.Options.DebugLSP {
530530 logging.Debug("Opened TypeScript file for initialization", "file", path)
531531 }
532532 }
@@ -535,11 +535,11 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
535535 return nil
536536 })
537537
538− if err != nil && cnf.DebugLSP {
538+ if err != nil && cfg.Options.DebugLSP {
539539 logging.Debug("Error walking directory for TypeScript files", "error", err)
540540 }
541541
542− if cnf.DebugLSP {
542+ if cfg.Options.DebugLSP {
543543 logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
544544 }
545545}
@@ -664,7 +664,7 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
664664}
665665
666666func (c *Client) CloseFile(ctx context.Context, filepath string) error {
667− cnf := config.Get()
667+ cfg := config.Get()
668668 uri := string(protocol.URIFromPath(filepath))
669669
670670 c.openFilesMu.Lock()
@@ -680,7 +680,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
680680 },
681681 }
682682
683− if cnf.DebugLSP {
683+ if cfg.Options.DebugLSP {
684684 logging.Debug("Closing file", "file", filepath)
685685 }
686686 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
@@ -704,7 +704,7 @@ func (c *Client) IsFileOpen(filepath string) bool {
704704
705705// CloseAllFiles closes all currently open files
706706func (c *Client) CloseAllFiles(ctx context.Context) {
707− cnf := config.Get()
707+ cfg := config.Get()
708708 c.openFilesMu.Lock()
709709 filesToClose := make([]string, 0, len(c.openFiles))
710710
@@ -719,12 +719,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
719719 // Then close them all
720720 for _, filePath := range filesToClose {
721721 err := c.CloseFile(ctx, filePath)
722− if err != nil && cnf.DebugLSP {
722+ if err != nil && cfg.Options.DebugLSP {
723723 logging.Warn("Error closing file", "file", filePath, "error", err)
724724 }
725725 }
726726
727− if cnf.DebugLSP {
727+ if cfg.Options.DebugLSP {
728728 logging.Debug("Closed all files", "files", filesToClose)
729729 }
730730}
internal/lsp/handlers.go+2−2
@@ -82,13 +82,13 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche
8282// Notifications
8383
8484func HandleServerMessage(params json.RawMessage) {
85− cnf := config.Get()
85+ cfg := config.Get()
8686 var msg struct {
8787 Type int `json:"type"`
8888 Message string `json:"message"`
8989 }
9090 if err := json.Unmarshal(params, &msg); err == nil {
91− if cnf.DebugLSP {
91+ if cfg.Options.DebugLSP {
9292 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
9393 }
9494 }
internal/lsp/transport.go+19−19
@@ -18,9 +18,9 @@ func WriteMessage(w io.Writer, msg *Message) error {
1818 if err != nil {
1919 return fmt.Errorf("failed to marshal message: %w", err)
2020 }
21− cnf := config.Get()
21+ cfg := config.Get()
2222
23− if cnf.DebugLSP {
23+ if cfg.Options.DebugLSP {
2424 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
2525 }
2626
@@ -39,7 +39,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
3939
4040// ReadMessage reads a single LSP message from the given reader
4141func ReadMessage(r *bufio.Reader) (*Message, error) {
42− cnf := config.Get()
42+ cfg := config.Get()
4343 // Read headers
4444 var contentLength int
4545 for {
@@ -49,7 +49,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
4949 }
5050 line = strings.TrimSpace(line)
5151
52− if cnf.DebugLSP {
52+ if cfg.Options.DebugLSP {
5353 logging.Debug("Received header", "line", line)
5454 }
5555
@@ -65,7 +65,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
6565 }
6666 }
6767
68− if cnf.DebugLSP {
68+ if cfg.Options.DebugLSP {
6969 logging.Debug("Content-Length", "length", contentLength)
7070 }
7171
@@ -76,7 +76,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
7676 return nil, fmt.Errorf("failed to read content: %w", err)
7777 }
7878
79− if cnf.DebugLSP {
79+ if cfg.Options.DebugLSP {
8080 logging.Debug("Received content", "content", string(content))
8181 }
8282
@@ -91,11 +91,11 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
9191
9292// handleMessages reads and dispatches messages in a loop
9393func (c *Client) handleMessages() {
94− cnf := config.Get()
94+ cfg := config.Get()
9595 for {
9696 msg, err := ReadMessage(c.stdout)
9797 if err != nil {
98− if cnf.DebugLSP {
98+ if cfg.Options.DebugLSP {
9999 logging.Error("Error reading message", "error", err)
100100 }
101101 return
@@ -103,7 +103,7 @@ func (c *Client) handleMessages() {
103103
104104 // Handle server->client request (has both Method and ID)
105105 if msg.Method != "" && msg.ID != 0 {
106− if cnf.DebugLSP {
106+ if cfg.Options.DebugLSP {
107107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
108108 }
109109
@@ -157,11 +157,11 @@ func (c *Client) handleMessages() {
157157 c.notificationMu.RUnlock()
158158
159159 if ok {
160− if cnf.DebugLSP {
160+ if cfg.Options.DebugLSP {
161161 logging.Debug("Handling notification", "method", msg.Method)
162162 }
163163 go handler(msg.Params)
164− } else if cnf.DebugLSP {
164+ } else if cfg.Options.DebugLSP {
165165 logging.Debug("No handler for notification", "method", msg.Method)
166166 }
167167 continue
@@ -174,12 +174,12 @@ func (c *Client) handleMessages() {
174174 c.handlersMu.RUnlock()
175175
176176 if ok {
177− if cnf.DebugLSP {
177+ if cfg.Options.DebugLSP {
178178 logging.Debug("Received response for request", "id", msg.ID)
179179 }
180180 ch <- msg
181181 close(ch)
182− } else if cnf.DebugLSP {
182+ } else if cfg.Options.DebugLSP {
183183 logging.Debug("No handler for response", "id", msg.ID)
184184 }
185185 }
@@ -188,10 +188,10 @@ func (c *Client) handleMessages() {
188188
189189// Call makes a request and waits for the response
190190func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
191− cnf := config.Get()
191+ cfg := config.Get()
192192 id := c.nextID.Add(1)
193193
194− if cnf.DebugLSP {
194+ if cfg.Options.DebugLSP {
195195 logging.Debug("Making call", "method", method, "id", id)
196196 }
197197
@@ -217,14 +217,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217217 return fmt.Errorf("failed to send request: %w", err)
218218 }
219219
220− if cnf.DebugLSP {
220+ if cfg.Options.DebugLSP {
221221 logging.Debug("Request sent", "method", method, "id", id)
222222 }
223223
224224 // Wait for response
225225 resp := <-ch
226226
227− if cnf.DebugLSP {
227+ if cfg.Options.DebugLSP {
228228 logging.Debug("Received response", "id", id)
229229 }
230230
@@ -249,8 +249,8 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
249249
250250// Notify sends a notification (a request without an ID that doesn't expect a response)
251251func (c *Client) Notify(ctx context.Context, method string, params any) error {
252− cnf := config.Get()
253− if cnf.DebugLSP {
252+ cfg := config.Get()
253+ if cfg.Options.DebugLSP {
254254 logging.Debug("Sending notification", "method", method)
255255 }
256256
internal/lsp/watcher/watcher.go+25−25
@@ -43,7 +43,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
4343
4444// AddRegistrations adds file watchers to track
4545func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
46− cnf := config.Get()
46+ cfg := config.Get()
4747
4848 logging.Debug("Adding file watcher registrations")
4949 w.registrationMu.Lock()
@@ -53,7 +53,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
5353 w.registrations = append(w.registrations, watchers...)
5454
5555 // Print detailed registration information for debugging
56− if cnf.DebugLSP {
56+ if cfg.Options.DebugLSP {
5757 logging.Debug("Adding file watcher registrations",
5858 "id", id,
5959 "watchers", len(watchers),
@@ -122,7 +122,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
122122 highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123123 filesOpened += highPriorityFilesOpened
124124
125− if cnf.DebugLSP {
125+ if cfg.Options.DebugLSP {
126126 logging.Debug("Opened high-priority files",
127127 "count", highPriorityFilesOpened,
128128 "serverName", serverName)
@@ -130,7 +130,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
130130
131131 // If we've already opened enough high-priority files, we might not need more
132132 if filesOpened >= maxFilesToOpen {
133− if cnf.DebugLSP {
133+ if cfg.Options.DebugLSP {
134134 logging.Debug("Reached file limit with high-priority files",
135135 "filesOpened", filesOpened,
136136 "maxFiles", maxFilesToOpen)
@@ -148,7 +148,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
148148 // Skip directories that should be excluded
149149 if d.IsDir() {
150150 if path != w.workspacePath && shouldExcludeDir(path) {
151− if cnf.DebugLSP {
151+ if cfg.Options.DebugLSP {
152152 logging.Debug("Skipping excluded directory", "path", path)
153153 }
154154 return filepath.SkipDir
@@ -176,7 +176,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
176176 })
177177
178178 elapsedTime := time.Since(startTime)
179− if cnf.DebugLSP {
179+ if cfg.Options.DebugLSP {
180180 logging.Debug("Limited workspace scan complete",
181181 "filesOpened", filesOpened,
182182 "maxFiles", maxFilesToOpen,
@@ -185,11 +185,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
185185 )
186186 }
187187
188− if err != nil && cnf.DebugLSP {
188+ if err != nil && cfg.Options.DebugLSP {
189189 logging.Debug("Error scanning workspace for files to open", "error", err)
190190 }
191191 }()
192− } else if cnf.DebugLSP {
192+ } else if cfg.Options.DebugLSP {
193193 logging.Debug("Using on-demand file loading for server", "server", serverName)
194194 }
195195}
@@ -197,7 +197,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
197197// openHighPriorityFiles opens important files for the server type
198198// Returns the number of files opened
199199func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
200− cnf := config.Get()
200+ cfg := config.Get()
201201 filesOpened := 0
202202
203203 // Define patterns for high-priority files based on server type
@@ -265,7 +265,7 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
265265 // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
266266 matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
267267 if err != nil {
268− if cnf.DebugLSP {
268+ if cfg.Options.DebugLSP {
269269 logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
270270 }
271271 continue
@@ -299,12 +299,12 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
299299 for j := i; j < end; j++ {
300300 fullPath := filesToOpen[j]
301301 if err := w.client.OpenFile(ctx, fullPath); err != nil {
302− if cnf.DebugLSP {
302+ if cfg.Options.DebugLSP {
303303 logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
304304 }
305305 } else {
306306 filesOpened++
307− if cnf.DebugLSP {
307+ if cfg.Options.DebugLSP {
308308 logging.Debug("Opened high-priority file", "path", fullPath)
309309 }
310310 }
@@ -321,7 +321,7 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
321321
322322// WatchWorkspace sets up file watching for a workspace
323323func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
324− cnf := config.Get()
324+ cfg := config.Get()
325325 w.workspacePath = workspacePath
326326
327327 // Store the watcher in the context for later use
@@ -356,7 +356,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
356356 // Skip excluded directories (except workspace root)
357357 if d.IsDir() && path != workspacePath {
358358 if shouldExcludeDir(path) {
359− if cnf.DebugLSP {
359+ if cfg.Options.DebugLSP {
360360 logging.Debug("Skipping excluded directory", "path", path)
361361 }
362362 return filepath.SkipDir
@@ -409,7 +409,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
409409 }
410410
411411 // Debug logging
412− if cnf.DebugLSP {
412+ if cfg.Options.DebugLSP {
413413 matched, kind := w.isPathWatched(event.Name)
414414 logging.Debug("File event",
415415 "path", event.Name,
@@ -676,8 +676,8 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
676676
677677// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
678678func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
679− cnf := config.Get()
680− if cnf.DebugLSP {
679+ cfg := config.Get()
680+ if cfg.Options.DebugLSP {
681681 logging.Debug("Notifying file event",
682682 "uri", uri,
683683 "changeType", changeType,
@@ -826,7 +826,7 @@ func shouldExcludeDir(dirPath string) bool {
826826// shouldExcludeFile returns true if the file should be excluded from opening
827827func shouldExcludeFile(filePath string) bool {
828828 fileName := filepath.Base(filePath)
829− cnf := config.Get()
829+ cfg := config.Get()
830830 // Skip dot files
831831 if strings.HasPrefix(fileName, ".") {
832832 return true
@@ -852,12 +852,12 @@ func shouldExcludeFile(filePath string) bool {
852852
853853 // Skip large files
854854 if info.Size() > maxFileSize {
855− if cnf.DebugLSP {
855+ if cfg.Options.DebugLSP {
856856 logging.Debug("Skipping large file",
857857 "path", filePath,
858858 "size", info.Size(),
859859 "maxSize", maxFileSize,
860− "debug", cnf.Debug,
860+ "debug", cfg.Options.Debug,
861861 "sizeMB", float64(info.Size())/(1024*1024),
862862 "maxSizeMB", float64(maxFileSize)/(1024*1024),
863863 )
@@ -870,7 +870,7 @@ func shouldExcludeFile(filePath string) bool {
870870
871871// openMatchingFile opens a file if it matches any of the registered patterns
872872func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
873− cnf := config.Get()
873+ cfg := config.Get()
874874 // Skip directories
875875 info, err := os.Stat(path)
876876 if err != nil || info.IsDir() {
@@ -890,10 +890,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
890890 // Check if the file is a high-priority file that should be opened immediately
891891 // This helps with project initialization for certain language servers
892892 if isHighPriorityFile(path, serverName) {
893− if cnf.DebugLSP {
893+ if cfg.Options.DebugLSP {
894894 logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
895895 }
896− if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
896+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
897897 logging.Error("Error opening high-priority file", "path", path, "error", err)
898898 }
899899 return
@@ -905,7 +905,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
905905
906906 // Check file size - for preloading we're more conservative
907907 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
908− if cnf.DebugLSP {
908+ if cfg.Options.DebugLSP {
909909 logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
910910 }
911911 return
@@ -937,7 +937,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
937937
938938 if shouldOpen {
939939 // Don't need to check if it's already open - the client.OpenFile handles that
940− if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
940+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
941941 logging.Error("Error opening file", "path", path, "error", err)
942942 }
943943 }
internal/message/content.go+5−4
@@ -5,7 +5,7 @@ import (
55 "slices"
66 "time"
77
8− "github.com/charmbracelet/crush/internal/llm/models"
8+ "github.com/charmbracelet/crush/internal/fur/provider"
99)
1010
1111type MessageRole string
@@ -71,9 +71,9 @@ type BinaryContent struct {
7171 Data []byte
7272}
7373
74−func (bc BinaryContent) String(provider models.InferenceProvider) string {
74+func (bc BinaryContent) String(p provider.InferenceProvider) string {
7575 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
76− if provider == models.ProviderOpenAI {
76+ if p == provider.InferenceProviderOpenAI {
7777 return "data:" + bc.MIMEType + ";base64," + base64Encoded
7878 }
7979 return base64Encoded
@@ -113,7 +113,8 @@ type Message struct {
113113 Role MessageRole
114114 SessionID string
115115 Parts []ContentPart
116− Model models.ModelID
116+ Model string
117+ Provider string
117118 CreatedAt int64
118119 UpdatedAt int64
119120}
internal/message/message.go+7−5
@@ -8,15 +8,15 @@ import (
88 "time"
99
1010 "github.com/charmbracelet/crush/internal/db"
11− "github.com/charmbracelet/crush/internal/llm/models"
1211 "github.com/charmbracelet/crush/internal/pubsub"
1312 "github.com/google/uuid"
1413)
1514
1615type CreateMessageParams struct {
17− Role MessageRole
18− Parts []ContentPart
19− Model models.ModelID
16+ Role MessageRole
17+ Parts []ContentPart
18+ Model string
19+ Provider string
2020}
2121
2222type Service interface {
@@ -70,6 +70,7 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
7070 Role: string(params.Role),
7171 Parts: string(partsJSON),
7272 Model: sql.NullString{String: string(params.Model), Valid: true},
73+ Provider: sql.NullString{String: params.Provider, Valid: params.Provider != ""},
7374 })
7475 if err != nil {
7576 return Message{}, err
@@ -154,7 +155,8 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
154155 SessionID: item.SessionID,
155156 Role: MessageRole(item.Role),
156157 Parts: parts,
157− Model: models.ModelID(item.Model.String),
158+ Model: item.Model.String,
159+ Provider: item.Provider.String,
158160 CreatedAt: item.CreatedAt,
159161 UpdatedAt: item.UpdatedAt,
160162 }, nil
internal/tui/components/chat/header/header.go+1−6
@@ -7,7 +7,6 @@ import (
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/crush/internal/config"
99 "github.com/charmbracelet/crush/internal/fsext"
10− "github.com/charmbracelet/crush/internal/llm/models"
1110 "github.com/charmbracelet/crush/internal/lsp"
1211 "github.com/charmbracelet/crush/internal/lsp/protocol"
1312 "github.com/charmbracelet/crush/internal/pubsub"
@@ -112,11 +111,7 @@ func (h *header) details() string {
112111 parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
113112 }
114113
115− cfg := config.Get()
116− agentCfg := cfg.Agents[config.AgentCoder]
117− selectedModelID := agentCfg.Model
118− model := models.SupportedModels[selectedModelID]
119−
114+ model := config.GetAgentModel(config.AgentCoder)
120115 percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
121116 formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
122117 parts = append(parts, formattedPercentage)
internal/tui/components/chat/messages/messages.go+5−3
@@ -10,7 +10,8 @@ import (
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/lipgloss/v2"
1212
13− "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/fur/provider"
1415 "github.com/charmbracelet/crush/internal/message"
1516 "github.com/charmbracelet/crush/internal/tui/components/anim"
1617 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -290,8 +291,9 @@ func (m *assistantSectionModel) View() tea.View {
290291 duration := finishTime.Sub(m.lastUserMessageTime)
291292 infoMsg := t.S().Subtle.Render(duration.String())
292293 icon := t.S().Subtle.Render(styles.ModelIcon)
293− model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
294− assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
294+ model := config.GetProviderModel(provider.InferenceProvider(m.message.Provider), m.message.Model)
295+ modelFormatted := t.S().Muted.Render(model.Name)
296+ assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
295297 return tea.NewView(
296298 t.S().Base.PaddingLeft(2).Render(
297299 core.Section(assistant, m.width-2),
internal/tui/components/chat/sidebar/sidebar.go+2−6
@@ -13,7 +13,6 @@ import (
1313 "github.com/charmbracelet/crush/internal/diff"
1414 "github.com/charmbracelet/crush/internal/fsext"
1515 "github.com/charmbracelet/crush/internal/history"
16− "github.com/charmbracelet/crush/internal/llm/models"
1716 "github.com/charmbracelet/crush/internal/logging"
1817 "github.com/charmbracelet/crush/internal/lsp"
1918 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -406,7 +405,7 @@ func (m *sidebarCmp) mcpBlock() string {
406405
407406 mcpList := []string{section, ""}
408407
409− mcp := config.Get().MCPServers
408+ mcp := config.Get().MCP
410409 if len(mcp) == 0 {
411410 return lipgloss.JoinVertical(
412411 lipgloss.Left,
@@ -475,10 +474,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
475474}
476475
477476func (s *sidebarCmp) currentModelBlock() string {
478− cfg := config.Get()
479− agentCfg := cfg.Agents[config.AgentCoder]
480− selectedModelID := agentCfg.Model
481− model := models.SupportedModels[selectedModelID]
477+ model := config.GetAgentModel(config.AgentCoder)
482478
483479 t := styles.CurrentTheme()
484480
internal/tui/components/dialogs/commands/loader.go+1−1
@@ -63,7 +63,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
6363
6464 // Project directory
6565 sources = append(sources, commandSource{
66− path: filepath.Join(cfg.Data.Directory, "commands"),
66+ path: filepath.Join(cfg.Options.DataDirectory, "commands"),
6767 prefix: ProjectCommandPrefix,
6868 })
6969
internal/tui/components/dialogs/init/init.go+3−3
@@ -5,7 +5,7 @@ import (
55 tea "github.com/charmbracelet/bubbletea/v2"
66 "github.com/charmbracelet/lipgloss/v2"
77
8− "github.com/charmbracelet/crush/internal/config"
8+ configv2 "github.com/charmbracelet/crush/internal/config"
99 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
1010 "github.com/charmbracelet/crush/internal/tui/components/core"
1111 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -184,7 +184,7 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
184184Add the .crush directory to the .gitignore file if it's not already there.`
185185
186186 // Mark the project as initialized
187− if err := config.MarkProjectInitialized(); err != nil {
187+ if err := configv2.MarkProjectInitialized(); err != nil {
188188 return util.ReportError(err)
189189 }
190190
@@ -196,7 +196,7 @@ Add the .crush directory to the .gitignore file if it's not already there.`
196196 )
197197 } else {
198198 // Mark the project as initialized without running the command
199− if err := config.MarkProjectInitialized(); err != nil {
199+ if err := configv2.MarkProjectInitialized(); err != nil {
200200 return util.ReportError(err)
201201 }
202202 }
internal/tui/components/dialogs/models/models.go+31−93
@@ -1,13 +1,11 @@
11package models
22
33import (
4− "slices"
5−
64 "github.com/charmbracelet/bubbles/v2/help"
75 "github.com/charmbracelet/bubbles/v2/key"
86 tea "github.com/charmbracelet/bubbletea/v2"
9− "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/llm/models"
7+ configv2 "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/fur/provider"
119 "github.com/charmbracelet/crush/internal/tui/components/completions"
1210 "github.com/charmbracelet/crush/internal/tui/components/core"
1311 "github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -26,7 +24,7 @@ const (
2624
2725// ModelSelectedMsg is sent when a model is selected
2826type ModelSelectedMsg struct {
29− Model models.Model
27+ Model configv2.PreferredModel
3028}
3129
3230// CloseModelDialogMsg is sent when a model is selected
@@ -37,6 +35,11 @@ type ModelDialog interface {
3735 dialogs.DialogModel
3836}
3937
38+type ModelOption struct {
39+ Provider provider.Provider
40+ Model provider.Model
41+}
42+
4043type modelDialogCmp struct {
4144 width int
4245 wWidth int // Width of the terminal window
@@ -80,47 +83,31 @@ func NewModelDialogCmp() ModelDialog {
8083 }
8184}
8285
83−var ProviderPopularity = map[models.InferenceProvider]int{
84− models.ProviderAnthropic: 1,
85− models.ProviderOpenAI: 2,
86− models.ProviderGemini: 3,
87− models.ProviderGROQ: 4,
88− models.ProviderOpenRouter: 5,
89− models.ProviderBedrock: 6,
90− models.ProviderAzure: 7,
91− models.ProviderVertexAI: 8,
92− models.ProviderXAI: 9,
93−}
94−
95−var ProviderName = map[models.InferenceProvider]string{
96− models.ProviderAnthropic: "Anthropic",
97− models.ProviderOpenAI: "OpenAI",
98− models.ProviderGemini: "Gemini",
99− models.ProviderGROQ: "Groq",
100− models.ProviderOpenRouter: "OpenRouter",
101− models.ProviderBedrock: "AWS Bedrock",
102− models.ProviderAzure: "Azure",
103− models.ProviderVertexAI: "VertexAI",
104− models.ProviderXAI: "xAI",
105−}
106−
10786func (m *modelDialogCmp) Init() tea.Cmd {
108− cfg := config.Get()
109− enabledProviders := getEnabledProviders(cfg)
87+ providers := configv2.Providers()
88+ cfg := configv2.Get()
11089
90+ coderAgent := cfg.Agents[configv2.AgentCoder]
11191 modelItems := []util.Model{}
112− for _, provider := range enabledProviders {
113− name, ok := ProviderName[provider]
114− if !ok {
115− name = string(provider) // Fallback to provider ID if name is not defined
92+ selectIndex := 0
93+ for _, provider := range providers {
94+ name := provider.Name
95+ if name == "" {
96+ name = string(provider.ID)
11697 }
11798 modelItems = append(modelItems, commands.NewItemSection(name))
118− for _, model := range getModelsForProvider(provider) {
119− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model))
99+ for _, model := range provider.Models {
100+ if model.ID == coderAgent.Model && provider.ID == coderAgent.Provider {
101+ selectIndex = len(modelItems) // Set the selected index to the current model
102+ }
103+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
104+ Provider: provider,
105+ Model: model,
106+ }))
120107 }
121108 }
122− m.modelList.SetItems(modelItems)
123− return m.modelList.Init()
109+
110+ return tea.Sequence(m.modelList.Init(), m.modelList.SetItems(modelItems), m.modelList.SetSelected(selectIndex))
124111}
125112
126113func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -137,11 +124,14 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137124 return m, nil // No item selected, do nothing
138125 }
139126 items := m.modelList.Items()
140− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model)
127+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
141128
142129 return m, tea.Sequence(
143130 util.CmdHandler(dialogs.CloseDialogMsg{}),
144− util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
131+ util.CmdHandler(ModelSelectedMsg{Model: configv2.PreferredModel{
132+ ModelID: selectedItem.Model.ID,
133+ Provider: selectedItem.Provider.ID,
134+ }}),
145135 )
146136 case key.Matches(msg, m.keyMap.Close):
147137 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
@@ -189,58 +179,6 @@ func (m *modelDialogCmp) listHeight() int {
189179 return min(listHeigh, m.wHeight/2)
190180}
191181
192−func GetSelectedModel(cfg *config.Config) models.Model {
193− agentCfg := cfg.Agents[config.AgentCoder]
194− selectedModelID := agentCfg.Model
195− return models.SupportedModels[selectedModelID]
196−}
197−
198−func getEnabledProviders(cfg *config.Config) []models.InferenceProvider {
199− var providers []models.InferenceProvider
200− for providerID, provider := range cfg.Providers {
201− if !provider.Disabled {
202− providers = append(providers, providerID)
203− }
204− }
205−
206− // Sort by provider popularity
207− slices.SortFunc(providers, func(a, b models.InferenceProvider) int {
208− rA := ProviderPopularity[a]
209− rB := ProviderPopularity[b]
210−
211− // models not included in popularity ranking default to last
212− if rA == 0 {
213− rA = 999
214− }
215− if rB == 0 {
216− rB = 999
217− }
218− return rA - rB
219− })
220− return providers
221−}
222−
223−func getModelsForProvider(provider models.InferenceProvider) []models.Model {
224− var providerModels []models.Model
225− for _, model := range models.SupportedModels {
226− if model.Provider == provider {
227− providerModels = append(providerModels, model)
228− }
229− }
230−
231− // reverse alphabetical order (if llm naming was consistent latest would appear first)
232− slices.SortFunc(providerModels, func(a, b models.Model) int {
233− if a.Name > b.Name {
234− return -1
235− } else if a.Name < b.Name {
236− return 1
237− }
238− return 0
239− })
240−
241− return providerModels
242−}
243−
244182func (m *modelDialogCmp) Position() (int, int) {
245183 row := m.wHeight/4 - 2 // just a bit above the center
246184 col := m.wWidth / 2
internal/tui/page/chat/chat.go+3−7
@@ -9,7 +9,6 @@ import (
99 tea "github.com/charmbracelet/bubbletea/v2"
1010 "github.com/charmbracelet/crush/internal/app"
1111 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
1312 "github.com/charmbracelet/crush/internal/message"
1413 "github.com/charmbracelet/crush/internal/session"
1514 "github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -171,14 +170,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171170 util.CmdHandler(ChatFocusedMsg{Focused: false}),
172171 )
173172 case key.Matches(msg, p.keyMap.AddAttachment):
174− cfg := config.Get()
175− agentCfg := cfg.Agents[config.AgentCoder]
176− selectedModelID := agentCfg.Model
177− model := models.SupportedModels[selectedModelID]
178− if model.SupportsAttachments {
173+ model := config.GetAgentModel(config.AgentCoder)
174+ if model.SupportsImages {
179175 return p, util.CmdHandler(OpenFilePickerMsg{})
180176 } else {
181− return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
177+ return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
182178 }
183179 case key.Matches(msg, p.keyMap.Tab):
184180 if p.session.ID == "" {
internal/tui/tui.go+4−3
@@ -8,6 +8,7 @@ import (
88 tea "github.com/charmbracelet/bubbletea/v2"
99 "github.com/charmbracelet/crush/internal/app"
1010 "github.com/charmbracelet/crush/internal/config"
11+ configv2 "github.com/charmbracelet/crush/internal/config"
1112 "github.com/charmbracelet/crush/internal/llm/agent"
1213 "github.com/charmbracelet/crush/internal/logging"
1314 "github.com/charmbracelet/crush/internal/permission"
@@ -69,7 +70,7 @@ func (a appModel) Init() tea.Cmd {
6970
7071 // Check if we should show the init dialog
7172 cmds = append(cmds, func() tea.Msg {
72− shouldShow, err := config.ShouldShowInitDialog()
73+ shouldShow, err := configv2.ProjectNeedsInitialization()
7374 if err != nil {
7475 return util.InfoMsg{
7576 Type: util.InfoTypeError,
@@ -172,7 +173,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172173
173174 // Model Switch
174175 case models.ModelSelectedMsg:
175− model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
176+ model, err := a.app.CoderAgent.Update(msg.Model)
176177 if err != nil {
177178 return a, util.ReportError(err)
178179 }
@@ -222,7 +223,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
222223 model := a.app.CoderAgent.Model()
223224 contextWindow := model.ContextWindow
224225 tokens := session.CompletionTokens + session.PromptTokens
225− if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
226+ if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize {
226227 // Show compact confirmation dialog
227228 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
228229 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),