on main help sign in
Merge pull request #83 from charmbracelet/config-provider-changes

Config/Agent/Provider Overhaul

commit6f2387c6bae53d11a216efdfbcd4c8cdc5b1b221 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 1 Jul 2025, 12:46 (1 year ago) parent1fa4e8a142ff9e
85 files changed+6941−3460
CRUSH.md+23 Taskfile.yaml+8−2 cmd/root.go+2−1 cmd/schema/README.md+145−27 cmd/schema/main.go+112−267 crush-schema.json+657−335 .crush.json → crush.json+1−4 cspell.json+1−39 go.mod+6−9 go.sum+11−20 internal/app/app.go+15−8 internal/config/config.go+1244−660 internal/config/config_test.go+2079 internal/config/fs.go+71 internal/config/init.go+6−13 internal/config/provider.go+83 internal/config/provider_mock.go+293 internal/config/provider_test.go+81 internal/config/shell.go+73 internal/config/validation_test.go+462 internal/db/connect.go+1−1 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/fsext/fileutil.go+1−1 internal/fur/client/client.go+63 internal/fur/provider/provider.go+73 internal/llm/agent/agent-tool.go+14−17 internal/llm/agent/agent.go+267−111 internal/llm/agent/mcp-tools.go+13−5 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−60 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+247−35 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+53−68 internal/llm/provider/bedrock.go+21−29 internal/llm/provider/gemini.go+54−46 internal/llm/provider/openai.go+58−68 internal/llm/provider/provider.go+74−115 internal/llm/provider/vertexai.go+4−9 internal/llm/tools/bash.go+4 internal/llm/tools/diagnostics.go+4 internal/llm/tools/edit.go+4 internal/llm/tools/fetch.go+4 internal/llm/tools/glob.go+4 internal/llm/tools/grep.go+4 internal/llm/tools/ls.go+4 internal/llm/tools/sourcegraph.go+4 internal/llm/tools/tools.go+1 internal/llm/tools/view.go+4 internal/llm/tools/write.go+4 internal/logging/logger.go+133−2 internal/logging/writer.go+1 internal/lsp/client.go+16−16 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+7−11 internal/tui/components/chat/splash/keys.go+18 internal/tui/components/chat/splash/splash.go+85 internal/tui/components/dialogs/commands/loader.go+1−1 internal/tui/components/dialogs/models/keys.go+8−2 internal/tui/components/dialogs/models/models.go+157−99 internal/tui/components/logo/logo.go+2−1 internal/tui/components/logs/details.go−1 internal/tui/page/chat/chat.go+3−7 internal/tui/tui.go+29−7
CRUSH.md+23
@@ -24,6 +24,29 @@
2424- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
2525- **Comments**: End comments in periods unless comments are at the end of the line.
2626
27+## Testing with Mock Providers
28+
29+When writing tests that involve provider configurations, use the mock providers to avoid API calls:
30+
31+```go
32+func TestYourFunction(t *testing.T) {
33+ // Enable mock providers for testing
34+ originalUseMock := config.UseMockProviders
35+ config.UseMockProviders = true
36+ defer func() {
37+ config.UseMockProviders = originalUseMock
38+ config.ResetProviders()
39+ }()
40+
41+ // Reset providers to ensure fresh mock data
42+ config.ResetProviders()
43+
44+ // Your test code here - providers will now return mock data
45+ providers := config.Providers()
46+ // ... test logic
47+}
48+```
49+
2750## Formatting
2851
2952- ALWAYS format any Go code you write.
Taskfile.yaml+8−2
@@ -6,12 +6,12 @@ tasks:
66 lint:
77 desc: Run base linters
88 cmds:
9− - golangci-lint run
9+ - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
1010
1111 lint-fix:
1212 desc: Run base linters and fix issues
1313 cmds:
14− - golangci-lint run --fix
14+ - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m --fix
1515
1616 test:
1717 desc: Run tests
@@ -44,3 +44,9 @@ tasks:
4444 desc: Allocations profile
4545 cmds:
4646 - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/allocs'
47+
48+ schema:
49+ desc: Generate JSON schema for configuration
50+ cmds:
51+ - go run cmd/schema/main.go > crush-schema.json
52+ - echo "Generated crush-schema.json"
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/README.md+145−27
@@ -1,64 +1,182 @@
11# Crush Configuration Schema Generator
22
3−This tool generates a JSON Schema for the Crush configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema.
3+This tool automatically generates a JSON Schema for the Crush configuration file by using Go reflection to analyze the configuration structs. The schema provides validation, autocompletion, and documentation for configuration files.
4+
5+## Features
6+
7+- **Automated Generation**: Uses reflection to automatically generate schemas from Go structs
8+- **Always Up-to-Date**: Schema stays in sync with code changes automatically
9+- **Comprehensive**: Includes all configuration options, types, and validation rules
10+- **Enhanced**: Adds provider enums, model lists, and custom descriptions
11+- **Extensible**: Easy to add new fields and modify existing ones
412
513## Usage
614
715```bash
16+# Generate the schema
817go run cmd/schema/main.go > crush-schema.json
18+
19+# Or use the task runner
20+task schema
921```
1022
11−This will generate a JSON Schema file that can be used to validate configuration files.
23+## How It Works
24+
25+The generator:
26+
27+1. **Reflects on Config Structs**: Analyzes the `config.Config` struct and all related types
28+2. **Generates Base Schema**: Creates JSON Schema definitions for all struct fields
29+3. **Enhances with Runtime Data**: Adds provider lists, model enums, and tool lists from the actual codebase
30+4. **Adds Custom Descriptions**: Provides meaningful descriptions for configuration options
31+5. **Sets Default Values**: Includes appropriate defaults for optional fields
1232
1333## Schema Features
1434
1535The generated schema includes:
1636
17−- All configuration options with descriptions
18−- Default values where applicable
19−- Validation for enum values (e.g., model IDs, provider types)
20−- Required fields
21−- Type checking
37+- **Type Safety**: Proper type definitions for all configuration fields
38+- **Validation**: Required fields, enum constraints, and format validation
39+- **Documentation**: Descriptions for all configuration options
40+- **Defaults**: Default values for optional settings
41+- **Provider Enums**: Current list of supported providers
42+- **Model Enums**: Available models from all configured providers
43+- **Tool Lists**: Valid tool names for agent configurations
44+- **Cross-References**: Proper relationships between different config sections
45+
46+## Adding New Configuration Fields
47+
48+To add new configuration options:
49+
50+1. **Add to Config Structs**: Add the field to the appropriate struct in `internal/config/`
51+2. **Add JSON Tags**: Include proper JSON tags with field names
52+3. **Regenerate Schema**: Run the schema generator to update the JSON schema
53+4. **Update Validation**: Add any custom validation logic if needed
54+
55+Example:
56+```go
57+type Options struct {
58+ // ... existing fields ...
59+
60+ // New field with JSON tag and description
61+ NewFeature bool `json:"new_feature,omitempty"`
62+}
63+```
64+
65+The schema generator will automatically:
66+- Detect the new field
67+- Generate appropriate JSON schema
68+- Add type information
69+- Include in validation
2270
2371## Using the Schema
2472
25−You can use the generated schema in several ways:
73+### Editor Integration
74+
75+Most modern editors support JSON Schema:
76+
77+**VS Code**: Add to your workspace settings:
78+```json
79+{
80+ "json.schemas": [
81+ {
82+ "fileMatch": ["crush.json", ".crush.json"],
83+ "url": "./crush-schema.json"
84+ }
85+ ]
86+}
87+```
2688
27−1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.crush.json` files.
89+**JetBrains IDEs**: Configure in Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings
2890
29−2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
91+### Validation Tools
3092
31−3. **Documentation**: The schema serves as documentation for the configuration options.
93+```bash
94+# Using jsonschema (Python)
95+pip install jsonschema
96+jsonschema -i crush.json crush-schema.json
3297
33−## Example Configuration
98+# Using ajv-cli (Node.js)
99+npm install -g ajv-cli
100+ajv validate -s crush-schema.json -d crush.json
101+```
34102
35−Here's an example configuration that conforms to the schema:
103+### Configuration Example
36104
37105```json
38106{
39− "data": {
40− "directory": ".crush"
107+ "models": {
108+ "large": {
109+ "model_id": "claude-3-5-sonnet-20241022",
110+ "provider": "anthropic",
111+ "reasoning_effort": "medium",
112+ "max_tokens": 8192
113+ },
114+ "small": {
115+ "model_id": "claude-3-5-haiku-20241022",
116+ "provider": "anthropic"
117+ }
41118 },
42− "debug": false,
43119 "providers": {
44120 "anthropic": {
45− "apiKey": "your-api-key"
121+ "id": "anthropic",
122+ "provider_type": "anthropic",
123+ "api_key": "your-api-key",
124+ "disabled": false
46125 }
47126 },
48127 "agents": {
49128 "coder": {
50− "model": "claude-3.7-sonnet",
51− "maxTokens": 5000,
52− "reasoningEffort": "medium"
53− },
54− "task": {
55− "model": "claude-3.7-sonnet",
56− "maxTokens": 5000
129+ "id": "coder",
130+ "name": "Coder",
131+ "model": "large",
132+ "disabled": false
57133 },
58− "title": {
59− "model": "claude-3.7-sonnet",
60− "maxTokens": 80
134+ "custom-agent": {
135+ "id": "custom-agent",
136+ "name": "Custom Agent",
137+ "description": "A custom agent for specific tasks",
138+ "model": "small",
139+ "allowed_tools": ["glob", "grep", "view"],
140+ "allowed_mcp": {
141+ "filesystem": ["read", "write"]
142+ }
143+ }
144+ },
145+ "mcp": {
146+ "filesystem": {
147+ "command": "mcp-filesystem",
148+ "args": ["--root", "/workspace"],
149+ "type": "stdio"
150+ }
151+ },
152+ "lsp": {
153+ "typescript": {
154+ "command": "typescript-language-server",
155+ "args": ["--stdio"],
156+ "enabled": true
157+ }
158+ },
159+ "options": {
160+ "context_paths": [
161+ "README.md",
162+ "docs/",
163+ ".cursorrules"
164+ ],
165+ "data_directory": ".crush",
166+ "debug": false,
167+ "tui": {
168+ "compact_mode": false
61169 }
62170 }
63171}
64172```
173+
174+## Maintenance
175+
176+The schema generator is designed to be maintenance-free. As long as:
177+
178+- Configuration structs have proper JSON tags
179+- New enums are added to the enhancement functions
180+- The generator is run after significant config changes
181+
182+The schema will stay current with the codebase automatically.
cmd/schema/main.go+112−267
@@ -6,25 +6,29 @@ import (
66 "os"
77
88 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/llm/models"
9+ "github.com/invopop/jsonschema"
1010)
1111
12−// JSONSchemaType represents a JSON Schema type
13−type JSONSchemaType struct {
14− Type string `json:"type,omitempty"`
15− Description string `json:"description,omitempty"`
16− Properties map[string]any `json:"properties,omitempty"`
17− Required []string `json:"required,omitempty"`
18− AdditionalProperties any `json:"additionalProperties,omitempty"`
19− Enum []any `json:"enum,omitempty"`
20− Items map[string]any `json:"items,omitempty"`
21− OneOf []map[string]any `json:"oneOf,omitempty"`
22− AnyOf []map[string]any `json:"anyOf,omitempty"`
23− Default any `json:"default,omitempty"`
24−}
25−
2612func main() {
27− schema := generateSchema()
13+ // Create a new reflector
14+ r := &jsonschema.Reflector{
15+ // Use anonymous schemas to avoid ID conflicts
16+ Anonymous: true,
17+ // Expand the root struct instead of referencing it
18+ ExpandedStruct: true,
19+ AllowAdditionalProperties: true,
20+ }
21+
22+ // Generate schema for the main Config struct
23+ schema := r.Reflect(&config.Config{})
24+
25+ // Enhance the schema with additional information
26+ enhanceSchema(schema)
27+
28+ // Set the schema metadata
29+ schema.Version = "https://json-schema.org/draft/2020-12/schema"
30+ schema.Title = "Crush Configuration"
31+ schema.Description = "Configuration schema for the Crush application"
2832
2933 // Pretty print the schema
3034 encoder := json.NewEncoder(os.Stdout)
@@ -35,276 +39,117 @@ func main() {
3539 }
3640}
3741
38−func generateSchema() map[string]any {
39− schema := map[string]any{
40− "$schema": "http://json-schema.org/draft-07/schema#",
41− "title": "Crush Configuration",
42− "description": "Configuration schema for the Crush application",
43− "type": "object",
44− "properties": map[string]any{},
45− }
42+// enhanceSchema adds additional enhancements to the generated schema
43+func enhanceSchema(schema *jsonschema.Schema) {
44+ // Add provider enums
45+ addProviderEnums(schema)
4646
47− // Add Data configuration
48− schema["properties"].(map[string]any)["data"] = map[string]any{
49− "type": "object",
50− "description": "Storage configuration",
51− "properties": map[string]any{
52− "directory": map[string]any{
53− "type": "string",
54− "description": "Directory where application data is stored",
55− "default": ".crush",
56− },
57− },
58− "required": []string{"directory"},
59− }
47+ // Add model enums
48+ addModelEnums(schema)
6049
61− // Add working directory
62− schema["properties"].(map[string]any)["wd"] = map[string]any{
63− "type": "string",
64− "description": "Working directory for the application",
65− }
66−
67− // Add debug flags
68− schema["properties"].(map[string]any)["debug"] = map[string]any{
69− "type": "boolean",
70− "description": "Enable debug mode",
71− "default": false,
72− }
73−
74− schema["properties"].(map[string]any)["debugLSP"] = map[string]any{
75− "type": "boolean",
76− "description": "Enable LSP debug mode",
77− "default": false,
78− }
79−
80− schema["properties"].(map[string]any)["contextPaths"] = map[string]any{
81− "type": "array",
82− "description": "Context paths for the application",
83− "items": map[string]any{
84− "type": "string",
85− },
86− "default": []string{
87− ".github/copilot-instructions.md",
88− ".cursorrules",
89− ".cursor/rules/",
90− "CLAUDE.md",
91− "CLAUDE.local.md",
92− "GEMINI.md",
93− "gemini.md",
94− "crush.md",
95− "crush.local.md",
96− "Crush.md",
97− "Crush.local.md",
98− "CRUSH.md",
99− "CRUSH.local.md",
100− },
101− }
102−
103− schema["properties"].(map[string]any)["tui"] = map[string]any{
104− "type": "object",
105− "description": "Terminal User Interface configuration",
106− "properties": map[string]any{
107− "theme": map[string]any{
108− "type": "string",
109− "description": "TUI theme name",
110− "default": "crush",
111− "enum": []string{
112− "crush",
113− "catppuccin",
114− "dracula",
115− "flexoki",
116− "gruvbox",
117− "monokai",
118− "onedark",
119− "tokyonight",
120− "tron",
121− },
122− },
123− },
124− }
50+ // Add tool enums
51+ addToolEnums(schema)
12552
126− // Add MCP servers
127− schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
128− "type": "object",
129− "description": "Model Control Protocol server configurations",
130− "additionalProperties": map[string]any{
131− "type": "object",
132− "description": "MCP server configuration",
133− "properties": map[string]any{
134− "command": map[string]any{
135− "type": "string",
136− "description": "Command to execute for the MCP server",
137− },
138− "env": map[string]any{
139− "type": "array",
140− "description": "Environment variables for the MCP server",
141− "items": map[string]any{
142− "type": "string",
143− },
144− },
145− "args": map[string]any{
146− "type": "array",
147− "description": "Command arguments for the MCP server",
148− "items": map[string]any{
149− "type": "string",
150− },
151− },
152− "type": map[string]any{
153− "type": "string",
154− "description": "Type of MCP server",
155− "enum": []string{"stdio", "sse"},
156− "default": "stdio",
157− },
158− "url": map[string]any{
159− "type": "string",
160− "description": "URL for SSE type MCP servers",
161− },
162− "headers": map[string]any{
163− "type": "object",
164− "description": "HTTP headers for SSE type MCP servers",
165− "additionalProperties": map[string]any{
166− "type": "string",
167− },
168− },
169− },
170− "required": []string{"command"},
171− },
172− }
53+ // Add default context paths
54+ addDefaultContextPaths(schema)
55+}
17356
174− // Add providers
175− providerSchema := map[string]any{
176− "type": "object",
177− "description": "LLM provider configurations",
178− "additionalProperties": map[string]any{
179− "type": "object",
180− "description": "Provider configuration",
181− "properties": map[string]any{
182− "apiKey": map[string]any{
183− "type": "string",
184− "description": "API key for the provider",
185− },
186− "disabled": map[string]any{
187− "type": "boolean",
188− "description": "Whether the provider is disabled",
189− "default": false,
190− },
191− },
192− },
57+// addProviderEnums adds provider enums to the schema
58+func addProviderEnums(schema *jsonschema.Schema) {
59+ providers := config.Providers()
60+ var providerIDs []any
61+ for _, p := range providers {
62+ providerIDs = append(providerIDs, string(p.ID))
19363 }
19464
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− }
65+ // Add to PreferredModel provider field
66+ if schema.Definitions != nil {
67+ if preferredModelDef, exists := schema.Definitions["PreferredModel"]; exists {
68+ if providerProp, exists := preferredModelDef.Properties.Get("provider"); exists {
69+ providerProp.Enum = providerIDs
70+ }
71+ }
20672
207− providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
208− "type": "string",
209− "description": "Provider type",
210− "enum": knownProviders,
73+ // Add to ProviderConfig ID field
74+ if providerConfigDef, exists := schema.Definitions["ProviderConfig"]; exists {
75+ if idProp, exists := providerConfigDef.Properties.Get("id"); exists {
76+ idProp.Enum = providerIDs
77+ }
78+ }
21179 }
80+}
21281
213− schema["properties"].(map[string]any)["providers"] = providerSchema
214−
215− // Add agents
216− agentSchema := map[string]any{
217− "type": "object",
218− "description": "Agent configurations",
219− "additionalProperties": map[string]any{
220− "type": "object",
221− "description": "Agent configuration",
222− "properties": map[string]any{
223− "model": map[string]any{
224− "type": "string",
225− "description": "Model ID for the agent",
226− },
227− "maxTokens": map[string]any{
228− "type": "integer",
229− "description": "Maximum tokens for the agent",
230− "minimum": 1,
231− },
232− "reasoningEffort": map[string]any{
233− "type": "string",
234− "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
235− "enum": []string{"low", "medium", "high"},
236− },
237− },
238− "required": []string{"model"},
239− },
82+// addModelEnums adds model enums to the schema
83+func addModelEnums(schema *jsonschema.Schema) {
84+ providers := config.Providers()
85+ var modelIDs []any
86+ for _, p := range providers {
87+ for _, m := range p.Models {
88+ modelIDs = append(modelIDs, m.ID)
89+ }
24090 }
24191
242− // Add model enum
243− modelEnum := []string{}
244− for modelID := range models.SupportedModels {
245− modelEnum = append(modelEnum, string(modelID))
92+ // Add to PreferredModel model_id field
93+ if schema.Definitions != nil {
94+ if preferredModelDef, exists := schema.Definitions["PreferredModel"]; exists {
95+ if modelIDProp, exists := preferredModelDef.Properties.Get("model_id"); exists {
96+ modelIDProp.Enum = modelIDs
97+ }
98+ }
24699 }
247− agentSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["model"].(map[string]any)["enum"] = modelEnum
100+}
248101
249− // Add specific agent properties
250− agentProperties := map[string]any{}
251− knownAgents := []string{
252− string(config.AgentCoder),
253− string(config.AgentTask),
254− string(config.AgentTitle),
102+// addToolEnums adds tool enums to the schema
103+func addToolEnums(schema *jsonschema.Schema) {
104+ tools := []any{
105+ "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
255106 }
256107
257− for _, agentName := range knownAgents {
258− agentProperties[agentName] = map[string]any{
259− "$ref": "#/definitions/agent",
108+ if schema.Definitions != nil {
109+ if agentDef, exists := schema.Definitions["Agent"]; exists {
110+ if allowedToolsProp, exists := agentDef.Properties.Get("allowed_tools"); exists {
111+ if allowedToolsProp.Items != nil {
112+ allowedToolsProp.Items.Enum = tools
113+ }
114+ }
260115 }
261116 }
117+}
262118
263− // Create a combined schema that allows both specific agents and additional ones
264− combinedAgentSchema := map[string]any{
265− "type": "object",
266− "description": "Agent configurations",
267− "properties": agentProperties,
268− "additionalProperties": agentSchema["additionalProperties"],
269− }
270−
271− schema["properties"].(map[string]any)["agents"] = combinedAgentSchema
272− schema["definitions"] = map[string]any{
273− "agent": agentSchema["additionalProperties"],
119+// addDefaultContextPaths adds default context paths to the schema
120+func addDefaultContextPaths(schema *jsonschema.Schema) {
121+ defaultContextPaths := []any{
122+ ".github/copilot-instructions.md",
123+ ".cursorrules",
124+ ".cursor/rules/",
125+ "CLAUDE.md",
126+ "CLAUDE.local.md",
127+ "GEMINI.md",
128+ "gemini.md",
129+ "crush.md",
130+ "crush.local.md",
131+ "Crush.md",
132+ "Crush.local.md",
133+ "CRUSH.md",
134+ "CRUSH.local.md",
135+ }
136+
137+ if schema.Definitions != nil {
138+ if optionsDef, exists := schema.Definitions["Options"]; exists {
139+ if contextPathsProp, exists := optionsDef.Properties.Get("context_paths"); exists {
140+ contextPathsProp.Default = defaultContextPaths
141+ }
142+ }
274143 }
275144
276− // Add LSP configuration
277− schema["properties"].(map[string]any)["lsp"] = map[string]any{
278− "type": "object",
279− "description": "Language Server Protocol configurations",
280− "additionalProperties": map[string]any{
281− "type": "object",
282− "description": "LSP configuration for a language",
283− "properties": map[string]any{
284− "disabled": map[string]any{
285− "type": "boolean",
286− "description": "Whether the LSP is disabled",
287− "default": false,
288− },
289− "command": map[string]any{
290− "type": "string",
291− "description": "Command to execute for the LSP server",
292− },
293− "args": map[string]any{
294− "type": "array",
295− "description": "Command arguments for the LSP server",
296− "items": map[string]any{
297− "type": "string",
298− },
299− },
300− "options": map[string]any{
301− "type": "object",
302− "description": "Additional options for the LSP server",
303− },
304− },
305− "required": []string{"command"},
306− },
145+ // Also add to root properties if they exist
146+ if schema.Properties != nil {
147+ if optionsProp, exists := schema.Properties.Get("options"); exists {
148+ if optionsProp.Properties != nil {
149+ if contextPathsProp, exists := optionsProp.Properties.Get("context_paths"); exists {
150+ contextPathsProp.Default = defaultContextPaths
151+ }
152+ }
153+ }
307154 }
308−
309− return schema
310155}
crush-schema.json+657−335
@@ -1,383 +1,705 @@
11{
2− "$schema": "http://json-schema.org/draft-07/schema#",
3− "definitions": {
4− "agent": {
5− "description": "Agent configuration",
2+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3+ "$defs": {
4+ "Agent": {
65 "properties": {
7− "maxTokens": {
8− "description": "Maximum tokens for the agent",
9− "minimum": 1,
10− "type": "integer"
6+ "id": {
7+ "type": "string",
8+ "enum": [
9+ "coder",
10+ "task",
11+ "coder",
12+ "task"
13+ ],
14+ "title": "Agent ID",
15+ "description": "Unique identifier for the agent"
16+ },
17+ "name": {
18+ "type": "string",
19+ "title": "Name",
20+ "description": "Display name of the agent"
21+ },
22+ "description": {
23+ "type": "string",
24+ "title": "Description",
25+ "description": "Description of what the agent does"
26+ },
27+ "disabled": {
28+ "type": "boolean",
29+ "title": "Disabled",
30+ "description": "Whether this agent is disabled",
31+ "default": false
1132 },
1233 "model": {
13− "description": "Model ID for the agent",
34+ "type": "string",
1435 "enum": [
15− "gpt-4.1",
16− "llama-3.3-70b-versatile",
17− "azure.gpt-4.1",
18− "openrouter.gpt-4o",
19− "openrouter.o1-mini",
20− "openrouter.claude-3-haiku",
21− "claude-3-opus",
22− "gpt-4o",
23− "gpt-4o-mini",
24− "o1",
25− "meta-llama/llama-4-maverick-17b-128e-instruct",
26− "azure.o3-mini",
27− "openrouter.gpt-4o-mini",
28− "openrouter.o1",
29− "claude-3.5-haiku",
30− "o4-mini",
31− "azure.gpt-4.1-mini",
32− "openrouter.o3",
33− "grok-3-beta",
34− "o3-mini",
35− "qwen-qwq",
36− "azure.o1",
37− "openrouter.gemini-2.5-flash",
38− "openrouter.gemini-2.5",
39− "o1-mini",
40− "azure.gpt-4o",
41− "openrouter.gpt-4.1-mini",
42− "openrouter.claude-3.5-sonnet",
43− "openrouter.o3-mini",
44− "gpt-4.1-mini",
45− "gpt-4.5-preview",
46− "gpt-4.1-nano",
47− "deepseek-r1-distill-llama-70b",
48− "azure.gpt-4o-mini",
49− "openrouter.gpt-4.1",
50− "bedrock.claude-3.7-sonnet",
51− "claude-3-haiku",
52− "o3",
53− "gemini-2.0-flash-lite",
54− "azure.o3",
55− "azure.gpt-4.5-preview",
56− "openrouter.claude-3-opus",
57− "grok-3-mini-fast-beta",
58− "claude-4-sonnet",
59− "azure.o4-mini",
60− "grok-3-fast-beta",
61− "claude-3.5-sonnet",
62− "azure.o1-mini",
63− "openrouter.claude-3.7-sonnet",
64− "openrouter.gpt-4.5-preview",
65− "grok-3-mini-beta",
66− "claude-3.7-sonnet",
67− "gemini-2.0-flash",
68− "openrouter.deepseek-r1-free",
69− "vertexai.gemini-2.5-flash",
70− "vertexai.gemini-2.5",
71− "o1-pro",
72− "gemini-2.5",
73− "meta-llama/llama-4-scout-17b-16e-instruct",
74− "azure.gpt-4.1-nano",
75− "openrouter.gpt-4.1-nano",
76− "gemini-2.5-flash",
77− "openrouter.o4-mini",
78− "openrouter.claude-3.5-haiku",
79− "claude-4-opus",
80− "openrouter.o1-pro"
36+ "large",
37+ "small",
38+ "large",
39+ "small"
8140 ],
82− "type": "string"
41+ "title": "Model Type",
42+ "description": "Type of model to use (large or small)"
8343 },
84− "reasoningEffort": {
85− "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
86− "enum": ["low", "medium", "high"],
87− "type": "string"
88− }
89− },
90− "required": ["model"],
91− "type": "object"
92− }
93− },
94− "description": "Configuration schema for the Crush application",
95− "properties": {
96− "agents": {
97− "additionalProperties": {
98− "description": "Agent configuration",
99− "properties": {
100− "maxTokens": {
101− "description": "Maximum tokens for the agent",
102− "minimum": 1,
103− "type": "integer"
104− },
105− "model": {
106− "description": "Model ID for the agent",
44+ "allowed_tools": {
45+ "items": {
46+ "type": "string",
10747 "enum": [
108− "gpt-4.1",
109− "llama-3.3-70b-versatile",
110− "azure.gpt-4.1",
111− "openrouter.gpt-4o",
112− "openrouter.o1-mini",
113− "openrouter.claude-3-haiku",
114− "claude-3-opus",
115− "gpt-4o",
116− "gpt-4o-mini",
117− "o1",
118− "meta-llama/llama-4-maverick-17b-128e-instruct",
119− "azure.o3-mini",
120− "openrouter.gpt-4o-mini",
121− "openrouter.o1",
122− "claude-3.5-haiku",
123− "o4-mini",
124− "azure.gpt-4.1-mini",
125− "openrouter.o3",
126− "grok-3-beta",
127− "o3-mini",
128− "qwen-qwq",
129− "azure.o1",
130− "openrouter.gemini-2.5-flash",
131− "openrouter.gemini-2.5",
132− "o1-mini",
133− "azure.gpt-4o",
134− "openrouter.gpt-4.1-mini",
135− "openrouter.claude-3.5-sonnet",
136− "openrouter.o3-mini",
137− "gpt-4.1-mini",
138− "gpt-4.5-preview",
139− "gpt-4.1-nano",
140− "deepseek-r1-distill-llama-70b",
141− "azure.gpt-4o-mini",
142− "openrouter.gpt-4.1",
143− "bedrock.claude-3.7-sonnet",
144− "claude-3-haiku",
145− "o3",
146− "gemini-2.0-flash-lite",
147− "azure.o3",
148− "azure.gpt-4.5-preview",
149− "openrouter.claude-3-opus",
150− "grok-3-mini-fast-beta",
151− "claude-4-sonnet",
152− "azure.o4-mini",
153− "grok-3-fast-beta",
154− "claude-3.5-sonnet",
155− "azure.o1-mini",
156− "openrouter.claude-3.7-sonnet",
157− "openrouter.gpt-4.5-preview",
158− "grok-3-mini-beta",
159− "claude-3.7-sonnet",
160− "gemini-2.0-flash",
161− "openrouter.deepseek-r1-free",
162− "vertexai.gemini-2.5-flash",
163− "vertexai.gemini-2.5",
164− "o1-pro",
165− "gemini-2.5",
166− "meta-llama/llama-4-scout-17b-16e-instruct",
167− "azure.gpt-4.1-nano",
168− "openrouter.gpt-4.1-nano",
169− "gemini-2.5-flash",
170− "openrouter.o4-mini",
171− "openrouter.claude-3.5-haiku",
172− "claude-4-opus",
173− "openrouter.o1-pro"
174− ],
175− "type": "string"
48+ "bash",
49+ "edit",
50+ "fetch",
51+ "glob",
52+ "grep",
53+ "ls",
54+ "sourcegraph",
55+ "view",
56+ "write",
57+ "agent"
58+ ]
59+ },
60+ "type": "array",
61+ "title": "Allowed Tools",
62+ "description": "List of tools this agent is allowed to use (if nil all tools are allowed)"
63+ },
64+ "allowed_mcp": {
65+ "additionalProperties": {
66+ "items": {
67+ "type": "string"
68+ },
69+ "type": "array"
17670 },
177− "reasoningEffort": {
178− "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
179− "enum": ["low", "medium", "high"],
71+ "type": "object",
72+ "title": "Allowed MCP",
73+ "description": "Map of MCP servers this agent can use and their allowed tools"
74+ },
75+ "allowed_lsp": {
76+ "items": {
18077 "type": "string"
181− }
78+ },
79+ "type": "array",
80+ "title": "Allowed LSP",
81+ "description": "List of LSP servers this agent can use (if nil all LSPs are allowed)"
18282 },
183− "required": ["model"],
184− "type": "object"
83+ "context_paths": {
84+ "items": {
85+ "type": "string"
86+ },
87+ "type": "array",
88+ "title": "Context Paths",
89+ "description": "Custom context paths for this agent (additive to global context paths)"
90+ }
18591 },
186− "description": "Agent configurations",
92+ "type": "object",
93+ "required": [
94+ "model"
95+ ]
96+ },
97+ "LSPConfig": {
18798 "properties": {
188− "coder": {
189− "$ref": "#/definitions/agent"
99+ "enabled": {
100+ "type": "boolean",
101+ "title": "Enabled",
102+ "description": "Whether this LSP server is enabled",
103+ "default": true
190104 },
191− "task": {
192− "$ref": "#/definitions/agent"
105+ "command": {
106+ "type": "string",
107+ "title": "Command",
108+ "description": "Command to execute for the LSP server"
109+ },
110+ "args": {
111+ "items": {
112+ "type": "string"
113+ },
114+ "type": "array",
115+ "title": "Arguments",
116+ "description": "Command line arguments for the LSP server"
193117 },
194− "title": {
195− "$ref": "#/definitions/agent"
118+ "options": {
119+ "title": "Options",
120+ "description": "LSP server specific options"
196121 }
197122 },
198− "type": "object"
123+ "type": "object",
124+ "required": [
125+ "command"
126+ ]
199127 },
200− "contextPaths": {
201− "default": [
202− ".github/copilot-instructions.md",
203− ".cursorrules",
204− ".cursor/rules/",
205− "CLAUDE.md",
206− "CLAUDE.local.md",
207− "GEMINI.md",
208− "gemini.md",
209− "crush.md",
210− "crush.local.md",
211− "Crush.md",
212− "Crush.local.md",
213− "CRUSH.md",
214− "CRUSH.local.md"
215− ],
216− "description": "Context paths for the application",
217− "items": {
218− "type": "string"
128+ "MCP": {
129+ "properties": {
130+ "command": {
131+ "type": "string",
132+ "title": "Command",
133+ "description": "Command to execute for stdio MCP servers"
134+ },
135+ "env": {
136+ "items": {
137+ "type": "string"
138+ },
139+ "type": "array",
140+ "title": "Environment",
141+ "description": "Environment variables for the MCP server"
142+ },
143+ "args": {
144+ "items": {
145+ "type": "string"
146+ },
147+ "type": "array",
148+ "title": "Arguments",
149+ "description": "Command line arguments for the MCP server"
150+ },
151+ "type": {
152+ "type": "string",
153+ "enum": [
154+ "stdio",
155+ "sse",
156+ "stdio",
157+ "sse"
158+ ],
159+ "title": "Type",
160+ "description": "Type of MCP connection",
161+ "default": "stdio"
162+ },
163+ "url": {
164+ "type": "string",
165+ "title": "URL",
166+ "description": "URL for SSE MCP servers"
167+ },
168+ "headers": {
169+ "additionalProperties": {
170+ "type": "string"
171+ },
172+ "type": "object",
173+ "title": "Headers",
174+ "description": "HTTP headers for SSE MCP servers"
175+ }
219176 },
220− "type": "array"
177+ "type": "object",
178+ "required": [
179+ "command",
180+ "type"
181+ ]
221182 },
222− "data": {
223− "description": "Storage configuration",
183+ "Model": {
224184 "properties": {
225− "directory": {
226− "default": ".crush",
227− "description": "Directory where application data is stored",
228− "type": "string"
185+ "id": {
186+ "type": "string",
187+ "title": "Model ID",
188+ "description": "Unique identifier for the model"
189+ },
190+ "name": {
191+ "type": "string",
192+ "title": "Model Name",
193+ "description": "Display name of the model"
194+ },
195+ "cost_per_1m_in": {
196+ "type": "number",
197+ "minimum": 0,
198+ "title": "Input Cost",
199+ "description": "Cost per 1 million input tokens"
200+ },
201+ "cost_per_1m_out": {
202+ "type": "number",
203+ "minimum": 0,
204+ "title": "Output Cost",
205+ "description": "Cost per 1 million output tokens"
206+ },
207+ "cost_per_1m_in_cached": {
208+ "type": "number",
209+ "minimum": 0,
210+ "title": "Cached Input Cost",
211+ "description": "Cost per 1 million cached input tokens"
212+ },
213+ "cost_per_1m_out_cached": {
214+ "type": "number",
215+ "minimum": 0,
216+ "title": "Cached Output Cost",
217+ "description": "Cost per 1 million cached output tokens"
218+ },
219+ "context_window": {
220+ "type": "integer",
221+ "minimum": 1,
222+ "title": "Context Window",
223+ "description": "Maximum context window size in tokens"
224+ },
225+ "default_max_tokens": {
226+ "type": "integer",
227+ "minimum": 1,
228+ "title": "Default Max Tokens",
229+ "description": "Default maximum tokens for responses"
230+ },
231+ "can_reason": {
232+ "type": "boolean",
233+ "title": "Can Reason",
234+ "description": "Whether the model supports reasoning capabilities"
235+ },
236+ "reasoning_effort": {
237+ "type": "string",
238+ "title": "Reasoning Effort",
239+ "description": "Default reasoning effort level for reasoning models"
240+ },
241+ "has_reasoning_effort": {
242+ "type": "boolean",
243+ "title": "Has Reasoning Effort",
244+ "description": "Whether the model supports reasoning effort configuration"
245+ },
246+ "supports_attachments": {
247+ "type": "boolean",
248+ "title": "Supports Images",
249+ "description": "Whether the model supports image attachments"
229250 }
230251 },
231− "required": ["directory"],
232− "type": "object"
233− },
234− "debug": {
235− "default": false,
236− "description": "Enable debug mode",
237− "type": "boolean"
252+ "type": "object",
253+ "required": [
254+ "id",
255+ "name",
256+ "cost_per_1m_out_cached",
257+ "context_window",
258+ "default_max_tokens",
259+ "can_reason",
260+ "reasoning_effort",
261+ "has_reasoning_effort",
262+ "supports_attachments"
263+ ]
238264 },
239− "debugLSP": {
240− "default": false,
241− "description": "Enable LSP debug mode",
242− "type": "boolean"
243− },
244− "lsp": {
245− "additionalProperties": {
246− "description": "LSP configuration for a language",
247− "properties": {
248− "args": {
249− "description": "Command arguments for the LSP server",
250− "items": {
251− "type": "string"
252− },
253− "type": "array"
254− },
255− "command": {
256− "description": "Command to execute for the LSP server",
265+ "Options": {
266+ "properties": {
267+ "context_paths": {
268+ "items": {
257269 "type": "string"
258270 },
259− "disabled": {
260− "default": false,
261− "description": "Whether the LSP is disabled",
262− "type": "boolean"
263− },
264− "options": {
265− "description": "Additional options for the LSP server",
266− "type": "object"
267− }
271+ "type": "array",
272+ "title": "Context Paths",
273+ "description": "List of paths to search for context files",
274+ "default": [
275+ ".github/copilot-instructions.md",
276+ ".cursorrules",
277+ ".cursor/rules/",
278+ "CLAUDE.md",
279+ "CLAUDE.local.md",
280+ "GEMINI.md",
281+ "gemini.md",
282+ "crush.md",
283+ "crush.local.md",
284+ "Crush.md",
285+ "Crush.local.md",
286+ "CRUSH.md",
287+ "CRUSH.local.md"
288+ ]
289+ },
290+ "tui": {
291+ "$ref": "#/$defs/TUIOptions",
292+ "title": "TUI Options",
293+ "description": "Terminal UI configuration options"
294+ },
295+ "debug": {
296+ "type": "boolean",
297+ "title": "Debug",
298+ "description": "Enable debug logging",
299+ "default": false
300+ },
301+ "debug_lsp": {
302+ "type": "boolean",
303+ "title": "Debug LSP",
304+ "description": "Enable LSP debug logging",
305+ "default": false
306+ },
307+ "disable_auto_summarize": {
308+ "type": "boolean",
309+ "title": "Disable Auto Summarize",
310+ "description": "Disable automatic conversation summarization",
311+ "default": false
268312 },
269− "required": ["command"],
270− "type": "object"
313+ "data_directory": {
314+ "type": "string",
315+ "title": "Data Directory",
316+ "description": "Directory for storing application data",
317+ "default": ".crush"
318+ }
271319 },
272− "description": "Language Server Protocol configurations",
273320 "type": "object"
274321 },
275− "mcpServers": {
276− "additionalProperties": {
277− "description": "MCP server configuration",
278− "properties": {
279− "args": {
280− "description": "Command arguments for the MCP server",
281− "items": {
282− "type": "string"
283− },
284− "type": "array"
285− },
286− "command": {
287− "description": "Command to execute for the MCP server",
288− "type": "string"
289− },
290− "env": {
291− "description": "Environment variables for the MCP server",
292− "items": {
293− "type": "string"
294− },
295− "type": "array"
296− },
297− "headers": {
298− "additionalProperties": {
299− "type": "string"
300− },
301− "description": "HTTP headers for SSE type MCP servers",
302− "type": "object"
303− },
304− "type": {
305− "default": "stdio",
306− "description": "Type of MCP server",
307− "enum": ["stdio", "sse"],
308− "type": "string"
309− },
310− "url": {
311− "description": "URL for SSE type MCP servers",
312− "type": "string"
313− }
322+ "PreferredModel": {
323+ "properties": {
324+ "model_id": {
325+ "type": "string",
326+ "enum": [
327+ "claude-opus-4-20250514",
328+ "claude-sonnet-4-20250514",
329+ "claude-3-7-sonnet-20250219",
330+ "claude-3-5-haiku-20241022",
331+ "claude-3-5-sonnet-20240620",
332+ "claude-3-5-sonnet-20241022",
333+ "codex-mini-latest",
334+ "o4-mini",
335+ "o3",
336+ "o3-pro",
337+ "gpt-4.1",
338+ "gpt-4.1-mini",
339+ "gpt-4.1-nano",
340+ "gpt-4.5-preview",
341+ "o3-mini",
342+ "gpt-4o",
343+ "gpt-4o-mini",
344+ "gemini-2.5-pro",
345+ "gemini-2.5-flash",
346+ "codex-mini-latest",
347+ "o4-mini",
348+ "o3",
349+ "o3-pro",
350+ "gpt-4.1",
351+ "gpt-4.1-mini",
352+ "gpt-4.1-nano",
353+ "gpt-4.5-preview",
354+ "o3-mini",
355+ "gpt-4o",
356+ "gpt-4o-mini",
357+ "anthropic.claude-opus-4-20250514-v1:0",
358+ "anthropic.claude-sonnet-4-20250514-v1:0",
359+ "anthropic.claude-3-7-sonnet-20250219-v1:0",
360+ "anthropic.claude-3-5-haiku-20241022-v1:0",
361+ "gemini-2.5-pro",
362+ "gemini-2.5-flash",
363+ "grok-3-mini",
364+ "grok-3",
365+ "mistralai/mistral-small-3.2-24b-instruct:free",
366+ "mistralai/mistral-small-3.2-24b-instruct",
367+ "minimax/minimax-m1:extended",
368+ "minimax/minimax-m1",
369+ "google/gemini-2.5-flash-lite-preview-06-17",
370+ "google/gemini-2.5-flash",
371+ "google/gemini-2.5-pro",
372+ "openai/o3-pro",
373+ "x-ai/grok-3-mini",
374+ "x-ai/grok-3",
375+ "mistralai/magistral-small-2506",
376+ "mistralai/magistral-medium-2506",
377+ "mistralai/magistral-medium-2506:thinking",
378+ "google/gemini-2.5-pro-preview",
379+ "deepseek/deepseek-r1-0528",
380+ "anthropic/claude-opus-4",
381+ "anthropic/claude-sonnet-4",
382+ "mistralai/devstral-small:free",
383+ "mistralai/devstral-small",
384+ "google/gemini-2.5-flash-preview-05-20",
385+ "google/gemini-2.5-flash-preview-05-20:thinking",
386+ "openai/codex-mini",
387+ "mistralai/mistral-medium-3",
388+ "google/gemini-2.5-pro-preview-05-06",
389+ "arcee-ai/caller-large",
390+ "arcee-ai/virtuoso-large",
391+ "arcee-ai/virtuoso-medium-v2",
392+ "qwen/qwen3-30b-a3b",
393+ "qwen/qwen3-14b",
394+ "qwen/qwen3-32b",
395+ "qwen/qwen3-235b-a22b",
396+ "google/gemini-2.5-flash-preview",
397+ "google/gemini-2.5-flash-preview:thinking",
398+ "openai/o4-mini-high",
399+ "openai/o3",
400+ "openai/o4-mini",
401+ "openai/gpt-4.1",
402+ "openai/gpt-4.1-mini",
403+ "openai/gpt-4.1-nano",
404+ "x-ai/grok-3-mini-beta",
405+ "x-ai/grok-3-beta",
406+ "meta-llama/llama-4-maverick",
407+ "meta-llama/llama-4-scout",
408+ "all-hands/openhands-lm-32b-v0.1",
409+ "google/gemini-2.5-pro-exp-03-25",
410+ "deepseek/deepseek-chat-v3-0324:free",
411+ "deepseek/deepseek-chat-v3-0324",
412+ "mistralai/mistral-small-3.1-24b-instruct:free",
413+ "mistralai/mistral-small-3.1-24b-instruct",
414+ "ai21/jamba-1.6-large",
415+ "ai21/jamba-1.6-mini",
416+ "openai/gpt-4.5-preview",
417+ "google/gemini-2.0-flash-lite-001",
418+ "anthropic/claude-3.7-sonnet",
419+ "anthropic/claude-3.7-sonnet:beta",
420+ "anthropic/claude-3.7-sonnet:thinking",
421+ "mistralai/mistral-saba",
422+ "openai/o3-mini-high",
423+ "google/gemini-2.0-flash-001",
424+ "qwen/qwen-turbo",
425+ "qwen/qwen-plus",
426+ "qwen/qwen-max",
427+ "openai/o3-mini",
428+ "mistralai/mistral-small-24b-instruct-2501",
429+ "deepseek/deepseek-r1-distill-llama-70b",
430+ "deepseek/deepseek-r1",
431+ "mistralai/codestral-2501",
432+ "deepseek/deepseek-chat",
433+ "openai/o1",
434+ "x-ai/grok-2-1212",
435+ "meta-llama/llama-3.3-70b-instruct",
436+ "amazon/nova-lite-v1",
437+ "amazon/nova-micro-v1",
438+ "amazon/nova-pro-v1",
439+ "openai/gpt-4o-2024-11-20",
440+ "mistralai/mistral-large-2411",
441+ "mistralai/mistral-large-2407",
442+ "mistralai/pixtral-large-2411",
443+ "thedrummer/unslopnemo-12b",
444+ "anthropic/claude-3.5-haiku:beta",
445+ "anthropic/claude-3.5-haiku",
446+ "anthropic/claude-3.5-haiku-20241022:beta",
447+ "anthropic/claude-3.5-haiku-20241022",
448+ "anthropic/claude-3.5-sonnet:beta",
449+ "anthropic/claude-3.5-sonnet",
450+ "x-ai/grok-beta",
451+ "mistralai/ministral-8b",
452+ "mistralai/ministral-3b",
453+ "nvidia/llama-3.1-nemotron-70b-instruct",
454+ "google/gemini-flash-1.5-8b",
455+ "meta-llama/llama-3.2-11b-vision-instruct",
456+ "meta-llama/llama-3.2-3b-instruct",
457+ "qwen/qwen-2.5-72b-instruct",
458+ "mistralai/pixtral-12b",
459+ "cohere/command-r-plus-08-2024",
460+ "cohere/command-r-08-2024",
461+ "microsoft/phi-3.5-mini-128k-instruct",
462+ "nousresearch/hermes-3-llama-3.1-70b",
463+ "openai/gpt-4o-2024-08-06",
464+ "meta-llama/llama-3.1-405b-instruct",
465+ "meta-llama/llama-3.1-70b-instruct",
466+ "meta-llama/llama-3.1-8b-instruct",
467+ "mistralai/mistral-nemo",
468+ "openai/gpt-4o-mini",
469+ "openai/gpt-4o-mini-2024-07-18",
470+ "anthropic/claude-3.5-sonnet-20240620:beta",
471+ "anthropic/claude-3.5-sonnet-20240620",
472+ "mistralai/mistral-7b-instruct-v0.3",
473+ "mistralai/mistral-7b-instruct:free",
474+ "mistralai/mistral-7b-instruct",
475+ "microsoft/phi-3-mini-128k-instruct",
476+ "microsoft/phi-3-medium-128k-instruct",
477+ "google/gemini-flash-1.5",
478+ "openai/gpt-4o-2024-05-13",
479+ "openai/gpt-4o",
480+ "openai/gpt-4o:extended",
481+ "meta-llama/llama-3-8b-instruct",
482+ "meta-llama/llama-3-70b-instruct",
483+ "mistralai/mixtral-8x22b-instruct",
484+ "openai/gpt-4-turbo",
485+ "google/gemini-pro-1.5",
486+ "cohere/command-r-plus",
487+ "cohere/command-r-plus-04-2024",
488+ "cohere/command-r",
489+ "anthropic/claude-3-haiku:beta",
490+ "anthropic/claude-3-haiku",
491+ "anthropic/claude-3-opus:beta",
492+ "anthropic/claude-3-opus",
493+ "anthropic/claude-3-sonnet:beta",
494+ "anthropic/claude-3-sonnet",
495+ "cohere/command-r-03-2024",
496+ "mistralai/mistral-large",
497+ "openai/gpt-3.5-turbo-0613",
498+ "openai/gpt-4-turbo-preview",
499+ "mistralai/mistral-small",
500+ "mistralai/mistral-tiny",
501+ "mistralai/mixtral-8x7b-instruct",
502+ "openai/gpt-4-1106-preview",
503+ "mistralai/mistral-7b-instruct-v0.1",
504+ "openai/gpt-3.5-turbo-16k",
505+ "openai/gpt-4",
506+ "openai/gpt-4-0314"
507+ ],
508+ "title": "Model ID",
509+ "description": "ID of the preferred model"
510+ },
511+ "provider": {
512+ "type": "string",
513+ "enum": [
514+ "anthropic",
515+ "openai",
516+ "gemini",
517+ "azure",
518+ "bedrock",
519+ "vertex",
520+ "xai",
521+ "openrouter"
522+ ],
523+ "title": "Provider",
524+ "description": "Provider for the preferred model"
525+ },
526+ "reasoning_effort": {
527+ "type": "string",
528+ "title": "Reasoning Effort",
529+ "description": "Override reasoning effort for this model"
530+ },
531+ "max_tokens": {
532+ "type": "integer",
533+ "minimum": 1,
534+ "title": "Max Tokens",
535+ "description": "Override max tokens for this model"
536+ },
537+ "think": {
538+ "type": "boolean",
539+ "title": "Think",
540+ "description": "Enable thinking for reasoning models",
541+ "default": false
542+ }
543+ },
544+ "type": "object",
545+ "required": [
546+ "model_id",
547+ "provider"
548+ ]
549+ },
550+ "PreferredModels": {
551+ "properties": {
552+ "large": {
553+ "$ref": "#/$defs/PreferredModel",
554+ "title": "Large Model",
555+ "description": "Preferred model configuration for large model type"
314556 },
315− "required": ["command"],
316− "type": "object"
557+ "small": {
558+ "$ref": "#/$defs/PreferredModel",
559+ "title": "Small Model",
560+ "description": "Preferred model configuration for small model type"
561+ }
317562 },
318− "description": "Model Control Protocol server configurations",
319563 "type": "object"
320564 },
321− "providers": {
322− "additionalProperties": {
323− "description": "Provider configuration",
324− "properties": {
325− "apiKey": {
326− "description": "API key for the provider",
565+ "ProviderConfig": {
566+ "properties": {
567+ "id": {
568+ "type": "string",
569+ "enum": [
570+ "anthropic",
571+ "openai",
572+ "gemini",
573+ "azure",
574+ "bedrock",
575+ "vertex",
576+ "xai",
577+ "openrouter"
578+ ],
579+ "title": "Provider ID",
580+ "description": "Unique identifier for the provider"
581+ },
582+ "base_url": {
583+ "type": "string",
584+ "title": "Base URL",
585+ "description": "Base URL for the provider API (required for custom providers)"
586+ },
587+ "provider_type": {
588+ "type": "string",
589+ "title": "Provider Type",
590+ "description": "Type of the provider (openai"
591+ },
592+ "api_key": {
593+ "type": "string",
594+ "title": "API Key",
595+ "description": "API key for authenticating with the provider"
596+ },
597+ "disabled": {
598+ "type": "boolean",
599+ "title": "Disabled",
600+ "description": "Whether this provider is disabled",
601+ "default": false
602+ },
603+ "extra_headers": {
604+ "additionalProperties": {
327605 "type": "string"
328606 },
329− "disabled": {
330− "default": false,
331− "description": "Whether the provider is disabled",
332− "type": "boolean"
333− },
334− "provider": {
335− "description": "Provider type",
336− "enum": [
337− "anthropic",
338− "openai",
339− "gemini",
340− "groq",
341− "openrouter",
342− "bedrock",
343− "azure",
344− "vertexai"
345− ],
607+ "type": "object",
608+ "title": "Extra Headers",
609+ "description": "Additional HTTP headers to send with requests"
610+ },
611+ "extra_params": {
612+ "additionalProperties": {
346613 "type": "string"
347− }
614+ },
615+ "type": "object",
616+ "title": "Extra Parameters",
617+ "description": "Additional provider-specific parameters"
618+ },
619+ "default_large_model": {
620+ "type": "string",
621+ "title": "Default Large Model",
622+ "description": "Default model ID for large model type"
623+ },
624+ "default_small_model": {
625+ "type": "string",
626+ "title": "Default Small Model",
627+ "description": "Default model ID for small model type"
348628 },
349− "type": "object"
629+ "models": {
630+ "items": {
631+ "$ref": "#/$defs/Model"
632+ },
633+ "type": "array",
634+ "title": "Models",
635+ "description": "List of available models for this provider"
636+ }
350637 },
351− "description": "LLM provider configurations",
352− "type": "object"
638+ "type": "object",
639+ "required": [
640+ "provider_type"
641+ ]
353642 },
354− "tui": {
355− "description": "Terminal User Interface configuration",
643+ "TUIOptions": {
356644 "properties": {
357− "theme": {
358− "default": "crush",
359− "description": "TUI theme name",
360− "enum": [
361− "crush",
362− "catppuccin",
363− "dracula",
364− "flexoki",
365− "gruvbox",
366− "monokai",
367− "onedark",
368− "tokyonight",
369− "tron"
370− ],
371− "type": "string"
645+ "compact_mode": {
646+ "type": "boolean",
647+ "title": "Compact Mode",
648+ "description": "Enable compact mode for the TUI",
649+ "default": false
372650 }
373651 },
374− "type": "object"
652+ "type": "object",
653+ "required": [
654+ "compact_mode"
655+ ]
656+ }
657+ },
658+ "properties": {
659+ "models": {
660+ "$ref": "#/$defs/PreferredModels",
661+ "title": "Models",
662+ "description": "Preferred model configurations for large and small model types"
663+ },
664+ "providers": {
665+ "additionalProperties": {
666+ "$ref": "#/$defs/ProviderConfig"
667+ },
668+ "type": "object",
669+ "title": "Providers",
670+ "description": "LLM provider configurations"
671+ },
672+ "agents": {
673+ "additionalProperties": {
674+ "$ref": "#/$defs/Agent"
675+ },
676+ "type": "object",
677+ "title": "Agents",
678+ "description": "Agent configurations for different tasks"
679+ },
680+ "mcp": {
681+ "additionalProperties": {
682+ "$ref": "#/$defs/MCP"
683+ },
684+ "type": "object",
685+ "title": "MCP",
686+ "description": "Model Control Protocol server configurations"
687+ },
688+ "lsp": {
689+ "additionalProperties": {
690+ "$ref": "#/$defs/LSPConfig"
691+ },
692+ "type": "object",
693+ "title": "LSP",
694+ "description": "Language Server Protocol configurations"
375695 },
376− "wd": {
377− "description": "Working directory for the application",
378− "type": "string"
696+ "options": {
697+ "$ref": "#/$defs/Options",
698+ "title": "Options",
699+ "description": "General application options and settings"
379700 }
380701 },
702+ "type": "object",
381703 "title": "Crush Configuration",
382− "type": "object"
704+ "description": "Configuration schema for the Crush application"
383705}
.crush.json → crush.jsonrenamed+1−4
@@ -1,11 +1,8 @@
11{
22 "$schema": "./crush-schema.json",
33 "lsp": {
4− "Go": {
4+ "go": {
55 "command": "gopls"
66 }
7− },
8− "tui": {
9− "theme": "crush"
107 }
118}
cspell.json+1−39
@@ -1,39 +1 @@
1−{
2− "flagWords": [],
3− "words": [
4− "afero",
5− "alecthomas",
6− "bubbletea",
7− "charmbracelet",
8− "charmtone",
9− "Charple",
10− "crush",
11− "diffview",
12− "Emph",
13− "filepicker",
14− "Focusable",
15− "fsext",
16− "GROQ",
17− "Guac",
18− "imageorient",
19− "Lanczos",
20− "lipgloss",
21− "lsps",
22− "lucasb",
23− "nfnt",
24− "oksvg",
25− "Preproc",
26− "rasterx",
27− "rivo",
28− "Sourcegraph",
29− "srwiley",
30− "Strikethrough",
31− "termenv",
32− "textinput",
33− "trashhalo",
34− "uniseg",
35− "Unticked"
36− ],
37− "version": "0.2",
38− "language": "en"
39−}
1+{"flagWords":[],"words":["afero","alecthomas","bubbletea","charmbracelet","charmtone","Charple","crush","diffview","Emph","filepicker","Focusable","fsext","GROQ","Guac","imageorient","Lanczos","lipgloss","lsps","lucasb","nfnt","oksvg","Preproc","rasterx","rivo","Sourcegraph","srwiley","Strikethrough","termenv","textinput","trashhalo","uniseg","Unticked","genai","jsonschema"],"version":"0.2","language":"en"}
go.mod+6−9
@@ -1,6 +1,6 @@
11module github.com/charmbracelet/crush
22
3−go 1.24.0
3+go 1.24.3
44
55require (
66 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
@@ -24,6 +24,7 @@ require (
2424 github.com/fsnotify/fsnotify v1.8.0
2525 github.com/go-logfmt/logfmt v0.6.0
2626 github.com/google/uuid v1.6.0
27+ github.com/invopop/jsonschema v0.13.0
2728 github.com/mark3labs/mcp-go v0.17.0
2829 github.com/muesli/termenv v0.16.0
2930 github.com/ncruces/go-sqlite3 v0.25.0
@@ -33,7 +34,6 @@ require (
3334 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
3435 github.com/sahilm/fuzzy v0.1.1
3536 github.com/spf13/cobra v1.9.1
36− github.com/spf13/viper v1.20.0
3737 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
3838 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
3939 github.com/stretchr/testify v1.10.0
@@ -65,6 +65,8 @@ require (
6565 github.com/aws/smithy-go v1.20.3 // indirect
6666 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
6767 github.com/aymerick/douceur v0.2.0 // indirect
68+ github.com/bahlo/generic-list-go v0.2.0 // indirect
69+ github.com/buger/jsonparser v1.1.1 // indirect
6870 github.com/charmbracelet/colorprofile v0.3.1 // indirect
6971 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
7072 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
@@ -78,7 +80,6 @@ require (
7880 github.com/felixge/httpsnoop v1.0.4 // indirect
7981 github.com/go-logr/logr v1.4.2 // indirect
8082 github.com/go-logr/stdr v1.2.2 // indirect
81− github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
8283 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
8384 github.com/google/go-cmp v0.7.0 // indirect
8485 github.com/google/s2a-go v0.1.8 // indirect
@@ -89,6 +90,7 @@ require (
8990 github.com/inconshreveable/mousetrap v1.1.0 // indirect
9091 github.com/kylelemons/godebug v1.1.0 // indirect
9192 github.com/lucasb-eyer/go-colorful v1.2.0
93+ github.com/mailru/easyjson v0.7.7 // indirect
9294 github.com/mattn/go-isatty v0.0.20 // indirect
9395 github.com/mattn/go-runewidth v0.0.16 // indirect
9496 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -99,23 +101,18 @@ require (
99101 github.com/muesli/mango-pflag v0.1.0 // indirect
100102 github.com/muesli/roff v0.1.0 // indirect
101103 github.com/ncruces/julianday v1.0.0 // indirect
102− github.com/pelletier/go-toml/v2 v2.2.3 // indirect
103104 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
104105 github.com/pmezard/go-difflib v1.0.0 // indirect
105106 github.com/rivo/uniseg v0.4.7
106− github.com/sagikazarmark/locafero v0.7.0 // indirect
107107 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
108108 github.com/sethvargo/go-retry v0.3.0 // indirect
109− github.com/sourcegraph/conc v0.3.0 // indirect
110− github.com/spf13/afero v1.12.0
111− github.com/spf13/cast v1.7.1 // indirect
112109 github.com/spf13/pflag v1.0.6 // indirect
113− github.com/subosito/gotenv v1.6.0 // indirect
114110 github.com/tetratelabs/wazero v1.9.0 // indirect
115111 github.com/tidwall/gjson v1.18.0 // indirect
116112 github.com/tidwall/match v1.1.1 // indirect
117113 github.com/tidwall/pretty v1.2.1 // indirect
118114 github.com/tidwall/sjson v1.2.5 // indirect
115+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
119116 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
120117 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
121118 github.com/yuin/goldmark v1.7.8 // indirect
go.sum+11−20
@@ -64,8 +64,12 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
6464github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
6565github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
6666github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
67+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
68+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
6769github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
6870github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
71+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
72+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
6973github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
7074github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
7175github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
@@ -84,8 +88,6 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqR
8488github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
8589github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
8690github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
87−github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
88−github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
8991github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c h1:2GELBLPgfSbHU53bsQhR9XIgNuVZ6w+Rz8RWV5Lq+A4=
9092github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
9193github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
@@ -114,8 +116,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
114116github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
115117github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
116118github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
117−github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
118−github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
119119github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
120120github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
121121github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -127,8 +127,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
127127github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
128128github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
129129github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
130−github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
131−github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
132130github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
133131github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
134132github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -151,6 +149,9 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
151149github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
152150github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
153151github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
152+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
153+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
154+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
154155github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
155156github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
156157github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -162,6 +163,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
162163github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
163164github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
164165github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
166+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
167+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
165168github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
166169github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
167170github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -194,8 +197,6 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
194197github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
195198github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
196199github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
197−github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
198−github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
199200github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
200201github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
201202github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -213,8 +214,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
213214github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
214215github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
215216github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
216−github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
217−github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
218217github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
219218github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
220219github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
@@ -225,18 +224,10 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
225224github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
226225github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
227226github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
228−github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
229−github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
230−github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
231−github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
232−github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
233−github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
234227github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
235228github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
236229github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
237230github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
238−github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
239−github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
240231github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
241232github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
242233github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
@@ -247,8 +238,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
247238github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
248239github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
249240github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
250−github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
251−github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
252241github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
253242github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
254243github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -261,6 +250,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
261250github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
262251github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
263252github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
253+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
254+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
264255github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
265256github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
266257github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
internal/app/app.go+15−8
@@ -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 := config.Get()
59+
60+ coderAgentCfg := cfg.Agents[config.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)
@@ -165,3 +168,7 @@ func (app *App) Shutdown() {
165168 }
166169 app.CoderAgent.CancelAll()
167170}
171+
172+func (app *App) UpdateAgentModel() error {
173+ return app.CoderAgent.UpdateModel()
174+}
internal/config/config.go+1244−660
@@ -1,95 +1,22 @@
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"
17+ "github.com/invopop/jsonschema"
1618)
1719
18−// MCPType defines the type of MCP (Model Control Protocol) server.
19−type MCPType string
20−
21−// Supported MCP types
22−const (
23− MCPStdio MCPType = "stdio"
24− MCPSse MCPType = "sse"
25−)
26−
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"`
35−}
36−
37−type AgentName string
38−
39−const (
40− AgentCoder AgentName = "coder"
41− AgentSummarizer AgentName = "summarizer"
42− AgentTask AgentName = "task"
43− AgentTitle AgentName = "title"
44−)
45−
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
51−}
52−
53−// Provider defines configuration for an LLM provider.
54−type Provider struct {
55− APIKey string `json:"apiKey"`
56− Disabled bool `json:"disabled"`
57−}
58−
59−// Data defines storage configuration.
60−type Data struct {
61− Directory string `json:"directory,omitempty"`
62−}
63−
64−// LSPConfig defines configuration for Language Server Protocol integration.
65−type LSPConfig struct {
66− Disabled bool `json:"enabled"`
67− Command string `json:"command"`
68− Args []string `json:"args"`
69− Options any `json:"options"`
70−}
71−
72−// TUIConfig defines the configuration for the Terminal User Interface.
73−type TUIConfig struct {
74− Theme string `json:"theme,omitempty"`
75−}
76−
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.ModelProvider]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"`
90−}
91−
92−// Application constants
9320const (
9421 defaultDataDirectory = ".crush"
9522 defaultLogLevel = "info"
@@ -114,53 +41,181 @@ var defaultContextPaths = []string{
11441 "CRUSH.local.md",
11542}
11643
117−// Global configuration instance
118−var cfg *Config
44+type AgentID string
11945
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− }
46+const (
47+ AgentCoder AgentID = "coder"
48+ AgentTask AgentID = "task"
49+)
12750
128− cfg = &Config{
129− WorkingDir: workingDir,
130− MCPServers: make(map[string]MCPServer),
131− Providers: make(map[models.ModelProvider]Provider),
132− LSP: make(map[string]LSPConfig),
133− }
51+type ModelType string
13452
135− configureViper()
136− setDefaults(debug)
53+const (
54+ LargeModel ModelType = "large"
55+ SmallModel ModelType = "small"
56+)
13757
138− // Read global config
139− if err := readConfig(viper.ReadInConfig()); err != nil {
140− return cfg, err
141− }
58+type Model struct {
59+ ID string `json:"id" jsonschema:"title=Model ID,description=Unique identifier for the model, the API model"`
60+ Name string `json:"name" jsonschema:"title=Model Name,description=Display name of the model"`
61+ CostPer1MIn float64 `json:"cost_per_1m_in,omitempty" jsonschema:"title=Input Cost,description=Cost per 1 million input tokens,minimum=0"`
62+ CostPer1MOut float64 `json:"cost_per_1m_out,omitempty" jsonschema:"title=Output Cost,description=Cost per 1 million output tokens,minimum=0"`
63+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached,omitempty" jsonschema:"title=Cached Input Cost,description=Cost per 1 million cached input tokens,minimum=0"`
64+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached,omitempty" jsonschema:"title=Cached Output Cost,description=Cost per 1 million cached output tokens,minimum=0"`
65+ ContextWindow int64 `json:"context_window" jsonschema:"title=Context Window,description=Maximum context window size in tokens,minimum=1"`
66+ DefaultMaxTokens int64 `json:"default_max_tokens" jsonschema:"title=Default Max Tokens,description=Default maximum tokens for responses,minimum=1"`
67+ CanReason bool `json:"can_reason,omitempty" jsonschema:"title=Can Reason,description=Whether the model supports reasoning capabilities"`
68+ ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"title=Reasoning Effort,description=Default reasoning effort level for reasoning models"`
69+ HasReasoningEffort bool `json:"has_reasoning_effort,omitempty" jsonschema:"title=Has Reasoning Effort,description=Whether the model supports reasoning effort configuration"`
70+ SupportsImages bool `json:"supports_attachments,omitempty" jsonschema:"title=Supports Images,description=Whether the model supports image attachments"`
71+}
14272
143− // Load and merge local config
144− mergeLocalConfig(workingDir)
73+type VertexAIOptions struct {
74+ APIKey string `json:"api_key,omitempty"`
75+ Project string `json:"project,omitempty"`
76+ Location string `json:"location,omitempty"`
77+}
14578
146− setProviderDefaults()
79+type ProviderConfig struct {
80+ ID provider.InferenceProvider `json:"id,omitempty" jsonschema:"title=Provider ID,description=Unique identifier for the provider"`
81+ BaseURL string `json:"base_url,omitempty" jsonschema:"title=Base URL,description=Base URL for the provider API (required for custom providers)"`
82+ ProviderType provider.Type `json:"provider_type" jsonschema:"title=Provider Type,description=Type of the provider (openai, anthropic, etc.)"`
83+ APIKey string `json:"api_key,omitempty" jsonschema:"title=API Key,description=API key for authenticating with the provider"`
84+ Disabled bool `json:"disabled,omitempty" jsonschema:"title=Disabled,description=Whether this provider is disabled,default=false"`
85+ ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"title=Extra Headers,description=Additional HTTP headers to send with requests"`
86+ // used for e.x for vertex to set the project
87+ ExtraParams map[string]string `json:"extra_params,omitempty" jsonschema:"title=Extra Parameters,description=Additional provider-specific parameters"`
14788
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− }
89+ DefaultLargeModel string `json:"default_large_model,omitempty" jsonschema:"title=Default Large Model,description=Default model ID for large model type"`
90+ DefaultSmallModel string `json:"default_small_model,omitempty" jsonschema:"title=Default Small Model,description=Default model ID for small model type"`
91+
92+ Models []Model `json:"models,omitempty" jsonschema:"title=Models,description=List of available models for this provider"`
93+}
94+
95+type Agent struct {
96+ ID AgentID `json:"id,omitempty" jsonschema:"title=Agent ID,description=Unique identifier for the agent,enum=coder,enum=task"`
97+ Name string `json:"name,omitempty" jsonschema:"title=Name,description=Display name of the agent"`
98+ Description string `json:"description,omitempty" jsonschema:"title=Description,description=Description of what the agent does"`
99+ // This is the id of the system prompt used by the agent
100+ Disabled bool `json:"disabled,omitempty" jsonschema:"title=Disabled,description=Whether this agent is disabled,default=false"`
101+
102+ Model ModelType `json:"model" jsonschema:"title=Model Type,description=Type of model to use (large or small),enum=large,enum=small"`
103+
104+ // The available tools for the agent
105+ // if this is nil, all tools are available
106+ AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"title=Allowed Tools,description=List of tools this agent is allowed to use (if nil all tools are allowed)"`
107+
108+ // this tells us which MCPs are available for this agent
109+ // if this is empty all mcps are available
110+ // the string array is the list of tools from the AllowedMCP the agent has available
111+ // if the string array is nil, all tools from the AllowedMCP are available
112+ AllowedMCP map[string][]string `json:"allowed_mcp,omitempty" jsonschema:"title=Allowed MCP,description=Map of MCP servers this agent can use and their allowed tools"`
113+
114+ // The list of LSPs that this agent can use
115+ // if this is nil, all LSPs are available
116+ AllowedLSP []string `json:"allowed_lsp,omitempty" jsonschema:"title=Allowed LSP,description=List of LSP servers this agent can use (if nil all LSPs are allowed)"`
117+
118+ // Overrides the context paths for this agent
119+ ContextPaths []string `json:"context_paths,omitempty" jsonschema:"title=Context Paths,description=Custom context paths for this agent (additive to global context paths)"`
120+}
121+
122+type MCPType string
123+
124+const (
125+ MCPStdio MCPType = "stdio"
126+ MCPSse MCPType = "sse"
127+)
128+
129+type MCP struct {
130+ Command string `json:"command" jsonschema:"title=Command,description=Command to execute for stdio MCP servers"`
131+ Env []string `json:"env,omitempty" jsonschema:"title=Environment,description=Environment variables for the MCP server"`
132+ Args []string `json:"args,omitempty" jsonschema:"title=Arguments,description=Command line arguments for the MCP server"`
133+ Type MCPType `json:"type" jsonschema:"title=Type,description=Type of MCP connection,enum=stdio,enum=sse,default=stdio"`
134+ URL string `json:"url,omitempty" jsonschema:"title=URL,description=URL for SSE MCP servers"`
135+ // TODO: maybe make it possible to get the value from the env
136+ Headers map[string]string `json:"headers,omitempty" jsonschema:"title=Headers,description=HTTP headers for SSE MCP servers"`
137+}
138+
139+type LSPConfig struct {
140+ Disabled bool `json:"enabled,omitempty" jsonschema:"title=Enabled,description=Whether this LSP server is enabled,default=true"`
141+ Command string `json:"command" jsonschema:"title=Command,description=Command to execute for the LSP server"`
142+ Args []string `json:"args,omitempty" jsonschema:"title=Arguments,description=Command line arguments for the LSP server"`
143+ Options any `json:"options,omitempty" jsonschema:"title=Options,description=LSP server specific options"`
144+}
145+
146+type TUIOptions struct {
147+ CompactMode bool `json:"compact_mode" jsonschema:"title=Compact Mode,description=Enable compact mode for the TUI,default=false"`
148+ // Here we can add themes later or any TUI related options
149+}
150+
151+type Options struct {
152+ ContextPaths []string `json:"context_paths,omitempty" jsonschema:"title=Context Paths,description=List of paths to search for context files"`
153+ TUI TUIOptions `json:"tui,omitempty" jsonschema:"title=TUI Options,description=Terminal UI configuration options"`
154+ Debug bool `json:"debug,omitempty" jsonschema:"title=Debug,description=Enable debug logging,default=false"`
155+ DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"title=Debug LSP,description=Enable LSP debug logging,default=false"`
156+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"title=Disable Auto Summarize,description=Disable automatic conversation summarization,default=false"`
157+ // Relative to the cwd
158+ DataDirectory string `json:"data_directory,omitempty" jsonschema:"title=Data Directory,description=Directory for storing application data,default=.crush"`
159+}
160+
161+type PreferredModel struct {
162+ ModelID string `json:"model_id" jsonschema:"title=Model ID,description=ID of the preferred model"`
163+ Provider provider.InferenceProvider `json:"provider" jsonschema:"title=Provider,description=Provider for the preferred model"`
164+ // ReasoningEffort overrides the default reasoning effort for this model
165+ ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"title=Reasoning Effort,description=Override reasoning effort for this model"`
166+ // MaxTokens overrides the default max tokens for this model
167+ MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"title=Max Tokens,description=Override max tokens for this model,minimum=1"`
168+
169+ // Think indicates if the model should think, only applicable for anthropic reasoning models
170+ Think bool `json:"think,omitempty" jsonschema:"title=Think,description=Enable thinking for reasoning models,default=false"`
171+}
172+
173+type PreferredModels struct {
174+ Large PreferredModel `json:"large,omitempty" jsonschema:"title=Large Model,description=Preferred model configuration for large model type"`
175+ Small PreferredModel `json:"small,omitempty" jsonschema:"title=Small Model,description=Preferred model configuration for small model type"`
176+}
177+
178+type Config struct {
179+ Models PreferredModels `json:"models,omitempty" jsonschema:"title=Models,description=Preferred model configurations for large and small model types"`
180+ // List of configured providers
181+ Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty" jsonschema:"title=Providers,description=LLM provider configurations"`
182+
183+ // List of configured agents
184+ Agents map[AgentID]Agent `json:"agents,omitempty" jsonschema:"title=Agents,description=Agent configurations for different tasks"`
185+
186+ // List of configured MCPs
187+ MCP map[string]MCP `json:"mcp,omitempty" jsonschema:"title=MCP,description=Model Control Protocol server configurations"`
188+
189+ // List of configured LSPs
190+ LSP map[string]LSPConfig `json:"lsp,omitempty" jsonschema:"title=LSP,description=Language Server Protocol configurations"`
191+
192+ // Miscellaneous options
193+ Options Options `json:"options,omitempty" jsonschema:"title=Options,description=General application options and settings"`
194+}
195+
196+var (
197+ instance *Config // The single instance of the Singleton
198+ cwd string
199+ once sync.Once // Ensures the initialization happens only once
152200
153− applyDefaultValues()
201+)
202+
203+func loadConfig(cwd string, debug bool) (*Config, error) {
204+ // First read the global config file
205+ cfgPath := ConfigPath()
206+
207+ cfg := defaultConfigBasedOnEnv()
208+ cfg.Options.Debug = debug
154209 defaultLevel := slog.LevelInfo
155− if cfg.Debug {
210+ if cfg.Options.Debug {
156211 defaultLevel = slog.LevelDebug
157212 }
158213 if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
159− loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
214+ loggingFile := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "debug.log")
160215
161216 // if file does not exist create it
162217 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
163− if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
218+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o755); err != nil {
164219 return cfg, fmt.Errorf("failed to create directory: %w", err)
165220 }
166221 if _, err := os.Create(loggingFile); err != nil {
@@ -168,6 +223,15 @@ func Load(workingDir string, debug bool) (*Config, error) {
168223 }
169224 }
170225
226+ messagesPath := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "messages")
227+
228+ if _, err := os.Stat(messagesPath); os.IsNotExist(err) {
229+ if err := os.MkdirAll(messagesPath, 0o756); err != nil {
230+ return cfg, fmt.Errorf("failed to create directory: %w", err)
231+ }
232+ }
233+ logging.MessageDir = messagesPath
234+
171235 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
172236 if err != nil {
173237 return cfg, fmt.Errorf("failed to open log file: %w", err)
@@ -184,237 +248,579 @@ func Load(workingDir string, debug bool) (*Config, error) {
184248 }))
185249 slog.SetDefault(logger)
186250 }
187−
188− // Validate configuration
189− if err := Validate(); err != nil {
190− return cfg, fmt.Errorf("config validation failed: %w", err)
251+ var globalCfg *Config
252+ if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
253+ // some other error occurred while checking the file
254+ return nil, err
255+ } else if err == nil {
256+ // config file exists, read it
257+ file, err := os.ReadFile(cfgPath)
258+ if err != nil {
259+ return nil, err
260+ }
261+ globalCfg = &Config{}
262+ if err := json.Unmarshal(file, globalCfg); err != nil {
263+ return nil, err
264+ }
265+ } else {
266+ // config file does not exist, create a new one
267+ globalCfg = &Config{}
268+ }
269+
270+ var localConfig *Config
271+ // Global config loaded, now read the local config file
272+ localConfigPath := filepath.Join(cwd, "crush.json")
273+ if _, err := os.Stat(localConfigPath); err != nil && !os.IsNotExist(err) {
274+ // some other error occurred while checking the file
275+ return nil, err
276+ } else if err == nil {
277+ // local config file exists, read it
278+ file, err := os.ReadFile(localConfigPath)
279+ if err != nil {
280+ return nil, err
281+ }
282+ localConfig = &Config{}
283+ if err := json.Unmarshal(file, localConfig); err != nil {
284+ return nil, err
285+ }
191286 }
192287
193− if cfg.Agents == nil {
194− cfg.Agents = make(map[AgentName]Agent)
195− }
288+ // merge options
289+ mergeOptions(cfg, globalCfg, localConfig)
196290
197− // Override the max tokens for title agent
198− cfg.Agents[AgentTitle] = Agent{
199− Model: cfg.Agents[AgentTitle].Model,
200− MaxTokens: 80,
291+ mergeProviderConfigs(cfg, globalCfg, localConfig)
292+ // no providers found the app is not initialized yet
293+ if len(cfg.Providers) == 0 {
294+ return cfg, nil
201295 }
296+ preferredProvider := getPreferredProvider(cfg.Providers)
297+ if preferredProvider != nil {
298+ cfg.Models = PreferredModels{
299+ Large: PreferredModel{
300+ ModelID: preferredProvider.DefaultLargeModel,
301+ Provider: preferredProvider.ID,
302+ },
303+ Small: PreferredModel{
304+ ModelID: preferredProvider.DefaultSmallModel,
305+ Provider: preferredProvider.ID,
306+ },
307+ }
308+ } else {
309+ // No valid providers found, set empty models
310+ cfg.Models = PreferredModels{}
311+ }
312+
313+ mergeModels(cfg, globalCfg, localConfig)
314+
315+ agents := map[AgentID]Agent{
316+ AgentCoder: {
317+ ID: AgentCoder,
318+ Name: "Coder",
319+ Description: "An agent that helps with executing coding tasks.",
320+ Model: LargeModel,
321+ ContextPaths: cfg.Options.ContextPaths,
322+ // All tools allowed
323+ },
324+ AgentTask: {
325+ ID: AgentTask,
326+ Name: "Task",
327+ Description: "An agent that helps with searching for context and finding implementation details.",
328+ Model: LargeModel,
329+ ContextPaths: cfg.Options.ContextPaths,
330+ AllowedTools: []string{
331+ "glob",
332+ "grep",
333+ "ls",
334+ "sourcegraph",
335+ "view",
336+ },
337+ // NO MCPs or LSPs by default
338+ AllowedMCP: map[string][]string{},
339+ AllowedLSP: []string{},
340+ },
341+ }
342+ cfg.Agents = agents
343+ mergeAgents(cfg, globalCfg, localConfig)
344+ mergeMCPs(cfg, globalCfg, localConfig)
345+ mergeLSPs(cfg, globalCfg, localConfig)
346+
347+ // Validate the final configuration
348+ if err := cfg.Validate(); err != nil {
349+ return cfg, fmt.Errorf("configuration validation failed: %w", err)
350+ }
351+
202352 return cfg, nil
203353}
204354
205−type configFinder struct {
206− appName string
207− dotPrefix bool
208− paths []string
355+func Init(workingDir string, debug bool) (*Config, error) {
356+ var err error
357+ once.Do(func() {
358+ cwd = workingDir
359+ instance, err = loadConfig(cwd, debug)
360+ if err != nil {
361+ logging.Error("Failed to load config", "error", err)
362+ }
363+ })
364+
365+ return instance, err
366+}
367+
368+func Get() *Config {
369+ if instance == nil {
370+ // TODO: Handle this better
371+ panic("Config not initialized. Call InitConfig first.")
372+ }
373+ return instance
209374}
210375
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)
376+func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
377+ providers := Providers()
378+ for _, p := range providers {
379+ if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
380+ return &providerConfig
381+ }
216382 }
217− paths := []string{}
218− for _, p := range f.paths {
219− if p == "" {
220− continue
383+ // if none found return the first configured provider
384+ for _, providerConfig := range configuredProviders {
385+ if !providerConfig.Disabled {
386+ return &providerConfig
221387 }
222− paths = append(paths, os.ExpandEnv(p))
223388 }
389+ return nil
390+}
224391
225− for _, path := range paths {
226− if path == "" {
227− continue
392+func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
393+ if other.APIKey != "" {
394+ base.APIKey = other.APIKey
395+ }
396+ // Only change these options if the provider is not a known provider
397+ if !slices.Contains(provider.KnownProviders(), p) {
398+ if other.BaseURL != "" {
399+ base.BaseURL = other.BaseURL
228400 }
229−
230− configPath := filepath.Join(path, configName)
231− if exists, err := afero.Exists(fsys, configPath); err == nil && exists {
232− configFiles = append(configFiles, configPath)
401+ if other.ProviderType != "" {
402+ base.ProviderType = other.ProviderType
403+ }
404+ if len(other.ExtraHeaders) > 0 {
405+ if base.ExtraHeaders == nil {
406+ base.ExtraHeaders = make(map[string]string)
407+ }
408+ maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
409+ }
410+ if len(other.ExtraParams) > 0 {
411+ if base.ExtraParams == nil {
412+ base.ExtraParams = make(map[string]string)
413+ }
414+ maps.Copy(base.ExtraParams, other.ExtraParams)
233415 }
234416 }
235− return configFiles, nil
236−}
237−
238−// configureViper sets up viper's configuration paths and environment variables.
239−func configureViper() {
240− viper.SetConfigType("json")
241417
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− }}
418+ if other.Disabled {
419+ base.Disabled = other.Disabled
420+ }
248421
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− }}
422+ if other.DefaultLargeModel != "" {
423+ base.DefaultLargeModel = other.DefaultLargeModel
424+ }
425+ // Add new models if they don't exist
426+ if other.Models != nil {
427+ for _, model := range other.Models {
428+ // check if the model already exists
429+ exists := false
430+ for _, existingModel := range base.Models {
431+ if existingModel.ID == model.ID {
432+ exists = true
433+ break
434+ }
435+ }
436+ if !exists {
437+ base.Models = append(base.Models, model)
438+ }
439+ }
440+ }
254441
255− localFinder := configFinder{appName: appName, dotPrefix: true, paths: []string{
256− ".",
257− }}
442+ return base
443+}
258444
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()
445+func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
446+ if !slices.Contains(provider.KnownProviders(), p) {
447+ if providerConfig.ProviderType != provider.TypeOpenAI {
448+ return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
449+ }
450+ if providerConfig.BaseURL == "" {
451+ return errors.New("base URL must be set for custom providers")
452+ }
453+ if providerConfig.APIKey == "" {
454+ return errors.New("API key must be set for custom providers")
455+ }
456+ }
457+ return nil
263458}
264459
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)
460+func mergeModels(base, global, local *Config) {
461+ for _, cfg := range []*Config{global, local} {
462+ if cfg == nil {
463+ continue
464+ }
465+ if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
466+ base.Models.Large = cfg.Models.Large
467+ }
271468
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)
469+ if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
470+ base.Models.Small = cfg.Models.Small
471+ }
278472 }
279473}
280474
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− }
475+func mergeOptions(base, global, local *Config) {
476+ for _, cfg := range []*Config{global, local} {
477+ if cfg == nil {
478+ continue
479+ }
480+ baseOptions := base.Options
481+ other := cfg.Options
482+ if len(other.ContextPaths) > 0 {
483+ baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
484+ }
307485
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
486+ if other.TUI.CompactMode {
487+ baseOptions.TUI.CompactMode = other.TUI.CompactMode
488+ }
489+
490+ if other.Debug {
491+ baseOptions.Debug = other.Debug
492+ }
493+
494+ if other.DebugLSP {
495+ baseOptions.DebugLSP = other.DebugLSP
496+ }
497+
498+ if other.DisableAutoSummarize {
499+ baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
500+ }
317501
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
502+ if other.DataDirectory != "" {
503+ baseOptions.DataDirectory = other.DataDirectory
504+ }
505+ base.Options = baseOptions
325506 }
507+}
326508
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
509+func mergeAgents(base, global, local *Config) {
510+ for _, cfg := range []*Config{global, local} {
511+ if cfg == nil {
512+ continue
513+ }
514+ for agentID, newAgent := range cfg.Agents {
515+ if _, ok := base.Agents[agentID]; !ok {
516+ newAgent.ID = agentID
517+ if newAgent.Model == "" {
518+ newAgent.Model = LargeModel
519+ }
520+ if len(newAgent.ContextPaths) > 0 {
521+ newAgent.ContextPaths = append(base.Options.ContextPaths, newAgent.ContextPaths...)
522+ } else {
523+ newAgent.ContextPaths = base.Options.ContextPaths
524+ }
525+ base.Agents[agentID] = newAgent
526+ } else {
527+ baseAgent := base.Agents[agentID]
528+
529+ if agentID == AgentCoder || agentID == AgentTask {
530+ if newAgent.Model != "" {
531+ baseAgent.Model = newAgent.Model
532+ }
533+ if newAgent.AllowedMCP != nil {
534+ baseAgent.AllowedMCP = newAgent.AllowedMCP
535+ }
536+ if newAgent.AllowedLSP != nil {
537+ baseAgent.AllowedLSP = newAgent.AllowedLSP
538+ }
539+ // Context paths are additive for known agents too
540+ if len(newAgent.ContextPaths) > 0 {
541+ baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
542+ }
543+ } else {
544+ if newAgent.Name != "" {
545+ baseAgent.Name = newAgent.Name
546+ }
547+ if newAgent.Description != "" {
548+ baseAgent.Description = newAgent.Description
549+ }
550+ if newAgent.Model != "" {
551+ baseAgent.Model = newAgent.Model
552+ } else if baseAgent.Model == "" {
553+ baseAgent.Model = LargeModel
554+ }
555+
556+ baseAgent.Disabled = newAgent.Disabled
557+
558+ if newAgent.AllowedTools != nil {
559+ baseAgent.AllowedTools = newAgent.AllowedTools
560+ }
561+ if newAgent.AllowedMCP != nil {
562+ baseAgent.AllowedMCP = newAgent.AllowedMCP
563+ }
564+ if newAgent.AllowedLSP != nil {
565+ baseAgent.AllowedLSP = newAgent.AllowedLSP
566+ }
567+ if len(newAgent.ContextPaths) > 0 {
568+ baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
569+ }
570+ }
571+
572+ base.Agents[agentID] = baseAgent
573+ }
574+ }
334575 }
576+}
335577
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
578+func mergeMCPs(base, global, local *Config) {
579+ for _, cfg := range []*Config{global, local} {
580+ if cfg == nil {
581+ continue
582+ }
583+ maps.Copy(base.MCP, cfg.MCP)
343584 }
585+}
344586
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
587+func mergeLSPs(base, global, local *Config) {
588+ for _, cfg := range []*Config{global, local} {
589+ if cfg == nil {
590+ continue
591+ }
592+ maps.Copy(base.LSP, cfg.LSP)
352593 }
594+}
353595
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
596+func mergeProviderConfigs(base, global, local *Config) {
597+ for _, cfg := range []*Config{global, local} {
598+ if cfg == nil {
599+ continue
600+ }
601+ for providerName, p := range cfg.Providers {
602+ p.ID = providerName
603+ if _, ok := base.Providers[providerName]; !ok {
604+ base.Providers[providerName] = p
605+ } else {
606+ base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], p)
607+ }
608+ }
361609 }
362610
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
611+ finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
612+ for providerName, providerConfig := range base.Providers {
613+ err := validateProvider(providerName, providerConfig)
614+ if err != nil {
615+ logging.Warn("Skipping provider", "name", providerName, "error", err)
616+ continue // Skip invalid providers
617+ }
618+ finalProviders[providerName] = providerConfig
370619 }
620+ base.Providers = finalProviders
621+}
371622
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
623+func providerDefaultConfig(providerID provider.InferenceProvider) ProviderConfig {
624+ switch providerID {
625+ case provider.InferenceProviderAnthropic:
626+ return ProviderConfig{
627+ ID: providerID,
628+ ProviderType: provider.TypeAnthropic,
629+ }
630+ case provider.InferenceProviderOpenAI:
631+ return ProviderConfig{
632+ ID: providerID,
633+ ProviderType: provider.TypeOpenAI,
634+ }
635+ case provider.InferenceProviderGemini:
636+ return ProviderConfig{
637+ ID: providerID,
638+ ProviderType: provider.TypeGemini,
639+ }
640+ case provider.InferenceProviderBedrock:
641+ return ProviderConfig{
642+ ID: providerID,
643+ ProviderType: provider.TypeBedrock,
644+ }
645+ case provider.InferenceProviderAzure:
646+ return ProviderConfig{
647+ ID: providerID,
648+ ProviderType: provider.TypeAzure,
649+ }
650+ case provider.InferenceProviderOpenRouter:
651+ return ProviderConfig{
652+ ID: providerID,
653+ ProviderType: provider.TypeOpenAI,
654+ BaseURL: "https://openrouter.ai/api/v1",
655+ ExtraHeaders: map[string]string{
656+ "HTTP-Referer": "crush.charm.land",
657+ "X-Title": "Crush",
658+ },
659+ }
660+ case provider.InferenceProviderXAI:
661+ return ProviderConfig{
662+ ID: providerID,
663+ ProviderType: provider.TypeXAI,
664+ BaseURL: "https://api.x.ai/v1",
665+ }
666+ case provider.InferenceProviderVertexAI:
667+ return ProviderConfig{
668+ ID: providerID,
669+ ProviderType: provider.TypeVertexAI,
670+ }
671+ default:
672+ return ProviderConfig{
673+ ID: providerID,
674+ ProviderType: provider.TypeOpenAI,
675+ }
379676 }
677+}
380678
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
679+func defaultConfigBasedOnEnv() *Config {
680+ cfg := &Config{
681+ Options: Options{
682+ DataDirectory: defaultDataDirectory,
683+ ContextPaths: defaultContextPaths,
684+ },
685+ Providers: make(map[provider.InferenceProvider]ProviderConfig),
686+ Agents: make(map[AgentID]Agent),
687+ LSP: make(map[string]LSPConfig),
688+ MCP: make(map[string]MCP),
689+ }
690+
691+ providers := Providers()
692+
693+ for _, p := range providers {
694+ if strings.HasPrefix(p.APIKey, "$") {
695+ envVar := strings.TrimPrefix(p.APIKey, "$")
696+ if apiKey := os.Getenv(envVar); apiKey != "" {
697+ providerConfig := providerDefaultConfig(p.ID)
698+ providerConfig.APIKey = apiKey
699+ providerConfig.DefaultLargeModel = p.DefaultLargeModelID
700+ providerConfig.DefaultSmallModel = p.DefaultSmallModelID
701+ baseURL := p.APIEndpoint
702+ if strings.HasPrefix(baseURL, "$") {
703+ envVar := strings.TrimPrefix(baseURL, "$")
704+ baseURL = os.Getenv(envVar)
705+ }
706+ providerConfig.BaseURL = baseURL
707+ for _, model := range p.Models {
708+ configModel := Model{
709+ ID: model.ID,
710+ Name: model.Name,
711+ CostPer1MIn: model.CostPer1MIn,
712+ CostPer1MOut: model.CostPer1MOut,
713+ CostPer1MInCached: model.CostPer1MInCached,
714+ CostPer1MOutCached: model.CostPer1MOutCached,
715+ ContextWindow: model.ContextWindow,
716+ DefaultMaxTokens: model.DefaultMaxTokens,
717+ CanReason: model.CanReason,
718+ SupportsImages: model.SupportsImages,
719+ }
720+ // Set reasoning effort for reasoning models
721+ if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
722+ configModel.HasReasoningEffort = model.HasReasoningEffort
723+ configModel.ReasoningEffort = model.DefaultReasoningEffort
724+ }
725+ providerConfig.Models = append(providerConfig.Models, configModel)
726+ }
727+ cfg.Providers[p.ID] = providerConfig
728+ }
729+ }
730+ }
731+ // TODO: support local models
732+
733+ if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
734+ providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
735+ providerConfig.ExtraParams = map[string]string{
736+ "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
737+ "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
738+ }
739+ // Find the VertexAI provider definition to get default models
740+ for _, p := range providers {
741+ if p.ID == provider.InferenceProviderVertexAI {
742+ providerConfig.DefaultLargeModel = p.DefaultLargeModelID
743+ providerConfig.DefaultSmallModel = p.DefaultSmallModelID
744+ for _, model := range p.Models {
745+ configModel := Model{
746+ ID: model.ID,
747+ Name: model.Name,
748+ CostPer1MIn: model.CostPer1MIn,
749+ CostPer1MOut: model.CostPer1MOut,
750+ CostPer1MInCached: model.CostPer1MInCached,
751+ CostPer1MOutCached: model.CostPer1MOutCached,
752+ ContextWindow: model.ContextWindow,
753+ DefaultMaxTokens: model.DefaultMaxTokens,
754+ CanReason: model.CanReason,
755+ SupportsImages: model.SupportsImages,
756+ }
757+ // Set reasoning effort for reasoning models
758+ if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
759+ configModel.HasReasoningEffort = model.HasReasoningEffort
760+ configModel.ReasoningEffort = model.DefaultReasoningEffort
761+ }
762+ providerConfig.Models = append(providerConfig.Models, configModel)
763+ }
764+ break
765+ }
766+ }
767+ cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
388768 }
389769
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
770+ if hasAWSCredentials() {
771+ providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
772+ providerConfig.ExtraParams = map[string]string{
773+ "region": os.Getenv("AWS_DEFAULT_REGION"),
774+ }
775+ if providerConfig.ExtraParams["region"] == "" {
776+ providerConfig.ExtraParams["region"] = os.Getenv("AWS_REGION")
777+ }
778+ // Find the Bedrock provider definition to get default models
779+ for _, p := range providers {
780+ if p.ID == provider.InferenceProviderBedrock {
781+ providerConfig.DefaultLargeModel = p.DefaultLargeModelID
782+ providerConfig.DefaultSmallModel = p.DefaultSmallModelID
783+ for _, model := range p.Models {
784+ configModel := Model{
785+ ID: model.ID,
786+ Name: model.Name,
787+ CostPer1MIn: model.CostPer1MIn,
788+ CostPer1MOut: model.CostPer1MOut,
789+ CostPer1MInCached: model.CostPer1MInCached,
790+ CostPer1MOutCached: model.CostPer1MOutCached,
791+ ContextWindow: model.ContextWindow,
792+ DefaultMaxTokens: model.DefaultMaxTokens,
793+ CanReason: model.CanReason,
794+ SupportsImages: model.SupportsImages,
795+ }
796+ // Set reasoning effort for reasoning models
797+ if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
798+ configModel.HasReasoningEffort = model.HasReasoningEffort
799+ configModel.ReasoningEffort = model.DefaultReasoningEffort
800+ }
801+ providerConfig.Models = append(providerConfig.Models, configModel)
802+ }
803+ break
804+ }
805+ }
806+ cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
397807 }
808+ return cfg
398809}
399810
400−// hasAWSCredentials checks if AWS credentials are available in the environment.
401811func hasAWSCredentials() bool {
402− // Check for explicit AWS credentials
403812 if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
404813 return true
405814 }
406815
407− // Check for AWS profile
408816 if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
409817 return true
410818 }
411819
412− // Check for AWS region
413820 if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
414821 return true
415822 }
416823
417− // Check if running on EC2 with instance profile
418824 if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
419825 os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
420826 return true
@@ -423,495 +829,673 @@ func hasAWSCredentials() bool {
423829 return false
424830}
425831
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
832+func WorkingDirectory() string {
833+ return cwd
834+}
835+
836+// TODO: Handle error state
837+
838+func GetAgentModel(agentID AgentID) Model {
839+ cfg := Get()
840+ agent, ok := cfg.Agents[agentID]
841+ if !ok {
842+ logging.Error("Agent not found", "agent_id", agentID)
843+ return Model{}
431844 }
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
845+
846+ var model PreferredModel
847+ switch agent.Model {
848+ case LargeModel:
849+ model = cfg.Models.Large
850+ case SmallModel:
851+ model = cfg.Models.Small
852+ default:
853+ logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
854+ model = cfg.Models.Large // Fallback to large model
435855 }
436− return false
856+ providerConfig, ok := cfg.Providers[model.Provider]
857+ if !ok {
858+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
859+ return Model{}
860+ }
861+
862+ for _, m := range providerConfig.Models {
863+ if m.ID == model.ModelID {
864+ return m
865+ }
866+ }
867+
868+ logging.Error("Model not found for agent", "agent_id", agentID, "model", agent.Model)
869+ return Model{}
437870}
438871
439−// readConfig handles the result of reading a configuration file.
440−func readConfig(err error) error {
441− if err == nil {
442− return nil
872+// GetAgentEffectiveMaxTokens returns the effective max tokens for an agent,
873+// considering any overrides from the preferred model configuration
874+func GetAgentEffectiveMaxTokens(agentID AgentID) int64 {
875+ cfg := Get()
876+ agent, ok := cfg.Agents[agentID]
877+ if !ok {
878+ logging.Error("Agent not found", "agent_id", agentID)
879+ return 0
443880 }
444881
445− // It's okay if the config file doesn't exist
446− if _, ok := err.(viper.ConfigFileNotFoundError); ok {
447− return nil
882+ var preferredModel PreferredModel
883+ switch agent.Model {
884+ case LargeModel:
885+ preferredModel = cfg.Models.Large
886+ case SmallModel:
887+ preferredModel = cfg.Models.Small
888+ default:
889+ logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
890+ preferredModel = cfg.Models.Large // Fallback to large model
448891 }
449892
450− return fmt.Errorf("failed to read config: %w", err)
451−}
893+ // Get the base model configuration
894+ baseModel := GetAgentModel(agentID)
895+ if baseModel.ID == "" {
896+ return 0
897+ }
452898
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)
899+ // Start with the default max tokens from the base model
900+ maxTokens := baseModel.DefaultMaxTokens
459901
460− // Merge local config if it exists
461− if err := local.ReadInConfig(); err == nil {
462− viper.MergeConfigMap(local.AllSettings())
902+ // Override with preferred model max tokens if set
903+ if preferredModel.MaxTokens > 0 {
904+ maxTokens = preferredModel.MaxTokens
463905 }
906+
907+ return maxTokens
464908}
465909
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− }
910+func GetAgentProvider(agentID AgentID) ProviderConfig {
911+ cfg := Get()
912+ agent, ok := cfg.Agents[agentID]
913+ if !ok {
914+ logging.Error("Agent not found", "agent_id", agentID)
915+ return ProviderConfig{}
474916 }
475−}
476917
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)
918+ var model PreferredModel
919+ switch agent.Model {
920+ case LargeModel:
921+ model = cfg.Models.Large
922+ case SmallModel:
923+ model = cfg.Models.Small
924+ default:
925+ logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
926+ model = cfg.Models.Large // Fallback to large model
927+ }
485928
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
929+ providerConfig, ok := cfg.Providers[model.Provider]
930+ if !ok {
931+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
932+ return ProviderConfig{}
493933 }
494934
495− // Check if provider for the model is configured
496− provider := model.Provider
497− providerCfg, providerExists := cfg.Providers[provider]
935+ return providerConfig
936+}
498937
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)
938+func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
939+ cfg := Get()
940+ providerConfig, ok := cfg.Providers[provider]
941+ if !ok {
942+ logging.Error("Provider not found", "provider", provider)
943+ return Model{}
944+ }
507945
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)
513− }
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)
946+ for _, model := range providerConfig.Models {
947+ if model.ID == modelID {
948+ return model
533949 }
534950 }
535951
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)
952+ logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
953+ return Model{}
954+}
542955
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)
956+func GetModel(modelType ModelType) Model {
957+ cfg := Get()
958+ var model PreferredModel
959+ switch modelType {
960+ case LargeModel:
961+ model = cfg.Models.Large
962+ case SmallModel:
963+ model = cfg.Models.Small
964+ default:
965+ model = cfg.Models.Large // Fallback to large model
966+ }
967+ providerConfig, ok := cfg.Providers[model.Provider]
968+ if !ok {
969+ return Model{}
970+ }
585971
586− // Update the agent with valid reasoning effort
587− updatedAgent := cfg.Agents[name]
588− updatedAgent.ReasoningEffort = "medium"
589− cfg.Agents[name] = updatedAgent
590− }
972+ for _, m := range providerConfig.Models {
973+ if m.ID == model.ModelID {
974+ return m
591975 }
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
603976 }
977+ return Model{}
978+}
604979
980+func UpdatePreferredModel(modelType ModelType, model PreferredModel) error {
981+ cfg := Get()
982+ switch modelType {
983+ case LargeModel:
984+ cfg.Models.Large = model
985+ case SmallModel:
986+ cfg.Models.Small = model
987+ default:
988+ return fmt.Errorf("unknown model type: %s", modelType)
989+ }
605990 return nil
606991}
607992
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")
993+// ValidationError represents a configuration validation error
994+type ValidationError struct {
995+ Field string
996+ Message string
997+}
998+
999+func (e ValidationError) Error() string {
1000+ return fmt.Sprintf("validation error in %s: %s", e.Field, e.Message)
1001+}
1002+
1003+// ValidationErrors represents multiple validation errors
1004+type ValidationErrors []ValidationError
1005+
1006+func (e ValidationErrors) Error() string {
1007+ if len(e) == 0 {
1008+ return "no validation errors"
1009+ }
1010+ if len(e) == 1 {
1011+ return e[0].Error()
6121012 }
6131013
614− // Validate agent models
615− for name, agent := range cfg.Agents {
616− if err := validateAgent(cfg, name, agent); err != nil {
617− return err
618− }
1014+ var messages []string
1015+ for _, err := range e {
1016+ messages = append(messages, err.Error())
6191017 }
1018+ return fmt.Sprintf("multiple validation errors: %s", strings.Join(messages, "; "))
1019+}
1020+
1021+// HasErrors returns true if there are any validation errors
1022+func (e ValidationErrors) HasErrors() bool {
1023+ return len(e) > 0
1024+}
1025+
1026+// Add appends a new validation error
1027+func (e *ValidationErrors) Add(field, message string) {
1028+ *e = append(*e, ValidationError{Field: field, Message: message})
1029+}
1030+
1031+// Validate performs comprehensive validation of the configuration
1032+func (c *Config) Validate() error {
1033+ var errors ValidationErrors
6201034
6211035 // 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
627− }
628− }
1036+ c.validateProviders(&errors)
1037+
1038+ // Validate models
1039+ c.validateModels(&errors)
1040+
1041+ // Validate agents
1042+ c.validateAgents(&errors)
1043+
1044+ // Validate options
1045+ c.validateOptions(&errors)
1046+
1047+ // Validate MCP configurations
1048+ c.validateMCPs(&errors)
6291049
6301050 // 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
636− }
1051+ c.validateLSPs(&errors)
1052+
1053+ // Validate cross-references
1054+ c.validateCrossReferences(&errors)
1055+
1056+ // Validate completeness
1057+ c.validateCompleteness(&errors)
1058+
1059+ if errors.HasErrors() {
1060+ return errors
6371061 }
6381062
6391063 return nil
6401064}
6411065
642−// getProviderAPIKey gets the API key for a provider from environment variables
643−func getProviderAPIKey(provider models.ModelProvider) 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
676− }
677− cfg.Agents[agent] = Agent{
678− Model: models.Claude37Sonnet,
679− MaxTokens: maxTokens,
680− }
681− return true
1066+// validateProviders validates all provider configurations
1067+func (c *Config) validateProviders(errors *ValidationErrors) {
1068+ if c.Providers == nil {
1069+ c.Providers = make(map[provider.InferenceProvider]ProviderConfig)
6821070 }
6831071
684− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
685− var model models.ModelID
686− maxTokens := int64(5000)
687− reasoningEffort := ""
1072+ knownProviders := provider.KnownProviders()
1073+ validTypes := []provider.Type{
1074+ provider.TypeOpenAI,
1075+ provider.TypeAnthropic,
1076+ provider.TypeGemini,
1077+ provider.TypeAzure,
1078+ provider.TypeBedrock,
1079+ provider.TypeVertexAI,
1080+ provider.TypeXAI,
1081+ }
6881082
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
697− }
1083+ for providerID, providerConfig := range c.Providers {
1084+ fieldPrefix := fmt.Sprintf("providers.%s", providerID)
6981085
699− // Check if model supports reasoning
700− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
701− reasoningEffort = "medium"
1086+ // Validate API key for non-disabled providers
1087+ if !providerConfig.Disabled && providerConfig.APIKey == "" {
1088+ // Special case for AWS Bedrock and VertexAI which may use other auth methods
1089+ if providerID != provider.InferenceProviderBedrock && providerID != provider.InferenceProviderVertexAI {
1090+ errors.Add(fieldPrefix+".api_key", "API key is required for non-disabled providers")
1091+ }
7021092 }
7031093
704− cfg.Agents[agent] = Agent{
705− Model: model,
706− MaxTokens: maxTokens,
707− ReasoningEffort: reasoningEffort,
1094+ // Validate provider type
1095+ validType := slices.Contains(validTypes, providerConfig.ProviderType)
1096+ if !validType {
1097+ errors.Add(fieldPrefix+".provider_type", fmt.Sprintf("invalid provider type: %s", providerConfig.ProviderType))
7081098 }
709− return true
710− }
7111099
712− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
713− var model models.ModelID
714− maxTokens := int64(5000)
715− reasoningEffort := ""
1100+ // Validate custom providers
1101+ isKnownProvider := slices.Contains(knownProviders, providerID)
7161102
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
1103+ if !isKnownProvider {
1104+ // Custom provider validation
1105+ if providerConfig.BaseURL == "" {
1106+ errors.Add(fieldPrefix+".base_url", "BaseURL is required for custom providers")
1107+ }
1108+ if providerConfig.ProviderType != provider.TypeOpenAI {
1109+ errors.Add(fieldPrefix+".provider_type", "custom providers currently only support OpenAI type")
1110+ }
7251111 }
7261112
727− // Check if model supports reasoning
728− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
729− reasoningEffort = "medium"
1113+ // Validate models
1114+ modelIDs := make(map[string]bool)
1115+ for i, model := range providerConfig.Models {
1116+ modelFieldPrefix := fmt.Sprintf("%s.models[%d]", fieldPrefix, i)
1117+
1118+ // Check for duplicate model IDs
1119+ if modelIDs[model.ID] {
1120+ errors.Add(modelFieldPrefix+".id", fmt.Sprintf("duplicate model ID: %s", model.ID))
1121+ }
1122+ modelIDs[model.ID] = true
1123+
1124+ // Validate required model fields
1125+ if model.ID == "" {
1126+ errors.Add(modelFieldPrefix+".id", "model ID is required")
1127+ }
1128+ if model.Name == "" {
1129+ errors.Add(modelFieldPrefix+".name", "model name is required")
1130+ }
1131+ if model.ContextWindow <= 0 {
1132+ errors.Add(modelFieldPrefix+".context_window", "context window must be positive")
1133+ }
1134+ if model.DefaultMaxTokens <= 0 {
1135+ errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens must be positive")
1136+ }
1137+ if model.DefaultMaxTokens > model.ContextWindow {
1138+ errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens cannot exceed context window")
1139+ }
1140+
1141+ // Validate cost fields
1142+ if model.CostPer1MIn < 0 {
1143+ errors.Add(modelFieldPrefix+".cost_per_1m_in", "cost per 1M input tokens cannot be negative")
1144+ }
1145+ if model.CostPer1MOut < 0 {
1146+ errors.Add(modelFieldPrefix+".cost_per_1m_out", "cost per 1M output tokens cannot be negative")
1147+ }
1148+ if model.CostPer1MInCached < 0 {
1149+ errors.Add(modelFieldPrefix+".cost_per_1m_in_cached", "cached cost per 1M input tokens cannot be negative")
1150+ }
1151+ if model.CostPer1MOutCached < 0 {
1152+ errors.Add(modelFieldPrefix+".cost_per_1m_out_cached", "cached cost per 1M output tokens cannot be negative")
1153+ }
7301154 }
7311155
732− cfg.Agents[agent] = Agent{
733− Model: model,
734− MaxTokens: maxTokens,
735− ReasoningEffort: reasoningEffort,
1156+ // Validate default model references
1157+ if providerConfig.DefaultLargeModel != "" {
1158+ if !modelIDs[providerConfig.DefaultLargeModel] {
1159+ errors.Add(fieldPrefix+".default_large_model", fmt.Sprintf("default large model '%s' not found in provider models", providerConfig.DefaultLargeModel))
1160+ }
7361161 }
737− return true
1162+ if providerConfig.DefaultSmallModel != "" {
1163+ if !modelIDs[providerConfig.DefaultSmallModel] {
1164+ errors.Add(fieldPrefix+".default_small_model", fmt.Sprintf("default small model '%s' not found in provider models", providerConfig.DefaultSmallModel))
1165+ }
1166+ }
1167+
1168+ // Validate provider-specific requirements
1169+ c.validateProviderSpecific(providerID, providerConfig, errors)
7381170 }
1171+}
7391172
740− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
741− var model models.ModelID
742− maxTokens := int64(5000)
1173+// validateProviderSpecific validates provider-specific requirements
1174+func (c *Config) validateProviderSpecific(providerID provider.InferenceProvider, providerConfig ProviderConfig, errors *ValidationErrors) {
1175+ fieldPrefix := fmt.Sprintf("providers.%s", providerID)
7431176
744− if agent == AgentTitle {
745− model = models.Gemini25Flash
746− maxTokens = 80
747− } else {
748− model = models.Gemini25
1177+ switch providerID {
1178+ case provider.InferenceProviderVertexAI:
1179+ if !providerConfig.Disabled {
1180+ if providerConfig.ExtraParams == nil {
1181+ errors.Add(fieldPrefix+".extra_params", "VertexAI requires extra_params configuration")
1182+ } else {
1183+ if providerConfig.ExtraParams["project"] == "" {
1184+ errors.Add(fieldPrefix+".extra_params.project", "VertexAI requires project parameter")
1185+ }
1186+ if providerConfig.ExtraParams["location"] == "" {
1187+ errors.Add(fieldPrefix+".extra_params.location", "VertexAI requires location parameter")
1188+ }
1189+ }
7491190 }
750−
751− cfg.Agents[agent] = Agent{
752− Model: model,
753− MaxTokens: maxTokens,
1191+ case provider.InferenceProviderBedrock:
1192+ if !providerConfig.Disabled {
1193+ if providerConfig.ExtraParams == nil || providerConfig.ExtraParams["region"] == "" {
1194+ errors.Add(fieldPrefix+".extra_params.region", "Bedrock requires region parameter")
1195+ }
1196+ // Check for AWS credentials in environment
1197+ if !hasAWSCredentials() {
1198+ errors.Add(fieldPrefix, "Bedrock requires AWS credentials in environment")
1199+ }
7541200 }
755− return true
7561201 }
1202+}
7571203
758− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
759− maxTokens := int64(5000)
760− if agent == AgentTitle {
761− maxTokens = 80
1204+// validateModels validates preferred model configurations
1205+func (c *Config) validateModels(errors *ValidationErrors) {
1206+ // Validate large model
1207+ if c.Models.Large.ModelID != "" || c.Models.Large.Provider != "" {
1208+ if c.Models.Large.ModelID == "" {
1209+ errors.Add("models.large.model_id", "large model ID is required when provider is set")
1210+ }
1211+ if c.Models.Large.Provider == "" {
1212+ errors.Add("models.large.provider", "large model provider is required when model ID is set")
7621213 }
7631214
764− cfg.Agents[agent] = Agent{
765− Model: models.QWENQwq,
766− MaxTokens: maxTokens,
1215+ // Check if provider exists and is not disabled
1216+ if providerConfig, exists := c.Providers[c.Models.Large.Provider]; exists {
1217+ if providerConfig.Disabled {
1218+ errors.Add("models.large.provider", "large model provider is disabled")
1219+ }
1220+
1221+ // Check if model exists in provider
1222+ modelExists := false
1223+ for _, model := range providerConfig.Models {
1224+ if model.ID == c.Models.Large.ModelID {
1225+ modelExists = true
1226+ break
1227+ }
1228+ }
1229+ if !modelExists {
1230+ errors.Add("models.large.model_id", fmt.Sprintf("large model '%s' not found in provider '%s'", c.Models.Large.ModelID, c.Models.Large.Provider))
1231+ }
1232+ } else {
1233+ errors.Add("models.large.provider", fmt.Sprintf("large model provider '%s' not found", c.Models.Large.Provider))
7671234 }
768− return true
7691235 }
7701236
771− if hasAWSCredentials() {
772− maxTokens := int64(5000)
773− if agent == AgentTitle {
774− maxTokens = 80
1237+ // Validate small model
1238+ if c.Models.Small.ModelID != "" || c.Models.Small.Provider != "" {
1239+ if c.Models.Small.ModelID == "" {
1240+ errors.Add("models.small.model_id", "small model ID is required when provider is set")
7751241 }
1242+ if c.Models.Small.Provider == "" {
1243+ errors.Add("models.small.provider", "small model provider is required when model ID is set")
1244+ }
1245+
1246+ // Check if provider exists and is not disabled
1247+ if providerConfig, exists := c.Providers[c.Models.Small.Provider]; exists {
1248+ if providerConfig.Disabled {
1249+ errors.Add("models.small.provider", "small model provider is disabled")
1250+ }
7761251
777− cfg.Agents[agent] = Agent{
778− Model: models.BedrockClaude37Sonnet,
779− MaxTokens: maxTokens,
780− ReasoningEffort: "medium", // Claude models support reasoning
1252+ // Check if model exists in provider
1253+ modelExists := false
1254+ for _, model := range providerConfig.Models {
1255+ if model.ID == c.Models.Small.ModelID {
1256+ modelExists = true
1257+ break
1258+ }
1259+ }
1260+ if !modelExists {
1261+ errors.Add("models.small.model_id", fmt.Sprintf("small model '%s' not found in provider '%s'", c.Models.Small.ModelID, c.Models.Small.Provider))
1262+ }
1263+ } else {
1264+ errors.Add("models.small.provider", fmt.Sprintf("small model provider '%s' not found", c.Models.Small.Provider))
7811265 }
782− return true
1266+ }
1267+}
1268+
1269+// validateAgents validates agent configurations
1270+func (c *Config) validateAgents(errors *ValidationErrors) {
1271+ if c.Agents == nil {
1272+ c.Agents = make(map[AgentID]Agent)
7831273 }
7841274
785− if hasVertexAICredentials() {
786− var model models.ModelID
787− maxTokens := int64(5000)
1275+ validTools := []string{
1276+ "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
1277+ }
7881278
789− if agent == AgentTitle {
790− model = models.VertexAIGemini25Flash
791− maxTokens = 80
792− } else {
793− model = models.VertexAIGemini25
1279+ for agentID, agent := range c.Agents {
1280+ fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1281+
1282+ // Validate agent ID consistency
1283+ if agent.ID != agentID {
1284+ errors.Add(fieldPrefix+".id", fmt.Sprintf("agent ID mismatch: expected '%s', got '%s'", agentID, agent.ID))
7941285 }
7951286
796− cfg.Agents[agent] = Agent{
797− Model: model,
798− MaxTokens: maxTokens,
1287+ // Validate required fields
1288+ if agent.ID == "" {
1289+ errors.Add(fieldPrefix+".id", "agent ID is required")
1290+ }
1291+ if agent.Name == "" {
1292+ errors.Add(fieldPrefix+".name", "agent name is required")
7991293 }
800− return true
801− }
8021294
803− return false
804−}
1295+ // Validate model type
1296+ if agent.Model != LargeModel && agent.Model != SmallModel {
1297+ errors.Add(fieldPrefix+".model", fmt.Sprintf("invalid model type: %s (must be 'large' or 'small')", agent.Model))
1298+ }
8051299
806−func updateCfgFile(updateCfg func(config *Config)) error {
807− if cfg == nil {
808− return fmt.Errorf("config not loaded")
809− }
1300+ // Validate allowed tools
1301+ if agent.AllowedTools != nil {
1302+ for i, tool := range agent.AllowedTools {
1303+ validTool := slices.Contains(validTools, tool)
1304+ if !validTool {
1305+ errors.Add(fmt.Sprintf("%s.allowed_tools[%d]", fieldPrefix, i), fmt.Sprintf("unknown tool: %s", tool))
1306+ }
1307+ }
1308+ }
8101309
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)
1310+ // Validate MCP references
1311+ if agent.AllowedMCP != nil {
1312+ for mcpName := range agent.AllowedMCP {
1313+ if _, exists := c.MCP[mcpName]; !exists {
1314+ errors.Add(fieldPrefix+".allowed_mcp", fmt.Sprintf("referenced MCP '%s' not found", mcpName))
1315+ }
1316+ }
8181317 }
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)
1318+
1319+ // Validate LSP references
1320+ if agent.AllowedLSP != nil {
1321+ for _, lspName := range agent.AllowedLSP {
1322+ if _, exists := c.LSP[lspName]; !exists {
1323+ errors.Add(fieldPrefix+".allowed_lsp", fmt.Sprintf("referenced LSP '%s' not found", lspName))
1324+ }
1325+ }
1326+ }
1327+
1328+ // Validate context paths (basic path validation)
1329+ for i, contextPath := range agent.ContextPaths {
1330+ if contextPath == "" {
1331+ errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path cannot be empty")
1332+ }
1333+ // Check for invalid characters in path
1334+ if strings.Contains(contextPath, "\x00") {
1335+ errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path contains invalid characters")
1336+ }
8271337 }
828− configData = data
829− }
8301338
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)
1339+ // Validate known agents maintain their core properties
1340+ if agentID == AgentCoder {
1341+ if agent.Name != "Coder" {
1342+ errors.Add(fieldPrefix+".name", "coder agent name cannot be changed")
1343+ }
1344+ if agent.Description != "An agent that helps with executing coding tasks." {
1345+ errors.Add(fieldPrefix+".description", "coder agent description cannot be changed")
1346+ }
1347+ } else if agentID == AgentTask {
1348+ if agent.Name != "Task" {
1349+ errors.Add(fieldPrefix+".name", "task agent name cannot be changed")
1350+ }
1351+ if agent.Description != "An agent that helps with searching for context and finding implementation details." {
1352+ errors.Add(fieldPrefix+".description", "task agent description cannot be changed")
1353+ }
1354+ expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1355+ if agent.AllowedTools != nil && !slices.Equal(agent.AllowedTools, expectedTools) {
1356+ errors.Add(fieldPrefix+".allowed_tools", "task agent allowed tools cannot be changed")
1357+ }
1358+ }
8351359 }
1360+}
8361361
837− updateCfg(userCfg)
1362+// validateOptions validates configuration options
1363+func (c *Config) validateOptions(errors *ValidationErrors) {
1364+ // Validate data directory
1365+ if c.Options.DataDirectory == "" {
1366+ errors.Add("options.data_directory", "data directory is required")
1367+ }
8381368
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)
1369+ // Validate context paths
1370+ for i, contextPath := range c.Options.ContextPaths {
1371+ if contextPath == "" {
1372+ errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path cannot be empty")
1373+ }
1374+ if strings.Contains(contextPath, "\x00") {
1375+ errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path contains invalid characters")
1376+ }
8431377 }
1378+}
8441379
845− if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
846− return fmt.Errorf("failed to write config file: %w", err)
1380+// validateMCPs validates MCP configurations
1381+func (c *Config) validateMCPs(errors *ValidationErrors) {
1382+ if c.MCP == nil {
1383+ c.MCP = make(map[string]MCP)
8471384 }
8481385
849− return nil
850−}
1386+ for mcpName, mcpConfig := range c.MCP {
1387+ fieldPrefix := fmt.Sprintf("mcp.%s", mcpName)
8511388
852−// Get returns the current configuration.
853−// It's safe to call this function multiple times.
854−func Get() *Config {
855− return cfg
856−}
1389+ // Validate MCP type
1390+ if mcpConfig.Type != MCPStdio && mcpConfig.Type != MCPSse {
1391+ errors.Add(fieldPrefix+".type", fmt.Sprintf("invalid MCP type: %s (must be 'stdio' or 'sse')", mcpConfig.Type))
1392+ }
8571393
858−// WorkingDirectory returns the current working directory from the configuration.
859−func WorkingDirectory() string {
860− if cfg == nil {
861− panic("config not loaded")
1394+ // Validate based on type
1395+ if mcpConfig.Type == MCPStdio {
1396+ if mcpConfig.Command == "" {
1397+ errors.Add(fieldPrefix+".command", "command is required for stdio MCP")
1398+ }
1399+ } else if mcpConfig.Type == MCPSse {
1400+ if mcpConfig.URL == "" {
1401+ errors.Add(fieldPrefix+".url", "URL is required for SSE MCP")
1402+ }
1403+ }
8621404 }
863− return cfg.WorkingDir
8641405}
8651406
866−func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
867− if cfg == nil {
868− panic("config not loaded")
1407+// validateLSPs validates LSP configurations
1408+func (c *Config) validateLSPs(errors *ValidationErrors) {
1409+ if c.LSP == nil {
1410+ c.LSP = make(map[string]LSPConfig)
8691411 }
8701412
871− existingAgentCfg := cfg.Agents[agentName]
1413+ for lspName, lspConfig := range c.LSP {
1414+ fieldPrefix := fmt.Sprintf("lsp.%s", lspName)
8721415
873− model, ok := models.SupportedModels[modelID]
874− if !ok {
875− return fmt.Errorf("model %s not supported", modelID)
1416+ if lspConfig.Command == "" {
1417+ errors.Add(fieldPrefix+".command", "command is required for LSP")
1418+ }
8761419 }
1420+}
1421+
1422+// validateCrossReferences validates cross-references between different config sections
1423+func (c *Config) validateCrossReferences(errors *ValidationErrors) {
1424+ // Validate that agents can use their assigned model types
1425+ for agentID, agent := range c.Agents {
1426+ fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1427+
1428+ var preferredModel PreferredModel
1429+ switch agent.Model {
1430+ case LargeModel:
1431+ preferredModel = c.Models.Large
1432+ case SmallModel:
1433+ preferredModel = c.Models.Small
1434+ }
8771435
878− maxTokens := existingAgentCfg.MaxTokens
879− if model.DefaultMaxTokens > 0 {
880− maxTokens = model.DefaultMaxTokens
1436+ if preferredModel.Provider != "" {
1437+ if providerConfig, exists := c.Providers[preferredModel.Provider]; exists {
1438+ if providerConfig.Disabled {
1439+ errors.Add(fieldPrefix+".model", fmt.Sprintf("agent cannot use model type '%s' because provider '%s' is disabled", agent.Model, preferredModel.Provider))
1440+ }
1441+ }
1442+ }
8811443 }
1444+}
8821445
883− newAgentCfg := Agent{
884− Model: modelID,
885− MaxTokens: maxTokens,
886− ReasoningEffort: existingAgentCfg.ReasoningEffort,
1446+// validateCompleteness validates that the configuration is complete and usable
1447+func (c *Config) validateCompleteness(errors *ValidationErrors) {
1448+ // Check for at least one valid, non-disabled provider
1449+ hasValidProvider := false
1450+ for _, providerConfig := range c.Providers {
1451+ if !providerConfig.Disabled {
1452+ hasValidProvider = true
1453+ break
1454+ }
1455+ }
1456+ if !hasValidProvider {
1457+ errors.Add("providers", "at least one non-disabled provider is required")
8871458 }
888− cfg.Agents[agentName] = newAgentCfg
8891459
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)
1460+ // Check that default agents exist
1461+ if _, exists := c.Agents[AgentCoder]; !exists {
1462+ errors.Add("agents", "coder agent is required")
1463+ }
1464+ if _, exists := c.Agents[AgentTask]; !exists {
1465+ errors.Add("agents", "task agent is required")
8941466 }
8951467
896− return updateCfgFile(func(config *Config) {
897− if config.Agents == nil {
898− config.Agents = make(map[AgentName]Agent)
1468+ // Check that preferred models are set if providers exist
1469+ if hasValidProvider {
1470+ if c.Models.Large.ModelID == "" || c.Models.Large.Provider == "" {
1471+ errors.Add("models.large", "large preferred model must be configured when providers are available")
8991472 }
900− config.Agents[agentName] = newAgentCfg
901− })
1473+ if c.Models.Small.ModelID == "" || c.Models.Small.Provider == "" {
1474+ errors.Add("models.small", "small preferred model must be configured when providers are available")
1475+ }
1476+ }
9021477}
9031478
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")
1479+// JSONSchemaExtend adds custom schema properties for AgentID
1480+func (AgentID) JSONSchemaExtend(schema *jsonschema.Schema) {
1481+ schema.Enum = []any{
1482+ string(AgentCoder),
1483+ string(AgentTask),
9081484 }
1485+}
9091486
910− // Update the in-memory config
911− cfg.TUI.Theme = themeName
1487+// JSONSchemaExtend adds custom schema properties for ModelType
1488+func (ModelType) JSONSchemaExtend(schema *jsonschema.Schema) {
1489+ schema.Enum = []any{
1490+ string(LargeModel),
1491+ string(SmallModel),
1492+ }
1493+}
9121494
913− // Update the file config
914− return updateCfgFile(func(config *Config) {
915− config.TUI.Theme = themeName
916− })
1495+// JSONSchemaExtend adds custom schema properties for MCPType
1496+func (MCPType) JSONSchemaExtend(schema *jsonschema.Schema) {
1497+ schema.Enum = []any{
1498+ string(MCPStdio),
1499+ string(MCPSse),
1500+ }
9171501}
internal/config/config_test.gonew file+2079
@@ -0,0 +1,2079 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "sync"
8+ "testing"
9+
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func reset() {
16+ // Clear all environment variables that could affect config
17+ envVarsToUnset := []string{
18+ // API Keys
19+ "ANTHROPIC_API_KEY",
20+ "OPENAI_API_KEY",
21+ "GEMINI_API_KEY",
22+ "XAI_API_KEY",
23+ "OPENROUTER_API_KEY",
24+
25+ // Google Cloud / VertexAI
26+ "GOOGLE_GENAI_USE_VERTEXAI",
27+ "GOOGLE_CLOUD_PROJECT",
28+ "GOOGLE_CLOUD_LOCATION",
29+
30+ // AWS Credentials
31+ "AWS_ACCESS_KEY_ID",
32+ "AWS_SECRET_ACCESS_KEY",
33+ "AWS_REGION",
34+ "AWS_DEFAULT_REGION",
35+ "AWS_PROFILE",
36+ "AWS_DEFAULT_PROFILE",
37+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
38+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
39+
40+ // Other
41+ "CRUSH_DEV_DEBUG",
42+ }
43+
44+ for _, envVar := range envVarsToUnset {
45+ os.Unsetenv(envVar)
46+ }
47+
48+ // Reset singleton
49+ once = sync.Once{}
50+ instance = nil
51+ cwd = ""
52+ testConfigDir = ""
53+
54+ // Enable mock providers for all tests to avoid API calls
55+ UseMockProviders = true
56+ ResetProviders()
57+}
58+
59+// Core Configuration Loading Tests
60+
61+func TestInit_ValidWorkingDirectory(t *testing.T) {
62+ reset()
63+ testConfigDir = t.TempDir()
64+ cwdDir := t.TempDir()
65+
66+ cfg, err := Init(cwdDir, false)
67+
68+ require.NoError(t, err)
69+ assert.NotNil(t, cfg)
70+ assert.Equal(t, cwdDir, WorkingDirectory())
71+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
72+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
73+}
74+
75+func TestInit_WithDebugFlag(t *testing.T) {
76+ reset()
77+ testConfigDir = t.TempDir()
78+ cwdDir := t.TempDir()
79+
80+ cfg, err := Init(cwdDir, true)
81+
82+ require.NoError(t, err)
83+ assert.True(t, cfg.Options.Debug)
84+}
85+
86+func TestInit_SingletonBehavior(t *testing.T) {
87+ reset()
88+ testConfigDir = t.TempDir()
89+ cwdDir := t.TempDir()
90+
91+ cfg1, err1 := Init(cwdDir, false)
92+ cfg2, err2 := Init(cwdDir, false)
93+
94+ require.NoError(t, err1)
95+ require.NoError(t, err2)
96+ assert.Same(t, cfg1, cfg2)
97+}
98+
99+func TestGet_BeforeInitialization(t *testing.T) {
100+ reset()
101+
102+ assert.Panics(t, func() {
103+ Get()
104+ })
105+}
106+
107+func TestGet_AfterInitialization(t *testing.T) {
108+ reset()
109+ testConfigDir = t.TempDir()
110+ cwdDir := t.TempDir()
111+
112+ cfg1, err := Init(cwdDir, false)
113+ require.NoError(t, err)
114+
115+ cfg2 := Get()
116+ assert.Same(t, cfg1, cfg2)
117+}
118+
119+func TestLoadConfig_NoConfigFiles(t *testing.T) {
120+ reset()
121+ testConfigDir = t.TempDir()
122+ cwdDir := t.TempDir()
123+
124+ cfg, err := Init(cwdDir, false)
125+
126+ require.NoError(t, err)
127+ assert.Len(t, cfg.Providers, 0)
128+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
129+}
130+
131+func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
132+ reset()
133+ testConfigDir = t.TempDir()
134+ cwdDir := t.TempDir()
135+
136+ globalConfig := Config{
137+ Providers: map[provider.InferenceProvider]ProviderConfig{
138+ provider.InferenceProviderOpenAI: {
139+ ID: provider.InferenceProviderOpenAI,
140+ APIKey: "test-key",
141+ ProviderType: provider.TypeOpenAI,
142+ DefaultLargeModel: "gpt-4",
143+ DefaultSmallModel: "gpt-3.5-turbo",
144+ Models: []Model{
145+ {
146+ ID: "gpt-4",
147+ Name: "GPT-4",
148+ CostPer1MIn: 30.0,
149+ CostPer1MOut: 60.0,
150+ ContextWindow: 8192,
151+ DefaultMaxTokens: 4096,
152+ },
153+ {
154+ ID: "gpt-3.5-turbo",
155+ Name: "GPT-3.5 Turbo",
156+ CostPer1MIn: 1.0,
157+ CostPer1MOut: 2.0,
158+ ContextWindow: 4096,
159+ DefaultMaxTokens: 4096,
160+ },
161+ },
162+ },
163+ },
164+ Options: Options{
165+ ContextPaths: []string{"custom-context.md"},
166+ },
167+ }
168+
169+ configPath := filepath.Join(testConfigDir, "crush.json")
170+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
171+
172+ data, err := json.Marshal(globalConfig)
173+ require.NoError(t, err)
174+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
175+
176+ cfg, err := Init(cwdDir, false)
177+
178+ require.NoError(t, err)
179+ assert.Len(t, cfg.Providers, 1)
180+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
181+ assert.Contains(t, cfg.Options.ContextPaths, "custom-context.md")
182+}
183+
184+func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
185+ reset()
186+ testConfigDir = t.TempDir()
187+ cwdDir := t.TempDir()
188+
189+ localConfig := Config{
190+ Providers: map[provider.InferenceProvider]ProviderConfig{
191+ provider.InferenceProviderAnthropic: {
192+ ID: provider.InferenceProviderAnthropic,
193+ APIKey: "local-key",
194+ ProviderType: provider.TypeAnthropic,
195+ DefaultLargeModel: "claude-3-opus",
196+ DefaultSmallModel: "claude-3-haiku",
197+ Models: []Model{
198+ {
199+ ID: "claude-3-opus",
200+ Name: "Claude 3 Opus",
201+ CostPer1MIn: 15.0,
202+ CostPer1MOut: 75.0,
203+ ContextWindow: 200000,
204+ DefaultMaxTokens: 4096,
205+ },
206+ {
207+ ID: "claude-3-haiku",
208+ Name: "Claude 3 Haiku",
209+ CostPer1MIn: 0.25,
210+ CostPer1MOut: 1.25,
211+ ContextWindow: 200000,
212+ DefaultMaxTokens: 4096,
213+ },
214+ },
215+ },
216+ },
217+ Options: Options{
218+ TUI: TUIOptions{CompactMode: true},
219+ },
220+ }
221+
222+ localConfigPath := filepath.Join(cwdDir, "crush.json")
223+ data, err := json.Marshal(localConfig)
224+ require.NoError(t, err)
225+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
226+
227+ cfg, err := Init(cwdDir, false)
228+
229+ require.NoError(t, err)
230+ assert.Len(t, cfg.Providers, 1)
231+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
232+ assert.True(t, cfg.Options.TUI.CompactMode)
233+}
234+
235+func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
236+ reset()
237+ testConfigDir = t.TempDir()
238+ cwdDir := t.TempDir()
239+
240+ globalConfig := Config{
241+ Providers: map[provider.InferenceProvider]ProviderConfig{
242+ provider.InferenceProviderOpenAI: {
243+ ID: provider.InferenceProviderOpenAI,
244+ APIKey: "global-key",
245+ ProviderType: provider.TypeOpenAI,
246+ DefaultLargeModel: "gpt-4",
247+ DefaultSmallModel: "gpt-3.5-turbo",
248+ Models: []Model{
249+ {
250+ ID: "gpt-4",
251+ Name: "GPT-4",
252+ CostPer1MIn: 30.0,
253+ CostPer1MOut: 60.0,
254+ ContextWindow: 8192,
255+ DefaultMaxTokens: 4096,
256+ },
257+ {
258+ ID: "gpt-3.5-turbo",
259+ Name: "GPT-3.5 Turbo",
260+ CostPer1MIn: 1.0,
261+ CostPer1MOut: 2.0,
262+ ContextWindow: 4096,
263+ DefaultMaxTokens: 4096,
264+ },
265+ },
266+ },
267+ },
268+ Options: Options{
269+ ContextPaths: []string{"global-context.md"},
270+ },
271+ }
272+
273+ configPath := filepath.Join(testConfigDir, "crush.json")
274+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
275+ data, err := json.Marshal(globalConfig)
276+ require.NoError(t, err)
277+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
278+
279+ localConfig := Config{
280+ Providers: map[provider.InferenceProvider]ProviderConfig{
281+ provider.InferenceProviderOpenAI: {
282+ APIKey: "local-key", // Override global
283+ },
284+ provider.InferenceProviderAnthropic: {
285+ ID: provider.InferenceProviderAnthropic,
286+ APIKey: "anthropic-key",
287+ ProviderType: provider.TypeAnthropic,
288+ DefaultLargeModel: "claude-3-opus",
289+ DefaultSmallModel: "claude-3-haiku",
290+ Models: []Model{
291+ {
292+ ID: "claude-3-opus",
293+ Name: "Claude 3 Opus",
294+ CostPer1MIn: 15.0,
295+ CostPer1MOut: 75.0,
296+ ContextWindow: 200000,
297+ DefaultMaxTokens: 4096,
298+ },
299+ {
300+ ID: "claude-3-haiku",
301+ Name: "Claude 3 Haiku",
302+ CostPer1MIn: 0.25,
303+ CostPer1MOut: 1.25,
304+ ContextWindow: 200000,
305+ DefaultMaxTokens: 4096,
306+ },
307+ },
308+ },
309+ },
310+ Options: Options{
311+ ContextPaths: []string{"local-context.md"},
312+ TUI: TUIOptions{CompactMode: true},
313+ },
314+ }
315+
316+ localConfigPath := filepath.Join(cwdDir, "crush.json")
317+ data, err = json.Marshal(localConfig)
318+ require.NoError(t, err)
319+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
320+
321+ cfg, err := Init(cwdDir, false)
322+
323+ require.NoError(t, err)
324+ assert.Len(t, cfg.Providers, 2)
325+
326+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
327+ assert.Equal(t, "local-key", openaiProvider.APIKey)
328+
329+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
330+
331+ assert.Contains(t, cfg.Options.ContextPaths, "global-context.md")
332+ assert.Contains(t, cfg.Options.ContextPaths, "local-context.md")
333+ assert.True(t, cfg.Options.TUI.CompactMode)
334+}
335+
336+func TestLoadConfig_MalformedGlobalJSON(t *testing.T) {
337+ reset()
338+ testConfigDir = t.TempDir()
339+ cwdDir := t.TempDir()
340+
341+ configPath := filepath.Join(testConfigDir, "crush.json")
342+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
343+ require.NoError(t, os.WriteFile(configPath, []byte(`{invalid json`), 0o644))
344+
345+ _, err := Init(cwdDir, false)
346+ assert.Error(t, err)
347+}
348+
349+func TestLoadConfig_MalformedLocalJSON(t *testing.T) {
350+ reset()
351+ testConfigDir = t.TempDir()
352+ cwdDir := t.TempDir()
353+
354+ localConfigPath := filepath.Join(cwdDir, "crush.json")
355+ require.NoError(t, os.WriteFile(localConfigPath, []byte(`{invalid json`), 0o644))
356+
357+ _, err := Init(cwdDir, false)
358+ assert.Error(t, err)
359+}
360+
361+func TestConfigWithoutEnv(t *testing.T) {
362+ reset()
363+ testConfigDir = t.TempDir()
364+ cwdDir := t.TempDir()
365+
366+ cfg, _ := Init(cwdDir, false)
367+ assert.Len(t, cfg.Providers, 0)
368+}
369+
370+func TestConfigWithEnv(t *testing.T) {
371+ reset()
372+ testConfigDir = t.TempDir()
373+ cwdDir := t.TempDir()
374+
375+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
376+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
377+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
378+ os.Setenv("XAI_API_KEY", "test-xai-key")
379+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
380+
381+ cfg, _ := Init(cwdDir, false)
382+ assert.Len(t, cfg.Providers, 5)
383+}
384+
385+// Environment Variable Tests
386+
387+func TestEnvVars_NoEnvironmentVariables(t *testing.T) {
388+ reset()
389+ testConfigDir = t.TempDir()
390+ cwdDir := t.TempDir()
391+
392+ cfg, err := Init(cwdDir, false)
393+
394+ require.NoError(t, err)
395+ assert.Len(t, cfg.Providers, 0)
396+}
397+
398+func TestEnvVars_AllSupportedAPIKeys(t *testing.T) {
399+ reset()
400+ testConfigDir = t.TempDir()
401+ cwdDir := t.TempDir()
402+
403+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
404+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
405+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
406+ os.Setenv("XAI_API_KEY", "test-xai-key")
407+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
408+
409+ cfg, err := Init(cwdDir, false)
410+
411+ require.NoError(t, err)
412+ assert.Len(t, cfg.Providers, 5)
413+
414+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
415+ assert.Equal(t, "test-anthropic-key", anthropicProvider.APIKey)
416+ assert.Equal(t, provider.TypeAnthropic, anthropicProvider.ProviderType)
417+
418+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
419+ assert.Equal(t, "test-openai-key", openaiProvider.APIKey)
420+ assert.Equal(t, provider.TypeOpenAI, openaiProvider.ProviderType)
421+
422+ geminiProvider := cfg.Providers[provider.InferenceProviderGemini]
423+ assert.Equal(t, "test-gemini-key", geminiProvider.APIKey)
424+ assert.Equal(t, provider.TypeGemini, geminiProvider.ProviderType)
425+
426+ xaiProvider := cfg.Providers[provider.InferenceProviderXAI]
427+ assert.Equal(t, "test-xai-key", xaiProvider.APIKey)
428+ assert.Equal(t, provider.TypeXAI, xaiProvider.ProviderType)
429+
430+ openrouterProvider := cfg.Providers[provider.InferenceProviderOpenRouter]
431+ assert.Equal(t, "test-openrouter-key", openrouterProvider.APIKey)
432+ assert.Equal(t, provider.TypeOpenAI, openrouterProvider.ProviderType)
433+ assert.Equal(t, "https://openrouter.ai/api/v1", openrouterProvider.BaseURL)
434+}
435+
436+func TestEnvVars_PartialEnvironmentVariables(t *testing.T) {
437+ reset()
438+ testConfigDir = t.TempDir()
439+ cwdDir := t.TempDir()
440+
441+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
442+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
443+
444+ cfg, err := Init(cwdDir, false)
445+
446+ require.NoError(t, err)
447+ assert.Len(t, cfg.Providers, 2)
448+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
449+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
450+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderGemini)
451+}
452+
453+func TestEnvVars_VertexAIConfiguration(t *testing.T) {
454+ reset()
455+ testConfigDir = t.TempDir()
456+ cwdDir := t.TempDir()
457+
458+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
459+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
460+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
461+
462+ cfg, err := Init(cwdDir, false)
463+
464+ require.NoError(t, err)
465+ assert.Contains(t, cfg.Providers, provider.InferenceProviderVertexAI)
466+
467+ vertexProvider := cfg.Providers[provider.InferenceProviderVertexAI]
468+ assert.Equal(t, provider.TypeVertexAI, vertexProvider.ProviderType)
469+ assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
470+ assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
471+}
472+
473+func TestEnvVars_VertexAIWithoutUseFlag(t *testing.T) {
474+ reset()
475+ testConfigDir = t.TempDir()
476+ cwdDir := t.TempDir()
477+
478+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
479+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
480+
481+ cfg, err := Init(cwdDir, false)
482+
483+ require.NoError(t, err)
484+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderVertexAI)
485+}
486+
487+func TestEnvVars_AWSBedrockWithAccessKeys(t *testing.T) {
488+ reset()
489+ testConfigDir = t.TempDir()
490+ cwdDir := t.TempDir()
491+
492+ os.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
493+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
494+ os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
495+
496+ cfg, err := Init(cwdDir, false)
497+
498+ require.NoError(t, err)
499+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
500+
501+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
502+ assert.Equal(t, provider.TypeBedrock, bedrockProvider.ProviderType)
503+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
504+}
505+
506+func TestEnvVars_AWSBedrockWithProfile(t *testing.T) {
507+ reset()
508+ testConfigDir = t.TempDir()
509+ cwdDir := t.TempDir()
510+
511+ os.Setenv("AWS_PROFILE", "test-profile")
512+ os.Setenv("AWS_REGION", "eu-west-1")
513+
514+ cfg, err := Init(cwdDir, false)
515+
516+ require.NoError(t, err)
517+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
518+
519+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
520+ assert.Equal(t, "eu-west-1", bedrockProvider.ExtraParams["region"])
521+}
522+
523+func TestEnvVars_AWSBedrockWithContainerCredentials(t *testing.T) {
524+ reset()
525+ testConfigDir = t.TempDir()
526+ cwdDir := t.TempDir()
527+
528+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
529+ os.Setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
530+
531+ cfg, err := Init(cwdDir, false)
532+
533+ require.NoError(t, err)
534+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
535+}
536+
537+func TestEnvVars_AWSBedrockRegionPriority(t *testing.T) {
538+ reset()
539+ testConfigDir = t.TempDir()
540+ cwdDir := t.TempDir()
541+
542+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
543+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
544+ os.Setenv("AWS_DEFAULT_REGION", "us-west-2")
545+ os.Setenv("AWS_REGION", "us-east-1")
546+
547+ cfg, err := Init(cwdDir, false)
548+
549+ require.NoError(t, err)
550+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
551+ assert.Equal(t, "us-west-2", bedrockProvider.ExtraParams["region"])
552+}
553+
554+func TestEnvVars_AWSBedrockFallbackRegion(t *testing.T) {
555+ reset()
556+ testConfigDir = t.TempDir()
557+ cwdDir := t.TempDir()
558+
559+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
560+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
561+ os.Setenv("AWS_REGION", "us-east-1")
562+
563+ cfg, err := Init(cwdDir, false)
564+
565+ require.NoError(t, err)
566+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
567+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
568+}
569+
570+func TestEnvVars_NoAWSCredentials(t *testing.T) {
571+ reset()
572+ testConfigDir = t.TempDir()
573+ cwdDir := t.TempDir()
574+
575+ cfg, err := Init(cwdDir, false)
576+
577+ require.NoError(t, err)
578+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderBedrock)
579+}
580+
581+func TestEnvVars_CustomEnvironmentVariables(t *testing.T) {
582+ reset()
583+ testConfigDir = t.TempDir()
584+ cwdDir := t.TempDir()
585+
586+ os.Setenv("ANTHROPIC_API_KEY", "resolved-anthropic-key")
587+
588+ cfg, err := Init(cwdDir, false)
589+
590+ require.NoError(t, err)
591+ if len(cfg.Providers) > 0 {
592+ if anthropicProvider, exists := cfg.Providers[provider.InferenceProviderAnthropic]; exists {
593+ assert.Equal(t, "resolved-anthropic-key", anthropicProvider.APIKey)
594+ }
595+ }
596+}
597+
598+func TestEnvVars_CombinedEnvironmentVariables(t *testing.T) {
599+ reset()
600+ testConfigDir = t.TempDir()
601+ cwdDir := t.TempDir()
602+
603+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic")
604+ os.Setenv("OPENAI_API_KEY", "test-openai")
605+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
606+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
607+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
608+ os.Setenv("AWS_ACCESS_KEY_ID", "test-aws-key")
609+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-aws-secret")
610+ os.Setenv("AWS_DEFAULT_REGION", "us-west-1")
611+
612+ cfg, err := Init(cwdDir, false)
613+
614+ require.NoError(t, err)
615+
616+ expectedProviders := []provider.InferenceProvider{
617+ provider.InferenceProviderAnthropic,
618+ provider.InferenceProviderOpenAI,
619+ provider.InferenceProviderVertexAI,
620+ provider.InferenceProviderBedrock,
621+ }
622+
623+ for _, expectedProvider := range expectedProviders {
624+ assert.Contains(t, cfg.Providers, expectedProvider)
625+ }
626+}
627+
628+func TestHasAWSCredentials_AccessKeys(t *testing.T) {
629+ reset()
630+
631+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
632+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
633+
634+ assert.True(t, hasAWSCredentials())
635+}
636+
637+func TestHasAWSCredentials_Profile(t *testing.T) {
638+ reset()
639+
640+ os.Setenv("AWS_PROFILE", "test-profile")
641+
642+ assert.True(t, hasAWSCredentials())
643+}
644+
645+func TestHasAWSCredentials_DefaultProfile(t *testing.T) {
646+ reset()
647+
648+ os.Setenv("AWS_DEFAULT_PROFILE", "default")
649+
650+ assert.True(t, hasAWSCredentials())
651+}
652+
653+func TestHasAWSCredentials_Region(t *testing.T) {
654+ reset()
655+
656+ os.Setenv("AWS_REGION", "us-east-1")
657+
658+ assert.True(t, hasAWSCredentials())
659+}
660+
661+func TestHasAWSCredentials_ContainerCredentials(t *testing.T) {
662+ reset()
663+
664+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
665+
666+ assert.True(t, hasAWSCredentials())
667+}
668+
669+func TestHasAWSCredentials_NoCredentials(t *testing.T) {
670+ reset()
671+
672+ assert.False(t, hasAWSCredentials())
673+}
674+
675+func TestProviderMerging_GlobalToBase(t *testing.T) {
676+ reset()
677+ testConfigDir = t.TempDir()
678+ cwdDir := t.TempDir()
679+
680+ globalConfig := Config{
681+ Providers: map[provider.InferenceProvider]ProviderConfig{
682+ provider.InferenceProviderOpenAI: {
683+ ID: provider.InferenceProviderOpenAI,
684+ APIKey: "global-openai-key",
685+ ProviderType: provider.TypeOpenAI,
686+ DefaultLargeModel: "gpt-4",
687+ DefaultSmallModel: "gpt-3.5-turbo",
688+ Models: []Model{
689+ {
690+ ID: "gpt-4",
691+ Name: "GPT-4",
692+ ContextWindow: 8192,
693+ DefaultMaxTokens: 4096,
694+ },
695+ {
696+ ID: "gpt-3.5-turbo",
697+ Name: "GPT-3.5 Turbo",
698+ ContextWindow: 4096,
699+ DefaultMaxTokens: 2048,
700+ },
701+ },
702+ },
703+ },
704+ }
705+
706+ configPath := filepath.Join(testConfigDir, "crush.json")
707+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
708+ data, err := json.Marshal(globalConfig)
709+ require.NoError(t, err)
710+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
711+
712+ cfg, err := Init(cwdDir, false)
713+
714+ require.NoError(t, err)
715+ assert.Len(t, cfg.Providers, 1)
716+
717+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
718+ assert.Equal(t, "global-openai-key", openaiProvider.APIKey)
719+ assert.Equal(t, "gpt-4", openaiProvider.DefaultLargeModel)
720+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
721+ assert.Len(t, openaiProvider.Models, 2)
722+}
723+
724+func TestProviderMerging_LocalToBase(t *testing.T) {
725+ reset()
726+ testConfigDir = t.TempDir()
727+ cwdDir := t.TempDir()
728+
729+ localConfig := Config{
730+ Providers: map[provider.InferenceProvider]ProviderConfig{
731+ provider.InferenceProviderAnthropic: {
732+ ID: provider.InferenceProviderAnthropic,
733+ APIKey: "local-anthropic-key",
734+ ProviderType: provider.TypeAnthropic,
735+ DefaultLargeModel: "claude-3-opus",
736+ DefaultSmallModel: "claude-3-haiku",
737+ Models: []Model{
738+ {
739+ ID: "claude-3-opus",
740+ Name: "Claude 3 Opus",
741+ ContextWindow: 200000,
742+ DefaultMaxTokens: 4096,
743+ CostPer1MIn: 15.0,
744+ CostPer1MOut: 75.0,
745+ },
746+ {
747+ ID: "claude-3-haiku",
748+ Name: "Claude 3 Haiku",
749+ ContextWindow: 200000,
750+ DefaultMaxTokens: 4096,
751+ CostPer1MIn: 0.25,
752+ CostPer1MOut: 1.25,
753+ },
754+ },
755+ },
756+ },
757+ }
758+
759+ localConfigPath := filepath.Join(cwdDir, "crush.json")
760+ data, err := json.Marshal(localConfig)
761+ require.NoError(t, err)
762+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
763+
764+ cfg, err := Init(cwdDir, false)
765+
766+ require.NoError(t, err)
767+ assert.Len(t, cfg.Providers, 1)
768+
769+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
770+ assert.Equal(t, "local-anthropic-key", anthropicProvider.APIKey)
771+ assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModel)
772+ assert.Equal(t, "claude-3-haiku", anthropicProvider.DefaultSmallModel)
773+ assert.Len(t, anthropicProvider.Models, 2)
774+}
775+
776+func TestProviderMerging_ConflictingSettings(t *testing.T) {
777+ reset()
778+ testConfigDir = t.TempDir()
779+ cwdDir := t.TempDir()
780+
781+ globalConfig := Config{
782+ Providers: map[provider.InferenceProvider]ProviderConfig{
783+ provider.InferenceProviderOpenAI: {
784+ ID: provider.InferenceProviderOpenAI,
785+ APIKey: "global-key",
786+ ProviderType: provider.TypeOpenAI,
787+ DefaultLargeModel: "gpt-4",
788+ DefaultSmallModel: "gpt-3.5-turbo",
789+ Models: []Model{
790+ {
791+ ID: "gpt-4",
792+ Name: "GPT-4",
793+ ContextWindow: 8192,
794+ DefaultMaxTokens: 4096,
795+ },
796+ {
797+ ID: "gpt-3.5-turbo",
798+ Name: "GPT-3.5 Turbo",
799+ ContextWindow: 4096,
800+ DefaultMaxTokens: 2048,
801+ },
802+ {
803+ ID: "gpt-4-turbo",
804+ Name: "GPT-4 Turbo",
805+ ContextWindow: 128000,
806+ DefaultMaxTokens: 4096,
807+ },
808+ },
809+ },
810+ },
811+ }
812+
813+ configPath := filepath.Join(testConfigDir, "crush.json")
814+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
815+ data, err := json.Marshal(globalConfig)
816+ require.NoError(t, err)
817+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
818+
819+ // Create local config that overrides
820+ localConfig := Config{
821+ Providers: map[provider.InferenceProvider]ProviderConfig{
822+ provider.InferenceProviderOpenAI: {
823+ APIKey: "local-key",
824+ DefaultLargeModel: "gpt-4-turbo",
825+ },
826+ },
827+ }
828+
829+ localConfigPath := filepath.Join(cwdDir, "crush.json")
830+ data, err = json.Marshal(localConfig)
831+ require.NoError(t, err)
832+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
833+
834+ cfg, err := Init(cwdDir, false)
835+
836+ require.NoError(t, err)
837+
838+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
839+ assert.Equal(t, "local-key", openaiProvider.APIKey)
840+ assert.Equal(t, "gpt-4-turbo", openaiProvider.DefaultLargeModel)
841+ assert.False(t, openaiProvider.Disabled)
842+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
843+}
844+
845+func TestProviderMerging_CustomVsKnownProviders(t *testing.T) {
846+ reset()
847+ testConfigDir = t.TempDir()
848+ cwdDir := t.TempDir()
849+
850+ customProviderID := provider.InferenceProvider("custom-provider")
851+
852+ globalConfig := Config{
853+ Providers: map[provider.InferenceProvider]ProviderConfig{
854+ provider.InferenceProviderOpenAI: {
855+ ID: provider.InferenceProviderOpenAI,
856+ APIKey: "openai-key",
857+ BaseURL: "should-not-override",
858+ ProviderType: provider.TypeAnthropic,
859+ DefaultLargeModel: "gpt-4",
860+ DefaultSmallModel: "gpt-3.5-turbo",
861+ Models: []Model{
862+ {
863+ ID: "gpt-4",
864+ Name: "GPT-4",
865+ ContextWindow: 8192,
866+ DefaultMaxTokens: 4096,
867+ },
868+ {
869+ ID: "gpt-3.5-turbo",
870+ Name: "GPT-3.5 Turbo",
871+ ContextWindow: 4096,
872+ DefaultMaxTokens: 2048,
873+ },
874+ },
875+ },
876+ customProviderID: {
877+ ID: customProviderID,
878+ APIKey: "custom-key",
879+ BaseURL: "https://custom.api.com",
880+ ProviderType: provider.TypeOpenAI,
881+ DefaultLargeModel: "custom-large",
882+ DefaultSmallModel: "custom-small",
883+ Models: []Model{
884+ {
885+ ID: "custom-large",
886+ Name: "Custom Large",
887+ ContextWindow: 8192,
888+ DefaultMaxTokens: 4096,
889+ },
890+ {
891+ ID: "custom-small",
892+ Name: "Custom Small",
893+ ContextWindow: 4096,
894+ DefaultMaxTokens: 2048,
895+ },
896+ },
897+ },
898+ },
899+ }
900+
901+ localConfig := Config{
902+ Providers: map[provider.InferenceProvider]ProviderConfig{
903+ provider.InferenceProviderOpenAI: {
904+ BaseURL: "https://should-not-change.com",
905+ ProviderType: provider.TypeGemini, // Should not change
906+ },
907+ customProviderID: {
908+ BaseURL: "https://updated-custom.api.com",
909+ ProviderType: provider.TypeOpenAI,
910+ },
911+ },
912+ }
913+
914+ configPath := filepath.Join(testConfigDir, "crush.json")
915+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
916+ data, err := json.Marshal(globalConfig)
917+ require.NoError(t, err)
918+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
919+
920+ localConfigPath := filepath.Join(cwdDir, "crush.json")
921+ data, err = json.Marshal(localConfig)
922+ require.NoError(t, err)
923+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
924+
925+ cfg, err := Init(cwdDir, false)
926+
927+ require.NoError(t, err)
928+
929+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
930+ assert.NotEqual(t, "https://should-not-change.com", openaiProvider.BaseURL)
931+ assert.NotEqual(t, provider.TypeGemini, openaiProvider.ProviderType)
932+
933+ customProvider := cfg.Providers[customProviderID]
934+ assert.Equal(t, "custom-key", customProvider.APIKey)
935+ assert.Equal(t, "https://updated-custom.api.com", customProvider.BaseURL)
936+ assert.Equal(t, provider.TypeOpenAI, customProvider.ProviderType)
937+}
938+
939+func TestProviderValidation_CustomProviderMissingBaseURL(t *testing.T) {
940+ reset()
941+ testConfigDir = t.TempDir()
942+ cwdDir := t.TempDir()
943+
944+ customProviderID := provider.InferenceProvider("custom-provider")
945+
946+ globalConfig := Config{
947+ Providers: map[provider.InferenceProvider]ProviderConfig{
948+ customProviderID: {
949+ ID: customProviderID,
950+ APIKey: "custom-key",
951+ ProviderType: provider.TypeOpenAI,
952+ },
953+ },
954+ }
955+
956+ configPath := filepath.Join(testConfigDir, "crush.json")
957+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
958+ data, err := json.Marshal(globalConfig)
959+ require.NoError(t, err)
960+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
961+
962+ cfg, err := Init(cwdDir, false)
963+
964+ require.NoError(t, err)
965+ assert.NotContains(t, cfg.Providers, customProviderID)
966+}
967+
968+func TestProviderValidation_CustomProviderMissingAPIKey(t *testing.T) {
969+ reset()
970+ testConfigDir = t.TempDir()
971+ cwdDir := t.TempDir()
972+
973+ customProviderID := provider.InferenceProvider("custom-provider")
974+
975+ globalConfig := Config{
976+ Providers: map[provider.InferenceProvider]ProviderConfig{
977+ customProviderID: {
978+ ID: customProviderID,
979+ BaseURL: "https://custom.api.com",
980+ ProviderType: provider.TypeOpenAI,
981+ },
982+ },
983+ }
984+
985+ configPath := filepath.Join(testConfigDir, "crush.json")
986+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
987+ data, err := json.Marshal(globalConfig)
988+ require.NoError(t, err)
989+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
990+
991+ cfg, err := Init(cwdDir, false)
992+
993+ require.NoError(t, err)
994+ assert.NotContains(t, cfg.Providers, customProviderID)
995+}
996+
997+func TestProviderValidation_CustomProviderInvalidType(t *testing.T) {
998+ reset()
999+ testConfigDir = t.TempDir()
1000+ cwdDir := t.TempDir()
1001+
1002+ customProviderID := provider.InferenceProvider("custom-provider")
1003+
1004+ globalConfig := Config{
1005+ Providers: map[provider.InferenceProvider]ProviderConfig{
1006+ customProviderID: {
1007+ ID: customProviderID,
1008+ APIKey: "custom-key",
1009+ BaseURL: "https://custom.api.com",
1010+ ProviderType: provider.Type("invalid-type"),
1011+ },
1012+ },
1013+ }
1014+
1015+ configPath := filepath.Join(testConfigDir, "crush.json")
1016+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1017+ data, err := json.Marshal(globalConfig)
1018+ require.NoError(t, err)
1019+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1020+
1021+ cfg, err := Init(cwdDir, false)
1022+
1023+ require.NoError(t, err)
1024+ assert.NotContains(t, cfg.Providers, customProviderID)
1025+}
1026+
1027+func TestProviderValidation_KnownProviderValid(t *testing.T) {
1028+ reset()
1029+ testConfigDir = t.TempDir()
1030+ cwdDir := t.TempDir()
1031+
1032+ globalConfig := Config{
1033+ Providers: map[provider.InferenceProvider]ProviderConfig{
1034+ provider.InferenceProviderOpenAI: {
1035+ ID: provider.InferenceProviderOpenAI,
1036+ APIKey: "openai-key",
1037+ ProviderType: provider.TypeOpenAI,
1038+ DefaultLargeModel: "gpt-4",
1039+ DefaultSmallModel: "gpt-3.5-turbo",
1040+ Models: []Model{
1041+ {
1042+ ID: "gpt-4",
1043+ Name: "GPT-4",
1044+ ContextWindow: 8192,
1045+ DefaultMaxTokens: 4096,
1046+ },
1047+ {
1048+ ID: "gpt-3.5-turbo",
1049+ Name: "GPT-3.5 Turbo",
1050+ ContextWindow: 4096,
1051+ DefaultMaxTokens: 2048,
1052+ },
1053+ },
1054+ },
1055+ },
1056+ }
1057+
1058+ configPath := filepath.Join(testConfigDir, "crush.json")
1059+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1060+ data, err := json.Marshal(globalConfig)
1061+ require.NoError(t, err)
1062+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1063+
1064+ cfg, err := Init(cwdDir, false)
1065+
1066+ require.NoError(t, err)
1067+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1068+}
1069+
1070+func TestProviderValidation_DisabledProvider(t *testing.T) {
1071+ reset()
1072+ testConfigDir = t.TempDir()
1073+ cwdDir := t.TempDir()
1074+
1075+ globalConfig := Config{
1076+ Providers: map[provider.InferenceProvider]ProviderConfig{
1077+ provider.InferenceProviderOpenAI: {
1078+ ID: provider.InferenceProviderOpenAI,
1079+ APIKey: "openai-key",
1080+ ProviderType: provider.TypeOpenAI,
1081+ Disabled: true,
1082+ DefaultLargeModel: "gpt-4",
1083+ DefaultSmallModel: "gpt-3.5-turbo",
1084+ Models: []Model{
1085+ {
1086+ ID: "gpt-4",
1087+ Name: "GPT-4",
1088+ ContextWindow: 8192,
1089+ DefaultMaxTokens: 4096,
1090+ },
1091+ {
1092+ ID: "gpt-3.5-turbo",
1093+ Name: "GPT-3.5 Turbo",
1094+ ContextWindow: 4096,
1095+ DefaultMaxTokens: 2048,
1096+ },
1097+ },
1098+ },
1099+ provider.InferenceProviderAnthropic: {
1100+ ID: provider.InferenceProviderAnthropic,
1101+ APIKey: "anthropic-key",
1102+ ProviderType: provider.TypeAnthropic,
1103+ Disabled: false, // This one is enabled
1104+ DefaultLargeModel: "claude-3-opus",
1105+ DefaultSmallModel: "claude-3-haiku",
1106+ Models: []Model{
1107+ {
1108+ ID: "claude-3-opus",
1109+ Name: "Claude 3 Opus",
1110+ ContextWindow: 200000,
1111+ DefaultMaxTokens: 4096,
1112+ },
1113+ {
1114+ ID: "claude-3-haiku",
1115+ Name: "Claude 3 Haiku",
1116+ ContextWindow: 200000,
1117+ DefaultMaxTokens: 4096,
1118+ },
1119+ },
1120+ },
1121+ },
1122+ }
1123+
1124+ configPath := filepath.Join(testConfigDir, "crush.json")
1125+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1126+ data, err := json.Marshal(globalConfig)
1127+ require.NoError(t, err)
1128+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1129+
1130+ cfg, err := Init(cwdDir, false)
1131+
1132+ require.NoError(t, err)
1133+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1134+ assert.True(t, cfg.Providers[provider.InferenceProviderOpenAI].Disabled)
1135+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
1136+ assert.False(t, cfg.Providers[provider.InferenceProviderAnthropic].Disabled)
1137+}
1138+
1139+func TestProviderModels_AddingNewModels(t *testing.T) {
1140+ reset()
1141+ testConfigDir = t.TempDir()
1142+ cwdDir := t.TempDir()
1143+
1144+ globalConfig := Config{
1145+ Providers: map[provider.InferenceProvider]ProviderConfig{
1146+ provider.InferenceProviderOpenAI: {
1147+ ID: provider.InferenceProviderOpenAI,
1148+ APIKey: "openai-key",
1149+ ProviderType: provider.TypeOpenAI,
1150+ DefaultLargeModel: "gpt-4",
1151+ DefaultSmallModel: "gpt-4-turbo",
1152+ Models: []Model{
1153+ {
1154+ ID: "gpt-4",
1155+ Name: "GPT-4",
1156+ ContextWindow: 8192,
1157+ DefaultMaxTokens: 4096,
1158+ },
1159+ },
1160+ },
1161+ },
1162+ }
1163+
1164+ localConfig := Config{
1165+ Providers: map[provider.InferenceProvider]ProviderConfig{
1166+ provider.InferenceProviderOpenAI: {
1167+ Models: []Model{
1168+ {
1169+ ID: "gpt-4-turbo",
1170+ Name: "GPT-4 Turbo",
1171+ ContextWindow: 128000,
1172+ DefaultMaxTokens: 4096,
1173+ },
1174+ },
1175+ },
1176+ },
1177+ }
1178+
1179+ configPath := filepath.Join(testConfigDir, "crush.json")
1180+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1181+ data, err := json.Marshal(globalConfig)
1182+ require.NoError(t, err)
1183+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1184+
1185+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1186+ data, err = json.Marshal(localConfig)
1187+ require.NoError(t, err)
1188+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1189+
1190+ cfg, err := Init(cwdDir, false)
1191+
1192+ require.NoError(t, err)
1193+
1194+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1195+ assert.Len(t, openaiProvider.Models, 2)
1196+
1197+ modelIDs := make([]string, len(openaiProvider.Models))
1198+ for i, model := range openaiProvider.Models {
1199+ modelIDs[i] = model.ID
1200+ }
1201+ assert.Contains(t, modelIDs, "gpt-4")
1202+ assert.Contains(t, modelIDs, "gpt-4-turbo")
1203+}
1204+
1205+func TestProviderModels_DuplicateModelHandling(t *testing.T) {
1206+ reset()
1207+ testConfigDir = t.TempDir()
1208+ cwdDir := t.TempDir()
1209+
1210+ globalConfig := Config{
1211+ Providers: map[provider.InferenceProvider]ProviderConfig{
1212+ provider.InferenceProviderOpenAI: {
1213+ ID: provider.InferenceProviderOpenAI,
1214+ APIKey: "openai-key",
1215+ ProviderType: provider.TypeOpenAI,
1216+ DefaultLargeModel: "gpt-4",
1217+ DefaultSmallModel: "gpt-4",
1218+ Models: []Model{
1219+ {
1220+ ID: "gpt-4",
1221+ Name: "GPT-4",
1222+ ContextWindow: 8192,
1223+ DefaultMaxTokens: 4096,
1224+ },
1225+ },
1226+ },
1227+ },
1228+ }
1229+
1230+ localConfig := Config{
1231+ Providers: map[provider.InferenceProvider]ProviderConfig{
1232+ provider.InferenceProviderOpenAI: {
1233+ Models: []Model{
1234+ {
1235+ ID: "gpt-4",
1236+ Name: "GPT-4 Updated",
1237+ ContextWindow: 16384,
1238+ DefaultMaxTokens: 8192,
1239+ },
1240+ },
1241+ },
1242+ },
1243+ }
1244+
1245+ configPath := filepath.Join(testConfigDir, "crush.json")
1246+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1247+ data, err := json.Marshal(globalConfig)
1248+ require.NoError(t, err)
1249+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1250+
1251+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1252+ data, err = json.Marshal(localConfig)
1253+ require.NoError(t, err)
1254+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1255+
1256+ cfg, err := Init(cwdDir, false)
1257+
1258+ require.NoError(t, err)
1259+
1260+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1261+ assert.Len(t, openaiProvider.Models, 1)
1262+
1263+ model := openaiProvider.Models[0]
1264+ assert.Equal(t, "gpt-4", model.ID)
1265+ assert.Equal(t, "GPT-4", model.Name)
1266+ assert.Equal(t, int64(8192), model.ContextWindow)
1267+}
1268+
1269+func TestProviderModels_ModelCostAndCapabilities(t *testing.T) {
1270+ reset()
1271+ testConfigDir = t.TempDir()
1272+ cwdDir := t.TempDir()
1273+
1274+ globalConfig := Config{
1275+ Providers: map[provider.InferenceProvider]ProviderConfig{
1276+ provider.InferenceProviderOpenAI: {
1277+ ID: provider.InferenceProviderOpenAI,
1278+ APIKey: "openai-key",
1279+ ProviderType: provider.TypeOpenAI,
1280+ DefaultLargeModel: "gpt-4",
1281+ DefaultSmallModel: "gpt-4",
1282+ Models: []Model{
1283+ {
1284+ ID: "gpt-4",
1285+ Name: "GPT-4",
1286+ CostPer1MIn: 30.0,
1287+ CostPer1MOut: 60.0,
1288+ CostPer1MInCached: 15.0,
1289+ CostPer1MOutCached: 30.0,
1290+ ContextWindow: 8192,
1291+ DefaultMaxTokens: 4096,
1292+ CanReason: true,
1293+ ReasoningEffort: "medium",
1294+ SupportsImages: true,
1295+ },
1296+ },
1297+ },
1298+ },
1299+ }
1300+
1301+ configPath := filepath.Join(testConfigDir, "crush.json")
1302+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1303+ data, err := json.Marshal(globalConfig)
1304+ require.NoError(t, err)
1305+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1306+
1307+ cfg, err := Init(cwdDir, false)
1308+
1309+ require.NoError(t, err)
1310+
1311+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1312+ require.Len(t, openaiProvider.Models, 1)
1313+
1314+ model := openaiProvider.Models[0]
1315+ assert.Equal(t, 30.0, model.CostPer1MIn)
1316+ assert.Equal(t, 60.0, model.CostPer1MOut)
1317+ assert.Equal(t, 15.0, model.CostPer1MInCached)
1318+ assert.Equal(t, 30.0, model.CostPer1MOutCached)
1319+ assert.True(t, model.CanReason)
1320+ assert.Equal(t, "medium", model.ReasoningEffort)
1321+ assert.True(t, model.SupportsImages)
1322+}
1323+
1324+func TestDefaultAgents_CoderAgent(t *testing.T) {
1325+ reset()
1326+ testConfigDir = t.TempDir()
1327+ cwdDir := t.TempDir()
1328+
1329+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1330+
1331+ cfg, err := Init(cwdDir, false)
1332+
1333+ require.NoError(t, err)
1334+ assert.Contains(t, cfg.Agents, AgentCoder)
1335+
1336+ coderAgent := cfg.Agents[AgentCoder]
1337+ assert.Equal(t, AgentCoder, coderAgent.ID)
1338+ assert.Equal(t, "Coder", coderAgent.Name)
1339+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1340+ assert.Equal(t, LargeModel, coderAgent.Model)
1341+ assert.False(t, coderAgent.Disabled)
1342+ assert.Equal(t, cfg.Options.ContextPaths, coderAgent.ContextPaths)
1343+ assert.Nil(t, coderAgent.AllowedTools)
1344+}
1345+
1346+func TestDefaultAgents_TaskAgent(t *testing.T) {
1347+ reset()
1348+ testConfigDir = t.TempDir()
1349+ cwdDir := t.TempDir()
1350+
1351+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1352+
1353+ cfg, err := Init(cwdDir, false)
1354+
1355+ require.NoError(t, err)
1356+ assert.Contains(t, cfg.Agents, AgentTask)
1357+
1358+ taskAgent := cfg.Agents[AgentTask]
1359+ assert.Equal(t, AgentTask, taskAgent.ID)
1360+ assert.Equal(t, "Task", taskAgent.Name)
1361+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1362+ assert.Equal(t, LargeModel, taskAgent.Model)
1363+ assert.False(t, taskAgent.Disabled)
1364+ assert.Equal(t, cfg.Options.ContextPaths, taskAgent.ContextPaths)
1365+
1366+ expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1367+ assert.Equal(t, expectedTools, taskAgent.AllowedTools)
1368+
1369+ assert.Equal(t, map[string][]string{}, taskAgent.AllowedMCP)
1370+ assert.Equal(t, []string{}, taskAgent.AllowedLSP)
1371+}
1372+
1373+func TestAgentMerging_CustomAgent(t *testing.T) {
1374+ reset()
1375+ testConfigDir = t.TempDir()
1376+ cwdDir := t.TempDir()
1377+
1378+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1379+
1380+ globalConfig := Config{
1381+ Agents: map[AgentID]Agent{
1382+ AgentID("custom-agent"): {
1383+ ID: AgentID("custom-agent"),
1384+ Name: "Custom Agent",
1385+ Description: "A custom agent for testing",
1386+ Model: SmallModel,
1387+ AllowedTools: []string{"glob", "grep"},
1388+ AllowedMCP: map[string][]string{"mcp1": {"tool1", "tool2"}},
1389+ AllowedLSP: []string{"typescript", "go"},
1390+ ContextPaths: []string{"custom-context.md"},
1391+ },
1392+ },
1393+ MCP: map[string]MCP{
1394+ "mcp1": {
1395+ Type: MCPStdio,
1396+ Command: "test-mcp-command",
1397+ Args: []string{"--test"},
1398+ },
1399+ },
1400+ LSP: map[string]LSPConfig{
1401+ "typescript": {
1402+ Command: "typescript-language-server",
1403+ Args: []string{"--stdio"},
1404+ },
1405+ "go": {
1406+ Command: "gopls",
1407+ Args: []string{},
1408+ },
1409+ },
1410+ }
1411+
1412+ configPath := filepath.Join(testConfigDir, "crush.json")
1413+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1414+ data, err := json.Marshal(globalConfig)
1415+ require.NoError(t, err)
1416+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1417+
1418+ cfg, err := Init(cwdDir, false)
1419+
1420+ require.NoError(t, err)
1421+
1422+ assert.Contains(t, cfg.Agents, AgentCoder)
1423+ assert.Contains(t, cfg.Agents, AgentTask)
1424+ assert.Contains(t, cfg.Agents, AgentID("custom-agent"))
1425+
1426+ customAgent := cfg.Agents[AgentID("custom-agent")]
1427+ assert.Equal(t, "Custom Agent", customAgent.Name)
1428+ assert.Equal(t, "A custom agent for testing", customAgent.Description)
1429+ assert.Equal(t, SmallModel, customAgent.Model)
1430+ assert.Equal(t, []string{"glob", "grep"}, customAgent.AllowedTools)
1431+ assert.Equal(t, map[string][]string{"mcp1": {"tool1", "tool2"}}, customAgent.AllowedMCP)
1432+ assert.Equal(t, []string{"typescript", "go"}, customAgent.AllowedLSP)
1433+ expectedContextPaths := append(defaultContextPaths, "custom-context.md")
1434+ assert.Equal(t, expectedContextPaths, customAgent.ContextPaths)
1435+}
1436+
1437+func TestAgentMerging_ModifyDefaultCoderAgent(t *testing.T) {
1438+ reset()
1439+ testConfigDir = t.TempDir()
1440+ cwdDir := t.TempDir()
1441+
1442+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1443+
1444+ globalConfig := Config{
1445+ Agents: map[AgentID]Agent{
1446+ AgentCoder: {
1447+ Model: SmallModel,
1448+ AllowedMCP: map[string][]string{"mcp1": {"tool1"}},
1449+ AllowedLSP: []string{"typescript"},
1450+ ContextPaths: []string{"coder-specific.md"},
1451+ },
1452+ },
1453+ MCP: map[string]MCP{
1454+ "mcp1": {
1455+ Type: MCPStdio,
1456+ Command: "test-mcp-command",
1457+ Args: []string{"--test"},
1458+ },
1459+ },
1460+ LSP: map[string]LSPConfig{
1461+ "typescript": {
1462+ Command: "typescript-language-server",
1463+ Args: []string{"--stdio"},
1464+ },
1465+ },
1466+ }
1467+
1468+ configPath := filepath.Join(testConfigDir, "crush.json")
1469+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1470+ data, err := json.Marshal(globalConfig)
1471+ require.NoError(t, err)
1472+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1473+
1474+ cfg, err := Init(cwdDir, false)
1475+
1476+ require.NoError(t, err)
1477+
1478+ coderAgent := cfg.Agents[AgentCoder]
1479+ assert.Equal(t, AgentCoder, coderAgent.ID)
1480+ assert.Equal(t, "Coder", coderAgent.Name)
1481+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1482+
1483+ expectedContextPaths := append(cfg.Options.ContextPaths, "coder-specific.md")
1484+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1485+
1486+ assert.Equal(t, SmallModel, coderAgent.Model)
1487+ assert.Equal(t, map[string][]string{"mcp1": {"tool1"}}, coderAgent.AllowedMCP)
1488+ assert.Equal(t, []string{"typescript"}, coderAgent.AllowedLSP)
1489+}
1490+
1491+func TestAgentMerging_ModifyDefaultTaskAgent(t *testing.T) {
1492+ reset()
1493+ testConfigDir = t.TempDir()
1494+ cwdDir := t.TempDir()
1495+
1496+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1497+
1498+ globalConfig := Config{
1499+ Agents: map[AgentID]Agent{
1500+ AgentTask: {
1501+ Model: SmallModel,
1502+ AllowedMCP: map[string][]string{"search-mcp": nil},
1503+ AllowedLSP: []string{"python"},
1504+ Name: "Search Agent",
1505+ Description: "Custom search agent",
1506+ Disabled: true,
1507+ AllowedTools: []string{"glob", "grep", "view"},
1508+ },
1509+ },
1510+ MCP: map[string]MCP{
1511+ "search-mcp": {
1512+ Type: MCPStdio,
1513+ Command: "search-mcp-command",
1514+ Args: []string{"--search"},
1515+ },
1516+ },
1517+ LSP: map[string]LSPConfig{
1518+ "python": {
1519+ Command: "pylsp",
1520+ Args: []string{},
1521+ },
1522+ },
1523+ }
1524+
1525+ configPath := filepath.Join(testConfigDir, "crush.json")
1526+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1527+ data, err := json.Marshal(globalConfig)
1528+ require.NoError(t, err)
1529+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1530+
1531+ cfg, err := Init(cwdDir, false)
1532+
1533+ require.NoError(t, err)
1534+
1535+ taskAgent := cfg.Agents[AgentTask]
1536+ assert.Equal(t, "Task", taskAgent.Name)
1537+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1538+ assert.False(t, taskAgent.Disabled)
1539+ assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
1540+
1541+ assert.Equal(t, SmallModel, taskAgent.Model)
1542+ assert.Equal(t, map[string][]string{"search-mcp": nil}, taskAgent.AllowedMCP)
1543+ assert.Equal(t, []string{"python"}, taskAgent.AllowedLSP)
1544+}
1545+
1546+func TestAgentMerging_LocalOverridesGlobal(t *testing.T) {
1547+ reset()
1548+ testConfigDir = t.TempDir()
1549+ cwdDir := t.TempDir()
1550+
1551+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1552+
1553+ globalConfig := Config{
1554+ Agents: map[AgentID]Agent{
1555+ AgentID("test-agent"): {
1556+ ID: AgentID("test-agent"),
1557+ Name: "Global Agent",
1558+ Description: "Global description",
1559+ Model: LargeModel,
1560+ AllowedTools: []string{"glob"},
1561+ },
1562+ },
1563+ }
1564+
1565+ configPath := filepath.Join(testConfigDir, "crush.json")
1566+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1567+ data, err := json.Marshal(globalConfig)
1568+ require.NoError(t, err)
1569+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1570+
1571+ // Create local config that overrides
1572+ localConfig := Config{
1573+ Agents: map[AgentID]Agent{
1574+ AgentID("test-agent"): {
1575+ Name: "Local Agent",
1576+ Description: "Local description",
1577+ Model: SmallModel,
1578+ Disabled: true,
1579+ AllowedTools: []string{"grep", "view"},
1580+ AllowedMCP: map[string][]string{"local-mcp": {"tool1"}},
1581+ },
1582+ },
1583+ MCP: map[string]MCP{
1584+ "local-mcp": {
1585+ Type: MCPStdio,
1586+ Command: "local-mcp-command",
1587+ Args: []string{"--local"},
1588+ },
1589+ },
1590+ }
1591+
1592+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1593+ data, err = json.Marshal(localConfig)
1594+ require.NoError(t, err)
1595+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1596+
1597+ cfg, err := Init(cwdDir, false)
1598+
1599+ require.NoError(t, err)
1600+
1601+ testAgent := cfg.Agents[AgentID("test-agent")]
1602+ assert.Equal(t, "Local Agent", testAgent.Name)
1603+ assert.Equal(t, "Local description", testAgent.Description)
1604+ assert.Equal(t, SmallModel, testAgent.Model)
1605+ assert.True(t, testAgent.Disabled)
1606+ assert.Equal(t, []string{"grep", "view"}, testAgent.AllowedTools)
1607+ assert.Equal(t, map[string][]string{"local-mcp": {"tool1"}}, testAgent.AllowedMCP)
1608+}
1609+
1610+func TestAgentModelTypeAssignment(t *testing.T) {
1611+ reset()
1612+ testConfigDir = t.TempDir()
1613+ cwdDir := t.TempDir()
1614+
1615+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1616+
1617+ globalConfig := Config{
1618+ Agents: map[AgentID]Agent{
1619+ AgentID("large-agent"): {
1620+ ID: AgentID("large-agent"),
1621+ Name: "Large Model Agent",
1622+ Model: LargeModel,
1623+ },
1624+ AgentID("small-agent"): {
1625+ ID: AgentID("small-agent"),
1626+ Name: "Small Model Agent",
1627+ Model: SmallModel,
1628+ },
1629+ AgentID("default-agent"): {
1630+ ID: AgentID("default-agent"),
1631+ Name: "Default Model Agent",
1632+ },
1633+ },
1634+ }
1635+
1636+ configPath := filepath.Join(testConfigDir, "crush.json")
1637+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1638+ data, err := json.Marshal(globalConfig)
1639+ require.NoError(t, err)
1640+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1641+
1642+ cfg, err := Init(cwdDir, false)
1643+
1644+ require.NoError(t, err)
1645+
1646+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("large-agent")].Model)
1647+ assert.Equal(t, SmallModel, cfg.Agents[AgentID("small-agent")].Model)
1648+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("default-agent")].Model)
1649+}
1650+
1651+func TestAgentContextPathOverrides(t *testing.T) {
1652+ reset()
1653+ testConfigDir = t.TempDir()
1654+ cwdDir := t.TempDir()
1655+
1656+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1657+
1658+ globalConfig := Config{
1659+ Options: Options{
1660+ ContextPaths: []string{"global-context.md", "shared-context.md"},
1661+ },
1662+ Agents: map[AgentID]Agent{
1663+ AgentID("custom-context-agent"): {
1664+ ID: AgentID("custom-context-agent"),
1665+ Name: "Custom Context Agent",
1666+ ContextPaths: []string{"agent-specific.md", "custom.md"},
1667+ },
1668+ AgentID("default-context-agent"): {
1669+ ID: AgentID("default-context-agent"),
1670+ Name: "Default Context Agent",
1671+ },
1672+ },
1673+ }
1674+
1675+ configPath := filepath.Join(testConfigDir, "crush.json")
1676+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1677+ data, err := json.Marshal(globalConfig)
1678+ require.NoError(t, err)
1679+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1680+
1681+ cfg, err := Init(cwdDir, false)
1682+
1683+ require.NoError(t, err)
1684+
1685+ customAgent := cfg.Agents[AgentID("custom-context-agent")]
1686+ expectedCustomPaths := append(defaultContextPaths, "global-context.md", "shared-context.md", "agent-specific.md", "custom.md")
1687+ assert.Equal(t, expectedCustomPaths, customAgent.ContextPaths)
1688+
1689+ defaultAgent := cfg.Agents[AgentID("default-context-agent")]
1690+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "shared-context.md")
1691+ assert.Equal(t, expectedContextPaths, defaultAgent.ContextPaths)
1692+
1693+ coderAgent := cfg.Agents[AgentCoder]
1694+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1695+}
1696+
1697+func TestOptionsMerging_ContextPaths(t *testing.T) {
1698+ reset()
1699+ testConfigDir = t.TempDir()
1700+ cwdDir := t.TempDir()
1701+
1702+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1703+
1704+ globalConfig := Config{
1705+ Options: Options{
1706+ ContextPaths: []string{"global1.md", "global2.md"},
1707+ },
1708+ }
1709+
1710+ configPath := filepath.Join(testConfigDir, "crush.json")
1711+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1712+ data, err := json.Marshal(globalConfig)
1713+ require.NoError(t, err)
1714+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1715+
1716+ localConfig := Config{
1717+ Options: Options{
1718+ ContextPaths: []string{"local1.md", "local2.md"},
1719+ },
1720+ }
1721+
1722+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1723+ data, err = json.Marshal(localConfig)
1724+ require.NoError(t, err)
1725+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1726+
1727+ cfg, err := Init(cwdDir, false)
1728+
1729+ require.NoError(t, err)
1730+
1731+ expectedContextPaths := append(defaultContextPaths, "global1.md", "global2.md", "local1.md", "local2.md")
1732+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1733+}
1734+
1735+func TestOptionsMerging_TUIOptions(t *testing.T) {
1736+ reset()
1737+ testConfigDir = t.TempDir()
1738+ cwdDir := t.TempDir()
1739+
1740+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1741+
1742+ globalConfig := Config{
1743+ Options: Options{
1744+ TUI: TUIOptions{
1745+ CompactMode: false,
1746+ },
1747+ },
1748+ }
1749+
1750+ configPath := filepath.Join(testConfigDir, "crush.json")
1751+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1752+ data, err := json.Marshal(globalConfig)
1753+ require.NoError(t, err)
1754+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1755+
1756+ localConfig := Config{
1757+ Options: Options{
1758+ TUI: TUIOptions{
1759+ CompactMode: true,
1760+ },
1761+ },
1762+ }
1763+
1764+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1765+ data, err = json.Marshal(localConfig)
1766+ require.NoError(t, err)
1767+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1768+
1769+ cfg, err := Init(cwdDir, false)
1770+
1771+ require.NoError(t, err)
1772+
1773+ assert.True(t, cfg.Options.TUI.CompactMode)
1774+}
1775+
1776+func TestOptionsMerging_DebugFlags(t *testing.T) {
1777+ reset()
1778+ testConfigDir = t.TempDir()
1779+ cwdDir := t.TempDir()
1780+
1781+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1782+
1783+ globalConfig := Config{
1784+ Options: Options{
1785+ Debug: false,
1786+ DebugLSP: false,
1787+ DisableAutoSummarize: false,
1788+ },
1789+ }
1790+
1791+ configPath := filepath.Join(testConfigDir, "crush.json")
1792+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1793+ data, err := json.Marshal(globalConfig)
1794+ require.NoError(t, err)
1795+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1796+
1797+ localConfig := Config{
1798+ Options: Options{
1799+ DebugLSP: true,
1800+ DisableAutoSummarize: true,
1801+ },
1802+ }
1803+
1804+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1805+ data, err = json.Marshal(localConfig)
1806+ require.NoError(t, err)
1807+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1808+
1809+ cfg, err := Init(cwdDir, false)
1810+
1811+ require.NoError(t, err)
1812+
1813+ assert.False(t, cfg.Options.Debug)
1814+ assert.True(t, cfg.Options.DebugLSP)
1815+ assert.True(t, cfg.Options.DisableAutoSummarize)
1816+}
1817+
1818+func TestOptionsMerging_DataDirectory(t *testing.T) {
1819+ reset()
1820+ testConfigDir = t.TempDir()
1821+ cwdDir := t.TempDir()
1822+
1823+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1824+
1825+ globalConfig := Config{
1826+ Options: Options{
1827+ DataDirectory: "global-data",
1828+ },
1829+ }
1830+
1831+ configPath := filepath.Join(testConfigDir, "crush.json")
1832+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1833+ data, err := json.Marshal(globalConfig)
1834+ require.NoError(t, err)
1835+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1836+
1837+ localConfig := Config{
1838+ Options: Options{
1839+ DataDirectory: "local-data",
1840+ },
1841+ }
1842+
1843+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1844+ data, err = json.Marshal(localConfig)
1845+ require.NoError(t, err)
1846+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1847+
1848+ cfg, err := Init(cwdDir, false)
1849+
1850+ require.NoError(t, err)
1851+
1852+ assert.Equal(t, "local-data", cfg.Options.DataDirectory)
1853+}
1854+
1855+func TestOptionsMerging_DefaultValues(t *testing.T) {
1856+ reset()
1857+ testConfigDir = t.TempDir()
1858+ cwdDir := t.TempDir()
1859+
1860+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1861+
1862+ cfg, err := Init(cwdDir, false)
1863+
1864+ require.NoError(t, err)
1865+
1866+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
1867+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
1868+ assert.False(t, cfg.Options.TUI.CompactMode)
1869+ assert.False(t, cfg.Options.Debug)
1870+ assert.False(t, cfg.Options.DebugLSP)
1871+ assert.False(t, cfg.Options.DisableAutoSummarize)
1872+}
1873+
1874+func TestOptionsMerging_DebugFlagFromInit(t *testing.T) {
1875+ reset()
1876+ testConfigDir = t.TempDir()
1877+ cwdDir := t.TempDir()
1878+
1879+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1880+
1881+ globalConfig := Config{
1882+ Options: Options{
1883+ Debug: false,
1884+ },
1885+ }
1886+
1887+ configPath := filepath.Join(testConfigDir, "crush.json")
1888+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1889+ data, err := json.Marshal(globalConfig)
1890+ require.NoError(t, err)
1891+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1892+
1893+ cfg, err := Init(cwdDir, true)
1894+
1895+ require.NoError(t, err)
1896+
1897+ // Debug flag from Init should take precedence
1898+ assert.True(t, cfg.Options.Debug)
1899+}
1900+
1901+func TestOptionsMerging_ComplexScenario(t *testing.T) {
1902+ reset()
1903+ testConfigDir = t.TempDir()
1904+ cwdDir := t.TempDir()
1905+
1906+ // Set up a provider
1907+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1908+
1909+ // Create global config with various options
1910+ globalConfig := Config{
1911+ Options: Options{
1912+ ContextPaths: []string{"global-context.md"},
1913+ DataDirectory: "global-data",
1914+ Debug: false,
1915+ DebugLSP: false,
1916+ DisableAutoSummarize: false,
1917+ TUI: TUIOptions{
1918+ CompactMode: false,
1919+ },
1920+ },
1921+ }
1922+
1923+ configPath := filepath.Join(testConfigDir, "crush.json")
1924+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1925+ data, err := json.Marshal(globalConfig)
1926+ require.NoError(t, err)
1927+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1928+
1929+ // Create local config that partially overrides
1930+ localConfig := Config{
1931+ Options: Options{
1932+ ContextPaths: []string{"local-context.md"},
1933+ DebugLSP: true, // Override
1934+ DisableAutoSummarize: true, // Override
1935+ TUI: TUIOptions{
1936+ CompactMode: true, // Override
1937+ },
1938+ // DataDirectory and Debug not specified - should keep global values
1939+ },
1940+ }
1941+
1942+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1943+ data, err = json.Marshal(localConfig)
1944+ require.NoError(t, err)
1945+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1946+
1947+ cfg, err := Init(cwdDir, false)
1948+
1949+ require.NoError(t, err)
1950+
1951+ // Check merged results
1952+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "local-context.md")
1953+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1954+ assert.Equal(t, "global-data", cfg.Options.DataDirectory) // From global
1955+ assert.False(t, cfg.Options.Debug) // From global
1956+ assert.True(t, cfg.Options.DebugLSP) // From local
1957+ assert.True(t, cfg.Options.DisableAutoSummarize) // From local
1958+ assert.True(t, cfg.Options.TUI.CompactMode) // From local
1959+}
1960+
1961+// Model Selection Tests
1962+
1963+func TestModelSelection_PreferredModelSelection(t *testing.T) {
1964+ reset()
1965+ testConfigDir = t.TempDir()
1966+ cwdDir := t.TempDir()
1967+
1968+ // Set up multiple providers to test selection logic
1969+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
1970+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
1971+
1972+ cfg, err := Init(cwdDir, false)
1973+
1974+ require.NoError(t, err)
1975+ require.Len(t, cfg.Providers, 2)
1976+
1977+ // Should have preferred models set
1978+ assert.NotEmpty(t, cfg.Models.Large.ModelID)
1979+ assert.NotEmpty(t, cfg.Models.Large.Provider)
1980+ assert.NotEmpty(t, cfg.Models.Small.ModelID)
1981+ assert.NotEmpty(t, cfg.Models.Small.Provider)
1982+
1983+ // Both should use the same provider (first available)
1984+ assert.Equal(t, cfg.Models.Large.Provider, cfg.Models.Small.Provider)
1985+}
1986+
1987+func TestValidation_InvalidModelReference(t *testing.T) {
1988+ reset()
1989+ testConfigDir = t.TempDir()
1990+ cwdDir := t.TempDir()
1991+
1992+ globalConfig := Config{
1993+ Providers: map[provider.InferenceProvider]ProviderConfig{
1994+ provider.InferenceProviderOpenAI: {
1995+ ID: provider.InferenceProviderOpenAI,
1996+ APIKey: "test-key",
1997+ ProviderType: provider.TypeOpenAI,
1998+ DefaultLargeModel: "non-existent-model",
1999+ DefaultSmallModel: "gpt-3.5-turbo",
2000+ Models: []Model{
2001+ {
2002+ ID: "gpt-3.5-turbo",
2003+ Name: "GPT-3.5 Turbo",
2004+ ContextWindow: 4096,
2005+ DefaultMaxTokens: 2048,
2006+ },
2007+ },
2008+ },
2009+ },
2010+ }
2011+
2012+ configPath := filepath.Join(testConfigDir, "crush.json")
2013+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2014+ data, err := json.Marshal(globalConfig)
2015+ require.NoError(t, err)
2016+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2017+
2018+ _, err = Init(cwdDir, false)
2019+ assert.Error(t, err)
2020+}
2021+
2022+func TestValidation_EmptyAPIKey(t *testing.T) {
2023+ reset()
2024+ testConfigDir = t.TempDir()
2025+ cwdDir := t.TempDir()
2026+
2027+ globalConfig := Config{
2028+ Providers: map[provider.InferenceProvider]ProviderConfig{
2029+ provider.InferenceProviderOpenAI: {
2030+ ID: provider.InferenceProviderOpenAI,
2031+ ProviderType: provider.TypeOpenAI,
2032+ Models: []Model{
2033+ {
2034+ ID: "gpt-4",
2035+ Name: "GPT-4",
2036+ ContextWindow: 8192,
2037+ DefaultMaxTokens: 4096,
2038+ },
2039+ },
2040+ },
2041+ },
2042+ }
2043+
2044+ configPath := filepath.Join(testConfigDir, "crush.json")
2045+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2046+ data, err := json.Marshal(globalConfig)
2047+ require.NoError(t, err)
2048+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2049+
2050+ _, err = Init(cwdDir, false)
2051+ assert.Error(t, err)
2052+}
2053+
2054+func TestValidation_InvalidAgentModelType(t *testing.T) {
2055+ reset()
2056+ testConfigDir = t.TempDir()
2057+ cwdDir := t.TempDir()
2058+
2059+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
2060+
2061+ globalConfig := Config{
2062+ Agents: map[AgentID]Agent{
2063+ AgentID("invalid-agent"): {
2064+ ID: AgentID("invalid-agent"),
2065+ Name: "Invalid Agent",
2066+ Model: ModelType("invalid"),
2067+ },
2068+ },
2069+ }
2070+
2071+ configPath := filepath.Join(testConfigDir, "crush.json")
2072+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2073+ data, err := json.Marshal(globalConfig)
2074+ require.NoError(t, err)
2075+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2076+
2077+ _, err = Init(cwdDir, false)
2078+ assert.Error(t, err)
2079+}
internal/config/fs.gonew file+71
@@ -0,0 +1,71 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+)
9+
10+var testConfigDir string
11+
12+func baseConfigPath() string {
13+ if testConfigDir != "" {
14+ return testConfigDir
15+ }
16+
17+ xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
18+ if xdgConfigHome != "" {
19+ return filepath.Join(xdgConfigHome, "crush")
20+ }
21+
22+ // return the path to the main config directory
23+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
24+ // for linux and macOS, it should be in `$HOME/.config/crush/`
25+ if runtime.GOOS == "windows" {
26+ localAppData := os.Getenv("LOCALAPPDATA")
27+ if localAppData == "" {
28+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
29+ }
30+ return filepath.Join(localAppData, appName)
31+ }
32+
33+ return filepath.Join(os.Getenv("HOME"), ".config", appName)
34+}
35+
36+func baseDataPath() string {
37+ if testConfigDir != "" {
38+ return testConfigDir
39+ }
40+
41+ xdgDataHome := os.Getenv("XDG_DATA_HOME")
42+ if xdgDataHome != "" {
43+ return filepath.Join(xdgDataHome, appName)
44+ }
45+
46+ // return the path to the main data directory
47+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
48+ // for linux and macOS, it should be in `$HOME/.local/share/crush/`
49+ if runtime.GOOS == "windows" {
50+ localAppData := os.Getenv("LOCALAPPDATA")
51+ if localAppData == "" {
52+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
53+ }
54+ return filepath.Join(localAppData, appName)
55+ }
56+
57+ return filepath.Join(os.Getenv("HOME"), ".local", "share", appName)
58+}
59+
60+func ConfigPath() string {
61+ return filepath.Join(baseConfigPath(), fmt.Sprintf("%s.json", appName))
62+}
63+
64+func CrushInitialized() bool {
65+ cfgPath := ConfigPath()
66+ if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
67+ // config file does not exist, so Crush is not initialized
68+ return false
69+ }
70+ return true
71+}
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/provider.gonew file+83
@@ -0,0 +1,83 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "sync"
8+
9+ "github.com/charmbracelet/crush/internal/fur/client"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+)
12+
13+var fur = client.New()
14+
15+var (
16+ providerOnc sync.Once // Ensures the initialization happens only once
17+ providerList []provider.Provider
18+ // UseMockProviders can be set to true in tests to avoid API calls
19+ UseMockProviders bool
20+)
21+
22+func providersPath() string {
23+ return filepath.Join(baseDataPath(), "providers.json")
24+}
25+
26+func saveProviders(providers []provider.Provider) error {
27+ path := providersPath()
28+ dir := filepath.Dir(path)
29+ if err := os.MkdirAll(dir, 0o755); err != nil {
30+ return err
31+ }
32+
33+ data, err := json.MarshalIndent(providers, "", " ")
34+ if err != nil {
35+ return err
36+ }
37+
38+ return os.WriteFile(path, data, 0o644)
39+}
40+
41+func loadProviders() ([]provider.Provider, error) {
42+ path := providersPath()
43+ data, err := os.ReadFile(path)
44+ if err != nil {
45+ return nil, err
46+ }
47+
48+ var providers []provider.Provider
49+ err = json.Unmarshal(data, &providers)
50+ return providers, err
51+}
52+
53+func Providers() []provider.Provider {
54+ providerOnc.Do(func() {
55+ // Use mock providers when testing
56+ if UseMockProviders {
57+ providerList = MockProviders()
58+ return
59+ }
60+
61+ // Try to get providers from upstream API
62+ if providers, err := fur.GetProviders(); err == nil {
63+ providerList = providers
64+ // Save providers locally for future fallback
65+ _ = saveProviders(providers)
66+ } else {
67+ // If upstream fails, try to load from local cache
68+ if localProviders, localErr := loadProviders(); localErr == nil {
69+ providerList = localProviders
70+ } else {
71+ // If both fail, return empty list
72+ providerList = []provider.Provider{}
73+ }
74+ }
75+ })
76+ return providerList
77+}
78+
79+// ResetProviders resets the provider cache. Useful for testing.
80+func ResetProviders() {
81+ providerOnc = sync.Once{}
82+ providerList = nil
83+}
internal/config/provider_mock.gonew file+293
@@ -0,0 +1,293 @@
1+package config
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/fur/provider"
5+)
6+
7+// MockProviders returns a mock list of providers for testing.
8+// This avoids making API calls during tests and provides consistent test data.
9+// Simplified version with only default models from each provider.
10+func MockProviders() []provider.Provider {
11+ return []provider.Provider{
12+ {
13+ Name: "Anthropic",
14+ ID: provider.InferenceProviderAnthropic,
15+ APIKey: "$ANTHROPIC_API_KEY",
16+ APIEndpoint: "$ANTHROPIC_API_ENDPOINT",
17+ Type: provider.TypeAnthropic,
18+ DefaultLargeModelID: "claude-sonnet-4-20250514",
19+ DefaultSmallModelID: "claude-3-5-haiku-20241022",
20+ Models: []provider.Model{
21+ {
22+ ID: "claude-sonnet-4-20250514",
23+ Name: "Claude Sonnet 4",
24+ CostPer1MIn: 3.0,
25+ CostPer1MOut: 15.0,
26+ CostPer1MInCached: 3.75,
27+ CostPer1MOutCached: 0.3,
28+ ContextWindow: 200000,
29+ DefaultMaxTokens: 50000,
30+ CanReason: true,
31+ SupportsImages: true,
32+ },
33+ {
34+ ID: "claude-3-5-haiku-20241022",
35+ Name: "Claude 3.5 Haiku",
36+ CostPer1MIn: 0.8,
37+ CostPer1MOut: 4.0,
38+ CostPer1MInCached: 1.0,
39+ CostPer1MOutCached: 0.08,
40+ ContextWindow: 200000,
41+ DefaultMaxTokens: 5000,
42+ CanReason: false,
43+ SupportsImages: true,
44+ },
45+ },
46+ },
47+ {
48+ Name: "OpenAI",
49+ ID: provider.InferenceProviderOpenAI,
50+ APIKey: "$OPENAI_API_KEY",
51+ APIEndpoint: "$OPENAI_API_ENDPOINT",
52+ Type: provider.TypeOpenAI,
53+ DefaultLargeModelID: "codex-mini-latest",
54+ DefaultSmallModelID: "gpt-4o",
55+ Models: []provider.Model{
56+ {
57+ ID: "codex-mini-latest",
58+ Name: "Codex Mini",
59+ CostPer1MIn: 1.5,
60+ CostPer1MOut: 6.0,
61+ CostPer1MInCached: 0.0,
62+ CostPer1MOutCached: 0.375,
63+ ContextWindow: 200000,
64+ DefaultMaxTokens: 50000,
65+ CanReason: true,
66+ HasReasoningEffort: true,
67+ DefaultReasoningEffort: "medium",
68+ SupportsImages: true,
69+ },
70+ {
71+ ID: "gpt-4o",
72+ Name: "GPT-4o",
73+ CostPer1MIn: 2.5,
74+ CostPer1MOut: 10.0,
75+ CostPer1MInCached: 0.0,
76+ CostPer1MOutCached: 1.25,
77+ ContextWindow: 128000,
78+ DefaultMaxTokens: 20000,
79+ CanReason: false,
80+ SupportsImages: true,
81+ },
82+ },
83+ },
84+ {
85+ Name: "Google Gemini",
86+ ID: provider.InferenceProviderGemini,
87+ APIKey: "$GEMINI_API_KEY",
88+ APIEndpoint: "$GEMINI_API_ENDPOINT",
89+ Type: provider.TypeGemini,
90+ DefaultLargeModelID: "gemini-2.5-pro",
91+ DefaultSmallModelID: "gemini-2.5-flash",
92+ Models: []provider.Model{
93+ {
94+ ID: "gemini-2.5-pro",
95+ Name: "Gemini 2.5 Pro",
96+ CostPer1MIn: 1.25,
97+ CostPer1MOut: 10.0,
98+ CostPer1MInCached: 1.625,
99+ CostPer1MOutCached: 0.31,
100+ ContextWindow: 1048576,
101+ DefaultMaxTokens: 50000,
102+ CanReason: true,
103+ SupportsImages: true,
104+ },
105+ {
106+ ID: "gemini-2.5-flash",
107+ Name: "Gemini 2.5 Flash",
108+ CostPer1MIn: 0.3,
109+ CostPer1MOut: 2.5,
110+ CostPer1MInCached: 0.3833,
111+ CostPer1MOutCached: 0.075,
112+ ContextWindow: 1048576,
113+ DefaultMaxTokens: 50000,
114+ CanReason: true,
115+ SupportsImages: true,
116+ },
117+ },
118+ },
119+ {
120+ Name: "xAI",
121+ ID: provider.InferenceProviderXAI,
122+ APIKey: "$XAI_API_KEY",
123+ APIEndpoint: "https://api.x.ai/v1",
124+ Type: provider.TypeXAI,
125+ DefaultLargeModelID: "grok-3",
126+ DefaultSmallModelID: "grok-3-mini",
127+ Models: []provider.Model{
128+ {
129+ ID: "grok-3",
130+ Name: "Grok 3",
131+ CostPer1MIn: 3.0,
132+ CostPer1MOut: 15.0,
133+ CostPer1MInCached: 0.0,
134+ CostPer1MOutCached: 0.75,
135+ ContextWindow: 131072,
136+ DefaultMaxTokens: 20000,
137+ CanReason: false,
138+ SupportsImages: false,
139+ },
140+ {
141+ ID: "grok-3-mini",
142+ Name: "Grok 3 Mini",
143+ CostPer1MIn: 0.3,
144+ CostPer1MOut: 0.5,
145+ CostPer1MInCached: 0.0,
146+ CostPer1MOutCached: 0.075,
147+ ContextWindow: 131072,
148+ DefaultMaxTokens: 20000,
149+ CanReason: true,
150+ SupportsImages: false,
151+ },
152+ },
153+ },
154+ {
155+ Name: "Azure OpenAI",
156+ ID: provider.InferenceProviderAzure,
157+ APIKey: "$AZURE_OPENAI_API_KEY",
158+ APIEndpoint: "$AZURE_OPENAI_API_ENDPOINT",
159+ Type: provider.TypeAzure,
160+ DefaultLargeModelID: "o4-mini",
161+ DefaultSmallModelID: "gpt-4o",
162+ Models: []provider.Model{
163+ {
164+ ID: "o4-mini",
165+ Name: "o4 Mini",
166+ CostPer1MIn: 1.1,
167+ CostPer1MOut: 4.4,
168+ CostPer1MInCached: 0.0,
169+ CostPer1MOutCached: 0.275,
170+ ContextWindow: 200000,
171+ DefaultMaxTokens: 50000,
172+ CanReason: true,
173+ HasReasoningEffort: false,
174+ DefaultReasoningEffort: "medium",
175+ SupportsImages: true,
176+ },
177+ {
178+ ID: "gpt-4o",
179+ Name: "GPT-4o",
180+ CostPer1MIn: 2.5,
181+ CostPer1MOut: 10.0,
182+ CostPer1MInCached: 0.0,
183+ CostPer1MOutCached: 1.25,
184+ ContextWindow: 128000,
185+ DefaultMaxTokens: 20000,
186+ CanReason: false,
187+ SupportsImages: true,
188+ },
189+ },
190+ },
191+ {
192+ Name: "AWS Bedrock",
193+ ID: provider.InferenceProviderBedrock,
194+ Type: provider.TypeBedrock,
195+ DefaultLargeModelID: "anthropic.claude-sonnet-4-20250514-v1:0",
196+ DefaultSmallModelID: "anthropic.claude-3-5-haiku-20241022-v1:0",
197+ Models: []provider.Model{
198+ {
199+ ID: "anthropic.claude-sonnet-4-20250514-v1:0",
200+ Name: "AWS Claude Sonnet 4",
201+ CostPer1MIn: 3.0,
202+ CostPer1MOut: 15.0,
203+ CostPer1MInCached: 3.75,
204+ CostPer1MOutCached: 0.3,
205+ ContextWindow: 200000,
206+ DefaultMaxTokens: 50000,
207+ CanReason: true,
208+ SupportsImages: true,
209+ },
210+ {
211+ ID: "anthropic.claude-3-5-haiku-20241022-v1:0",
212+ Name: "AWS Claude 3.5 Haiku",
213+ CostPer1MIn: 0.8,
214+ CostPer1MOut: 4.0,
215+ CostPer1MInCached: 1.0,
216+ CostPer1MOutCached: 0.08,
217+ ContextWindow: 200000,
218+ DefaultMaxTokens: 50000,
219+ CanReason: false,
220+ SupportsImages: true,
221+ },
222+ },
223+ },
224+ {
225+ Name: "Google Vertex AI",
226+ ID: provider.InferenceProviderVertexAI,
227+ Type: provider.TypeVertexAI,
228+ DefaultLargeModelID: "gemini-2.5-pro",
229+ DefaultSmallModelID: "gemini-2.5-flash",
230+ Models: []provider.Model{
231+ {
232+ ID: "gemini-2.5-pro",
233+ Name: "Gemini 2.5 Pro",
234+ CostPer1MIn: 1.25,
235+ CostPer1MOut: 10.0,
236+ CostPer1MInCached: 1.625,
237+ CostPer1MOutCached: 0.31,
238+ ContextWindow: 1048576,
239+ DefaultMaxTokens: 50000,
240+ CanReason: true,
241+ SupportsImages: true,
242+ },
243+ {
244+ ID: "gemini-2.5-flash",
245+ Name: "Gemini 2.5 Flash",
246+ CostPer1MIn: 0.3,
247+ CostPer1MOut: 2.5,
248+ CostPer1MInCached: 0.3833,
249+ CostPer1MOutCached: 0.075,
250+ ContextWindow: 1048576,
251+ DefaultMaxTokens: 50000,
252+ CanReason: true,
253+ SupportsImages: true,
254+ },
255+ },
256+ },
257+ {
258+ Name: "OpenRouter",
259+ ID: provider.InferenceProviderOpenRouter,
260+ APIKey: "$OPENROUTER_API_KEY",
261+ APIEndpoint: "https://openrouter.ai/api/v1",
262+ Type: provider.TypeOpenAI,
263+ DefaultLargeModelID: "anthropic/claude-sonnet-4",
264+ DefaultSmallModelID: "anthropic/claude-haiku-3.5",
265+ Models: []provider.Model{
266+ {
267+ ID: "anthropic/claude-sonnet-4",
268+ Name: "Anthropic: Claude Sonnet 4",
269+ CostPer1MIn: 3.0,
270+ CostPer1MOut: 15.0,
271+ CostPer1MInCached: 3.75,
272+ CostPer1MOutCached: 0.3,
273+ ContextWindow: 200000,
274+ DefaultMaxTokens: 32000,
275+ CanReason: true,
276+ SupportsImages: true,
277+ },
278+ {
279+ ID: "anthropic/claude-haiku-3.5",
280+ Name: "Anthropic: Claude 3.5 Haiku",
281+ CostPer1MIn: 0.8,
282+ CostPer1MOut: 4.0,
283+ CostPer1MInCached: 1.0,
284+ CostPer1MOutCached: 0.08,
285+ ContextWindow: 200000,
286+ DefaultMaxTokens: 4096,
287+ CanReason: false,
288+ SupportsImages: true,
289+ },
290+ },
291+ },
292+ }
293+}
internal/config/provider_test.gonew file+81
@@ -0,0 +1,81 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/fur/provider"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestProviders_MockEnabled(t *testing.T) {
12+ originalUseMock := UseMockProviders
13+ UseMockProviders = true
14+ defer func() {
15+ UseMockProviders = originalUseMock
16+ ResetProviders()
17+ }()
18+
19+ ResetProviders()
20+ providers := Providers()
21+ require.NotEmpty(t, providers)
22+
23+ providerIDs := make(map[provider.InferenceProvider]bool)
24+ for _, p := range providers {
25+ providerIDs[p.ID] = true
26+ }
27+
28+ assert.True(t, providerIDs[provider.InferenceProviderAnthropic])
29+ assert.True(t, providerIDs[provider.InferenceProviderOpenAI])
30+ assert.True(t, providerIDs[provider.InferenceProviderGemini])
31+}
32+
33+func TestProviders_ResetFunctionality(t *testing.T) {
34+ UseMockProviders = true
35+ defer func() {
36+ UseMockProviders = false
37+ ResetProviders()
38+ }()
39+
40+ providers1 := Providers()
41+ require.NotEmpty(t, providers1)
42+
43+ ResetProviders()
44+ providers2 := Providers()
45+ require.NotEmpty(t, providers2)
46+
47+ assert.Equal(t, len(providers1), len(providers2))
48+}
49+
50+func TestProviders_ModelCapabilities(t *testing.T) {
51+ originalUseMock := UseMockProviders
52+ UseMockProviders = true
53+ defer func() {
54+ UseMockProviders = originalUseMock
55+ ResetProviders()
56+ }()
57+
58+ ResetProviders()
59+ providers := Providers()
60+
61+ var openaiProvider provider.Provider
62+ for _, p := range providers {
63+ if p.ID == provider.InferenceProviderOpenAI {
64+ openaiProvider = p
65+ break
66+ }
67+ }
68+ require.NotEmpty(t, openaiProvider.ID)
69+
70+ var foundReasoning, foundNonReasoning bool
71+ for _, model := range openaiProvider.Models {
72+ if model.CanReason && model.HasReasoningEffort {
73+ foundReasoning = true
74+ } else if !model.CanReason {
75+ foundNonReasoning = true
76+ }
77+ }
78+
79+ assert.True(t, foundReasoning)
80+ assert.True(t, foundNonReasoning)
81+}
internal/config/shell.gonew file+73
@@ -0,0 +1,73 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "time"
9+
10+ "github.com/charmbracelet/crush/internal/logging"
11+ "github.com/charmbracelet/crush/internal/shell"
12+)
13+
14+// ExecuteCommand executes a shell command and returns the output
15+// This is a shared utility that can be used by both provider config and tools
16+func ExecuteCommand(ctx context.Context, command string, workingDir string) (string, error) {
17+ if workingDir == "" {
18+ workingDir = WorkingDirectory()
19+ }
20+
21+ persistentShell := shell.NewShell(&shell.Options{WorkingDir: workingDir})
22+
23+ stdout, stderr, err := persistentShell.Exec(ctx, command)
24+ if err != nil {
25+ logging.Debug("Command execution failed", "command", command, "error", err, "stderr", stderr)
26+ return "", fmt.Errorf("command execution failed: %w", err)
27+ }
28+
29+ return strings.TrimSpace(stdout), nil
30+}
31+
32+// ResolveAPIKey resolves an API key that can be either:
33+// - A direct string value
34+// - An environment variable (prefixed with $)
35+// - A shell command (wrapped in $(...))
36+func ResolveAPIKey(apiKey string) (string, error) {
37+ if !strings.HasPrefix(apiKey, "$") {
38+ return apiKey, nil
39+ }
40+
41+ if strings.HasPrefix(apiKey, "$(") && strings.HasSuffix(apiKey, ")") {
42+ command := strings.TrimSuffix(strings.TrimPrefix(apiKey, "$("), ")")
43+ logging.Debug("Resolving API key from command", "command", command)
44+ return resolveCommandAPIKey(command)
45+ }
46+
47+ envVar := strings.TrimPrefix(apiKey, "$")
48+ if value := os.Getenv(envVar); value != "" {
49+ logging.Debug("Resolved environment variable", "envVar", envVar, "value", value)
50+ return value, nil
51+ }
52+
53+ logging.Debug("Environment variable not found", "envVar", envVar)
54+
55+ return "", fmt.Errorf("environment variable %s not found", envVar)
56+}
57+
58+// resolveCommandAPIKey executes a command to get an API key, with caching support
59+func resolveCommandAPIKey(command string) (string, error) {
60+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
61+ defer cancel()
62+
63+ logging.Debug("Executing command for API key", "command", command)
64+
65+ workingDir := WorkingDirectory()
66+
67+ result, err := ExecuteCommand(ctx, command, workingDir)
68+ if err != nil {
69+ return "", fmt.Errorf("failed to execute API key command: %w", err)
70+ }
71+ logging.Debug("Command executed successfully", "command", command, "result", result)
72+ return result, nil
73+}
internal/config/validation_test.gonew file+462
@@ -0,0 +1,462 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/fur/provider"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestConfig_Validate_ValidConfig(t *testing.T) {
12+ cfg := &Config{
13+ Models: PreferredModels{
14+ Large: PreferredModel{
15+ ModelID: "gpt-4",
16+ Provider: provider.InferenceProviderOpenAI,
17+ },
18+ Small: PreferredModel{
19+ ModelID: "gpt-3.5-turbo",
20+ Provider: provider.InferenceProviderOpenAI,
21+ },
22+ },
23+ Providers: map[provider.InferenceProvider]ProviderConfig{
24+ provider.InferenceProviderOpenAI: {
25+ ID: provider.InferenceProviderOpenAI,
26+ APIKey: "test-key",
27+ ProviderType: provider.TypeOpenAI,
28+ DefaultLargeModel: "gpt-4",
29+ DefaultSmallModel: "gpt-3.5-turbo",
30+ Models: []Model{
31+ {
32+ ID: "gpt-4",
33+ Name: "GPT-4",
34+ ContextWindow: 8192,
35+ DefaultMaxTokens: 4096,
36+ CostPer1MIn: 30.0,
37+ CostPer1MOut: 60.0,
38+ },
39+ {
40+ ID: "gpt-3.5-turbo",
41+ Name: "GPT-3.5 Turbo",
42+ ContextWindow: 4096,
43+ DefaultMaxTokens: 2048,
44+ CostPer1MIn: 1.5,
45+ CostPer1MOut: 2.0,
46+ },
47+ },
48+ },
49+ },
50+ Agents: map[AgentID]Agent{
51+ AgentCoder: {
52+ ID: AgentCoder,
53+ Name: "Coder",
54+ Description: "An agent that helps with executing coding tasks.",
55+ Model: LargeModel,
56+ ContextPaths: []string{"CRUSH.md"},
57+ },
58+ AgentTask: {
59+ ID: AgentTask,
60+ Name: "Task",
61+ Description: "An agent that helps with searching for context and finding implementation details.",
62+ Model: LargeModel,
63+ ContextPaths: []string{"CRUSH.md"},
64+ AllowedTools: []string{"glob", "grep", "ls", "sourcegraph", "view"},
65+ AllowedMCP: map[string][]string{},
66+ AllowedLSP: []string{},
67+ },
68+ },
69+ MCP: map[string]MCP{},
70+ LSP: map[string]LSPConfig{},
71+ Options: Options{
72+ DataDirectory: ".crush",
73+ ContextPaths: []string{"CRUSH.md"},
74+ },
75+ }
76+
77+ err := cfg.Validate()
78+ assert.NoError(t, err)
79+}
80+
81+func TestConfig_Validate_MissingAPIKey(t *testing.T) {
82+ cfg := &Config{
83+ Providers: map[provider.InferenceProvider]ProviderConfig{
84+ provider.InferenceProviderOpenAI: {
85+ ID: provider.InferenceProviderOpenAI,
86+ ProviderType: provider.TypeOpenAI,
87+ // Missing APIKey
88+ },
89+ },
90+ Options: Options{
91+ DataDirectory: ".crush",
92+ ContextPaths: []string{"CRUSH.md"},
93+ },
94+ }
95+
96+ err := cfg.Validate()
97+ require.Error(t, err)
98+ assert.Contains(t, err.Error(), "API key is required")
99+}
100+
101+func TestConfig_Validate_InvalidProviderType(t *testing.T) {
102+ cfg := &Config{
103+ Providers: map[provider.InferenceProvider]ProviderConfig{
104+ provider.InferenceProviderOpenAI: {
105+ ID: provider.InferenceProviderOpenAI,
106+ APIKey: "test-key",
107+ ProviderType: provider.Type("invalid"),
108+ },
109+ },
110+ Options: Options{
111+ DataDirectory: ".crush",
112+ ContextPaths: []string{"CRUSH.md"},
113+ },
114+ }
115+
116+ err := cfg.Validate()
117+ require.Error(t, err)
118+ assert.Contains(t, err.Error(), "invalid provider type")
119+}
120+
121+func TestConfig_Validate_CustomProviderMissingBaseURL(t *testing.T) {
122+ customProvider := provider.InferenceProvider("custom-provider")
123+ cfg := &Config{
124+ Providers: map[provider.InferenceProvider]ProviderConfig{
125+ customProvider: {
126+ ID: customProvider,
127+ APIKey: "test-key",
128+ ProviderType: provider.TypeOpenAI,
129+ // Missing BaseURL for custom provider
130+ },
131+ },
132+ Options: Options{
133+ DataDirectory: ".crush",
134+ ContextPaths: []string{"CRUSH.md"},
135+ },
136+ }
137+
138+ err := cfg.Validate()
139+ require.Error(t, err)
140+ assert.Contains(t, err.Error(), "BaseURL is required for custom providers")
141+}
142+
143+func TestConfig_Validate_DuplicateModelIDs(t *testing.T) {
144+ cfg := &Config{
145+ Providers: map[provider.InferenceProvider]ProviderConfig{
146+ provider.InferenceProviderOpenAI: {
147+ ID: provider.InferenceProviderOpenAI,
148+ APIKey: "test-key",
149+ ProviderType: provider.TypeOpenAI,
150+ Models: []Model{
151+ {
152+ ID: "gpt-4",
153+ Name: "GPT-4",
154+ ContextWindow: 8192,
155+ DefaultMaxTokens: 4096,
156+ },
157+ {
158+ ID: "gpt-4", // Duplicate ID
159+ Name: "GPT-4 Duplicate",
160+ ContextWindow: 8192,
161+ DefaultMaxTokens: 4096,
162+ },
163+ },
164+ },
165+ },
166+ Options: Options{
167+ DataDirectory: ".crush",
168+ ContextPaths: []string{"CRUSH.md"},
169+ },
170+ }
171+
172+ err := cfg.Validate()
173+ require.Error(t, err)
174+ assert.Contains(t, err.Error(), "duplicate model ID")
175+}
176+
177+func TestConfig_Validate_InvalidModelFields(t *testing.T) {
178+ cfg := &Config{
179+ Providers: map[provider.InferenceProvider]ProviderConfig{
180+ provider.InferenceProviderOpenAI: {
181+ ID: provider.InferenceProviderOpenAI,
182+ APIKey: "test-key",
183+ ProviderType: provider.TypeOpenAI,
184+ Models: []Model{
185+ {
186+ ID: "", // Empty ID
187+ Name: "GPT-4",
188+ ContextWindow: 0, // Invalid context window
189+ DefaultMaxTokens: -1, // Invalid max tokens
190+ CostPer1MIn: -5.0, // Negative cost
191+ },
192+ },
193+ },
194+ },
195+ Options: Options{
196+ DataDirectory: ".crush",
197+ ContextPaths: []string{"CRUSH.md"},
198+ },
199+ }
200+
201+ err := cfg.Validate()
202+ require.Error(t, err)
203+ validationErr := err.(ValidationErrors)
204+ assert.True(t, len(validationErr) >= 4) // Should have multiple validation errors
205+}
206+
207+func TestConfig_Validate_DefaultModelNotFound(t *testing.T) {
208+ cfg := &Config{
209+ Providers: map[provider.InferenceProvider]ProviderConfig{
210+ provider.InferenceProviderOpenAI: {
211+ ID: provider.InferenceProviderOpenAI,
212+ APIKey: "test-key",
213+ ProviderType: provider.TypeOpenAI,
214+ DefaultLargeModel: "nonexistent-model",
215+ Models: []Model{
216+ {
217+ ID: "gpt-4",
218+ Name: "GPT-4",
219+ ContextWindow: 8192,
220+ DefaultMaxTokens: 4096,
221+ },
222+ },
223+ },
224+ },
225+ Options: Options{
226+ DataDirectory: ".crush",
227+ ContextPaths: []string{"CRUSH.md"},
228+ },
229+ }
230+
231+ err := cfg.Validate()
232+ require.Error(t, err)
233+ assert.Contains(t, err.Error(), "default large model 'nonexistent-model' not found")
234+}
235+
236+func TestConfig_Validate_AgentIDMismatch(t *testing.T) {
237+ cfg := &Config{
238+ Agents: map[AgentID]Agent{
239+ AgentCoder: {
240+ ID: AgentTask, // Wrong ID
241+ Name: "Coder",
242+ },
243+ },
244+ Options: Options{
245+ DataDirectory: ".crush",
246+ ContextPaths: []string{"CRUSH.md"},
247+ },
248+ }
249+
250+ err := cfg.Validate()
251+ require.Error(t, err)
252+ assert.Contains(t, err.Error(), "agent ID mismatch")
253+}
254+
255+func TestConfig_Validate_InvalidAgentModelType(t *testing.T) {
256+ cfg := &Config{
257+ Agents: map[AgentID]Agent{
258+ AgentCoder: {
259+ ID: AgentCoder,
260+ Name: "Coder",
261+ Model: ModelType("invalid"),
262+ },
263+ },
264+ Options: Options{
265+ DataDirectory: ".crush",
266+ ContextPaths: []string{"CRUSH.md"},
267+ },
268+ }
269+
270+ err := cfg.Validate()
271+ require.Error(t, err)
272+ assert.Contains(t, err.Error(), "invalid model type")
273+}
274+
275+func TestConfig_Validate_UnknownTool(t *testing.T) {
276+ cfg := &Config{
277+ Agents: map[AgentID]Agent{
278+ AgentID("custom-agent"): {
279+ ID: AgentID("custom-agent"),
280+ Name: "Custom Agent",
281+ Model: LargeModel,
282+ AllowedTools: []string{"unknown-tool"},
283+ },
284+ },
285+ Options: Options{
286+ DataDirectory: ".crush",
287+ ContextPaths: []string{"CRUSH.md"},
288+ },
289+ }
290+
291+ err := cfg.Validate()
292+ require.Error(t, err)
293+ assert.Contains(t, err.Error(), "unknown tool")
294+}
295+
296+func TestConfig_Validate_MCPReference(t *testing.T) {
297+ cfg := &Config{
298+ Agents: map[AgentID]Agent{
299+ AgentID("custom-agent"): {
300+ ID: AgentID("custom-agent"),
301+ Name: "Custom Agent",
302+ Model: LargeModel,
303+ AllowedMCP: map[string][]string{"nonexistent-mcp": nil},
304+ },
305+ },
306+ MCP: map[string]MCP{}, // Empty MCP map
307+ Options: Options{
308+ DataDirectory: ".crush",
309+ ContextPaths: []string{"CRUSH.md"},
310+ },
311+ }
312+
313+ err := cfg.Validate()
314+ require.Error(t, err)
315+ assert.Contains(t, err.Error(), "referenced MCP 'nonexistent-mcp' not found")
316+}
317+
318+func TestConfig_Validate_InvalidMCPType(t *testing.T) {
319+ cfg := &Config{
320+ MCP: map[string]MCP{
321+ "test-mcp": {
322+ Type: MCPType("invalid"),
323+ },
324+ },
325+ Options: Options{
326+ DataDirectory: ".crush",
327+ ContextPaths: []string{"CRUSH.md"},
328+ },
329+ }
330+
331+ err := cfg.Validate()
332+ require.Error(t, err)
333+ assert.Contains(t, err.Error(), "invalid MCP type")
334+}
335+
336+func TestConfig_Validate_MCPMissingCommand(t *testing.T) {
337+ cfg := &Config{
338+ MCP: map[string]MCP{
339+ "test-mcp": {
340+ Type: MCPStdio,
341+ // Missing Command
342+ },
343+ },
344+ Options: Options{
345+ DataDirectory: ".crush",
346+ ContextPaths: []string{"CRUSH.md"},
347+ },
348+ }
349+
350+ err := cfg.Validate()
351+ require.Error(t, err)
352+ assert.Contains(t, err.Error(), "command is required for stdio MCP")
353+}
354+
355+func TestConfig_Validate_LSPMissingCommand(t *testing.T) {
356+ cfg := &Config{
357+ LSP: map[string]LSPConfig{
358+ "test-lsp": {
359+ // Missing Command
360+ },
361+ },
362+ Options: Options{
363+ DataDirectory: ".crush",
364+ ContextPaths: []string{"CRUSH.md"},
365+ },
366+ }
367+
368+ err := cfg.Validate()
369+ require.Error(t, err)
370+ assert.Contains(t, err.Error(), "command is required for LSP")
371+}
372+
373+func TestConfig_Validate_NoValidProviders(t *testing.T) {
374+ cfg := &Config{
375+ Providers: map[provider.InferenceProvider]ProviderConfig{
376+ provider.InferenceProviderOpenAI: {
377+ ID: provider.InferenceProviderOpenAI,
378+ APIKey: "test-key",
379+ ProviderType: provider.TypeOpenAI,
380+ Disabled: true, // Disabled
381+ },
382+ },
383+ Options: Options{
384+ DataDirectory: ".crush",
385+ ContextPaths: []string{"CRUSH.md"},
386+ },
387+ }
388+
389+ err := cfg.Validate()
390+ require.Error(t, err)
391+ assert.Contains(t, err.Error(), "at least one non-disabled provider is required")
392+}
393+
394+func TestConfig_Validate_MissingDefaultAgents(t *testing.T) {
395+ cfg := &Config{
396+ Providers: map[provider.InferenceProvider]ProviderConfig{
397+ provider.InferenceProviderOpenAI: {
398+ ID: provider.InferenceProviderOpenAI,
399+ APIKey: "test-key",
400+ ProviderType: provider.TypeOpenAI,
401+ },
402+ },
403+ Agents: map[AgentID]Agent{}, // Missing default agents
404+ Options: Options{
405+ DataDirectory: ".crush",
406+ ContextPaths: []string{"CRUSH.md"},
407+ },
408+ }
409+
410+ err := cfg.Validate()
411+ require.Error(t, err)
412+ assert.Contains(t, err.Error(), "coder agent is required")
413+ assert.Contains(t, err.Error(), "task agent is required")
414+}
415+
416+func TestConfig_Validate_KnownAgentProtection(t *testing.T) {
417+ cfg := &Config{
418+ Agents: map[AgentID]Agent{
419+ AgentCoder: {
420+ ID: AgentCoder,
421+ Name: "Modified Coder", // Should not be allowed
422+ Description: "Modified description", // Should not be allowed
423+ Model: LargeModel,
424+ },
425+ },
426+ Options: Options{
427+ DataDirectory: ".crush",
428+ ContextPaths: []string{"CRUSH.md"},
429+ },
430+ }
431+
432+ err := cfg.Validate()
433+ require.Error(t, err)
434+ assert.Contains(t, err.Error(), "coder agent name cannot be changed")
435+ assert.Contains(t, err.Error(), "coder agent description cannot be changed")
436+}
437+
438+func TestConfig_Validate_EmptyDataDirectory(t *testing.T) {
439+ cfg := &Config{
440+ Options: Options{
441+ DataDirectory: "", // Empty
442+ ContextPaths: []string{"CRUSH.md"},
443+ },
444+ }
445+
446+ err := cfg.Validate()
447+ require.Error(t, err)
448+ assert.Contains(t, err.Error(), "data directory is required")
449+}
450+
451+func TestConfig_Validate_EmptyContextPath(t *testing.T) {
452+ cfg := &Config{
453+ Options: Options{
454+ DataDirectory: ".crush",
455+ ContextPaths: []string{""}, // Empty context path
456+ },
457+ }
458+
459+ err := cfg.Validate()
460+ require.Error(t, err)
461+ assert.Contains(t, err.Error(), "context path cannot be empty")
462+}
internal/db/connect.go+1−1
@@ -17,7 +17,7 @@ import (
1717)
1818
1919func Connect(ctx context.Context) (*sql.DB, error) {
20− dataDir := config.Get().Data.Directory
20+ dataDir := config.Get().Options.DataDirectory
2121 if dataDir == "" {
2222 return nil, fmt.Errorf("data.dir is not set")
2323 }
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/fsext/fileutil.go+1−1
@@ -55,7 +55,7 @@ func GetRgSearchCmd(pattern, path, include string) *exec.Cmd {
5555 return nil
5656 }
5757 // Use -n to show line numbers and include the matched line
58− args := []string{"-n", pattern}
58+ args := []string{"-H", "-n", pattern}
5959 if include != "" {
6060 args = append(args, "--glob", include)
6161 }
internal/fur/client/client.gonew file+63
@@ -0,0 +1,63 @@
1+// Package client provides a client for interacting with the fur service.
2+package client
3+
4+import (
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "os"
9+
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+)
12+
13+const defaultURL = "https://fur.charmcli.dev"
14+
15+// Client represents a client for the fur service.
16+type Client struct {
17+ baseURL string
18+ httpClient *http.Client
19+}
20+
21+// New creates a new client instance
22+// Uses FUR_URL environment variable or falls back to localhost:8080.
23+func New() *Client {
24+ baseURL := os.Getenv("FUR_URL")
25+ if baseURL == "" {
26+ baseURL = defaultURL
27+ }
28+
29+ return &Client{
30+ baseURL: baseURL,
31+ httpClient: &http.Client{},
32+ }
33+}
34+
35+// NewWithURL creates a new client with a specific URL.
36+func NewWithURL(url string) *Client {
37+ return &Client{
38+ baseURL: url,
39+ httpClient: &http.Client{},
40+ }
41+}
42+
43+// GetProviders retrieves all available providers from the service.
44+func (c *Client) GetProviders() ([]provider.Provider, error) {
45+ url := fmt.Sprintf("%s/providers", c.baseURL)
46+
47+ resp, err := c.httpClient.Get(url) //nolint:noctx
48+ if err != nil {
49+ return nil, fmt.Errorf("failed to make request: %w", err)
50+ }
51+ defer resp.Body.Close() //nolint:errcheck
52+
53+ if resp.StatusCode != http.StatusOK {
54+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
55+ }
56+
57+ var providers []provider.Provider
58+ if err := json.NewDecoder(resp.Body).Decode(&providers); err != nil {
59+ return nil, fmt.Errorf("failed to decode response: %w", err)
60+ }
61+
62+ return providers, nil
63+}
internal/fur/provider/provider.gonew file+73
@@ -0,0 +1,73 @@
1+// Package provider provides types and constants for AI providers.
2+package provider
3+
4+// Type represents the type of AI provider.
5+type Type string
6+
7+// All the supported AI provider types.
8+const (
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+)
17+
18+// InferenceProvider represents the inference provider identifier.
19+type InferenceProvider string
20+
21+// All the inference providers supported by the system.
22+const (
23+ InferenceProviderOpenAI InferenceProvider = "openai"
24+ InferenceProviderAnthropic InferenceProvider = "anthropic"
25+ InferenceProviderGemini InferenceProvider = "gemini"
26+ InferenceProviderAzure InferenceProvider = "azure"
27+ InferenceProviderBedrock InferenceProvider = "bedrock"
28+ InferenceProviderVertexAI InferenceProvider = "vertexai"
29+ InferenceProviderXAI InferenceProvider = "xai"
30+ InferenceProviderOpenRouter InferenceProvider = "openrouter"
31+)
32+
33+// Provider represents an AI provider configuration.
34+type Provider struct {
35+ Name string `json:"name"`
36+ ID InferenceProvider `json:"id"`
37+ APIKey string `json:"api_key,omitempty"`
38+ APIEndpoint string `json:"api_endpoint,omitempty"`
39+ Type Type `json:"type,omitempty"`
40+ DefaultLargeModelID string `json:"default_large_model_id,omitempty"`
41+ DefaultSmallModelID string `json:"default_small_model_id,omitempty"`
42+ Models []Model `json:"models,omitempty"`
43+}
44+
45+// Model represents an AI model configuration.
46+type Model struct {
47+ ID string `json:"id"`
48+ Name string `json:"model"`
49+ CostPer1MIn float64 `json:"cost_per_1m_in"`
50+ CostPer1MOut float64 `json:"cost_per_1m_out"`
51+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
52+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
53+ ContextWindow int64 `json:"context_window"`
54+ DefaultMaxTokens int64 `json:"default_max_tokens"`
55+ CanReason bool `json:"can_reason"`
56+ HasReasoningEffort bool `json:"has_reasoning_efforts"`
57+ DefaultReasoningEffort string `json:"default_reasoning_effort,omitempty"`
58+ SupportsImages bool `json:"supports_attachments"`
59+}
60+
61+// KnownProviders returns all the known inference providers.
62+func KnownProviders() []InferenceProvider {
63+ return []InferenceProvider{
64+ InferenceProviderOpenAI,
65+ InferenceProviderAnthropic,
66+ InferenceProviderGemini,
67+ InferenceProviderAzure,
68+ InferenceProviderBedrock,
69+ InferenceProviderVertexAI,
70+ InferenceProviderXAI,
71+ InferenceProviderOpenRouter,
72+ }
73+}
internal/llm/agent/agent-tool.go+14−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 (
@@ -26,6 +24,10 @@ type AgentParams struct {
2624 Prompt string `json:"prompt"`
2725}
2826
27+func (b *agentTool) Name() string {
28+ return AgentToolName
29+}
30+
2931func (b *agentTool) Info() tools.ToolInfo {
3032 return tools.ToolInfo{
3133 Name: AgentToolName,
@@ -54,17 +56,12 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
5456 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
5557 }
5658
57− agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
58− if err != nil {
59− return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
60− }
61−
6259 session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
6360 if err != nil {
6461 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
6562 }
6663
67− done, err := agent.Run(ctx, session.ID, params.Prompt)
64+ done, err := b.agent.Run(ctx, session.ID, params.Prompt)
6865 if err != nil {
6966 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
7067 }
@@ -97,13 +94,13 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
9794}
9895
9996func NewAgentTool(
100− Sessions session.Service,
101− Messages message.Service,
102− LspClients map[string]*lsp.Client,
97+ agent Service,
98+ sessions session.Service,
99+ messages message.Service,
103100) tools.BaseTool {
104101 return &agentTool{
105− sessions: Sessions,
106− messages: Messages,
107− lspClients: LspClients,
102+ sessions: sessions,
103+ messages: messages,
104+ agent: agent,
108105 }
109106}
internal/llm/agent/agent.go+267−111
@@ -4,16 +4,18 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "slices"
78 "strings"
89 "sync"
910 "time"
1011
1112 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
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,72 +49,189 @@ type AgentEvent struct {
4749
4850type Service interface {
4951 pubsub.Suscriber[AgentEvent]
50− Model() models.Model
52+ Model() config.Model
53+ EffectiveMaxTokens() int64
5154 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
5255 Cancel(sessionID string)
5356 CancelAll()
5457 IsSessionBusy(sessionID string) bool
5558 IsBusy() bool
56− Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
5759 Summarize(ctx context.Context, sessionID string) error
60+ UpdateModel() error
5861}
5962
6063type agent struct {
6164 *pubsub.Broker[AgentEvent]
65+ agentCfg config.Agent
6266 sessions session.Service
6367 messages message.Service
6468
65− tools []tools.BaseTool
66− provider provider.Provider
69+ tools []tools.BaseTool
70+ provider provider.Provider
71+ providerID string
6772
68− titleProvider provider.Provider
69− summarizeProvider provider.Provider
73+ titleProvider provider.Provider
74+ summarizeProvider provider.Provider
75+ summarizeProviderID string
7076
7177 activeRequests sync.Map
7278}
7379
80+var agentPromptMap = map[config.AgentID]prompt.PromptID{
81+ config.AgentCoder: prompt.PromptCoder,
82+ config.AgentTask: prompt.PromptTask,
83+}
84+
7485func NewAgent(
75− agentName config.AgentName,
86+ agentCfg config.Agent,
87+ // These services are needed in the tools
88+ permissions permission.Service,
7689 sessions session.Service,
7790 messages message.Service,
78− agentTools []tools.BaseTool,
91+ history history.Service,
92+ lspClients map[string]*lsp.Client,
7993) (Service, error) {
80− agentProvider, err := createAgentProvider(agentName)
94+ ctx := context.Background()
95+ cfg := config.Get()
96+ otherTools := GetMcpTools(ctx, permissions)
97+ if len(lspClients) > 0 {
98+ otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
99+ }
100+
101+ allTools := []tools.BaseTool{
102+ tools.NewBashTool(permissions),
103+ tools.NewEditTool(lspClients, permissions, history),
104+ tools.NewFetchTool(permissions),
105+ tools.NewGlobTool(),
106+ tools.NewGrepTool(),
107+ tools.NewLsTool(),
108+ tools.NewSourcegraphTool(),
109+ tools.NewViewTool(lspClients),
110+ tools.NewWriteTool(lspClients, permissions, history),
111+ }
112+
113+ if agentCfg.ID == config.AgentCoder {
114+ taskAgentCfg := config.Get().Agents[config.AgentTask]
115+ if taskAgentCfg.ID == "" {
116+ return nil, fmt.Errorf("task agent not found in config")
117+ }
118+ taskAgent, err := NewAgent(taskAgentCfg, permissions, sessions, messages, history, lspClients)
119+ if err != nil {
120+ return nil, fmt.Errorf("failed to create task agent: %w", err)
121+ }
122+
123+ allTools = append(
124+ allTools,
125+ NewAgentTool(
126+ taskAgent,
127+ sessions,
128+ messages,
129+ ),
130+ )
131+ }
132+
133+ allTools = append(allTools, otherTools...)
134+ providerCfg := config.GetAgentProvider(agentCfg.ID)
135+ if providerCfg.ID == "" {
136+ return nil, fmt.Errorf("provider for agent %s not found in config", agentCfg.Name)
137+ }
138+ model := config.GetAgentModel(agentCfg.ID)
139+
140+ if model.ID == "" {
141+ return nil, fmt.Errorf("model not found for agent %s", agentCfg.Name)
142+ }
143+
144+ promptID := agentPromptMap[agentCfg.ID]
145+ if promptID == "" {
146+ promptID = prompt.PromptDefault
147+ }
148+ opts := []provider.ProviderClientOption{
149+ provider.WithModel(agentCfg.Model),
150+ provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID)),
151+ }
152+ agentProvider, err := provider.NewProvider(providerCfg, opts...)
81153 if err != nil {
82154 return nil, err
83155 }
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
156+
157+ smallModelCfg := cfg.Models.Small
158+ var smallModel config.Model
159+
160+ var smallModelProviderCfg config.ProviderConfig
161+ if smallModelCfg.Provider == providerCfg.ID {
162+ smallModelProviderCfg = providerCfg
163+ } else {
164+ for _, p := range cfg.Providers {
165+ if p.ID == smallModelCfg.Provider {
166+ smallModelProviderCfg = p
167+ break
168+ }
169+ }
170+ if smallModelProviderCfg.ID == "" {
171+ return nil, fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
90172 }
91173 }
92− var summarizeProvider provider.Provider
93− if agentName == config.AgentCoder {
94− summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
95− if err != nil {
96− return nil, err
174+ for _, m := range smallModelProviderCfg.Models {
175+ if m.ID == smallModelCfg.ModelID {
176+ smallModel = m
177+ break
178+ }
179+ }
180+ if smallModel.ID == "" {
181+ return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.ModelID, smallModelProviderCfg.ID)
182+ }
183+
184+ titleOpts := []provider.ProviderClientOption{
185+ provider.WithModel(config.SmallModel),
186+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
187+ }
188+ titleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
189+ if err != nil {
190+ return nil, err
191+ }
192+ summarizeOpts := []provider.ProviderClientOption{
193+ provider.WithModel(config.SmallModel),
194+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
195+ }
196+ summarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
197+ if err != nil {
198+ return nil, err
199+ }
200+
201+ agentTools := []tools.BaseTool{}
202+ if agentCfg.AllowedTools == nil {
203+ agentTools = allTools
204+ } else {
205+ for _, tool := range allTools {
206+ if slices.Contains(agentCfg.AllowedTools, tool.Name()) {
207+ agentTools = append(agentTools, tool)
208+ }
97209 }
98210 }
99211
100212 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{},
213+ Broker: pubsub.NewBroker[AgentEvent](),
214+ agentCfg: agentCfg,
215+ provider: agentProvider,
216+ providerID: string(providerCfg.ID),
217+ messages: messages,
218+ sessions: sessions,
219+ tools: agentTools,
220+ titleProvider: titleProvider,
221+ summarizeProvider: summarizeProvider,
222+ summarizeProviderID: string(smallModelProviderCfg.ID),
223+ activeRequests: sync.Map{},
109224 }
110225
111226 return agent, nil
112227}
113228
114−func (a *agent) Model() models.Model {
115− return a.provider.Model()
229+func (a *agent) Model() config.Model {
230+ return config.GetAgentModel(a.agentCfg.ID)
231+}
232+
233+func (a *agent) EffectiveMaxTokens() int64 {
234+ return config.GetAgentEffectiveMaxTokens(a.agentCfg.ID)
116235}
117236
118237func (a *agent) Cancel(sessionID string) {
@@ -139,10 +258,10 @@ func (a *agent) IsBusy() bool {
139258 if cancelFunc, ok := value.(context.CancelFunc); ok {
140259 if cancelFunc != nil {
141260 busy = true
142− return false // Stop iterating
261+ return false
143262 }
144263 }
145− return true // Continue iterating
264+ return true
146265 })
147266 return busy
148267}
@@ -163,7 +282,9 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
163282 if err != nil {
164283 return err
165284 }
166− parts := []message.ContentPart{message.TextContent{Text: content}}
285+ parts := []message.ContentPart{message.TextContent{
286+ Text: fmt.Sprintf("Generate a concise title for the following content:\n\n%s", content),
287+ }}
167288
168289 // Use streaming approach like summarization
169290 response := a.titleProvider.StreamResponse(
@@ -207,7 +328,7 @@ func (a *agent) err(err error) AgentEvent {
207328}
208329
209330func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
210− if !a.provider.Model().SupportsAttachments && attachments != nil {
331+ if !a.Model().SupportsImages && attachments != nil {
211332 attachments = nil
212333 }
213334 events := make(chan AgentEvent)
@@ -242,6 +363,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
242363}
243364
244365func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
366+ cfg := config.Get()
245367 // List existing messages; if none, start title generation asynchronously.
246368 msgs, err := a.messages.List(ctx, sessionID)
247369 if err != nil {
@@ -300,7 +422,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
300422 }
301423 return a.err(fmt.Errorf("failed to process events: %w", err))
302424 }
303− logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
425+ if cfg.Options.Debug {
426+ seqId := (len(msgHistory) + 1) / 2
427+ toolResultFilepath := logging.WriteToolResultsJson(sessionID, seqId, toolResults)
428+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", "{}", "filepath", toolResultFilepath)
429+ } else {
430+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
431+ }
304432 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
305433 // We are not done, we need to respond with the tool response
306434 msgHistory = append(msgHistory, agentMessage, *toolResults)
@@ -324,12 +452,14 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
324452}
325453
326454func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
455+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
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.Model().ID,
462+ Provider: a.providerID,
333463 })
334464 if err != nil {
335465 return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
@@ -337,7 +467,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
337467
338468 // Add the session and message ID into the context if needed by tools.
339469 ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
340− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
341470
342471 // Process each event in the stream.
343472 for event := range eventChan {
@@ -369,9 +498,10 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
369498 default:
370499 // Continue processing
371500 var tool tools.BaseTool
372− for _, availableTools := range a.tools {
373− if availableTools.Info().Name == toolCall.Name {
374− tool = availableTools
501+ for _, availableTool := range a.tools {
502+ if availableTool.Info().Name == toolCall.Name {
503+ tool = availableTool
504+ break
375505 }
376506 }
377507
@@ -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)
@@ -478,13 +609,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
478609 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
479610 return fmt.Errorf("failed to update message: %w", err)
480611 }
481− return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
612+ return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
482613 }
483614
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 config.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,25 +637,6 @@ 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) {
510− if a.IsBusy() {
511− return models.Model{}, fmt.Errorf("cannot change model while processing requests")
512− }
513−
514− if err := config.UpdateAgentModel(agentName, modelID); err != nil {
515− return models.Model{}, fmt.Errorf("failed to update config: %w", err)
516− }
517−
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)
521− }
522−
523− a.provider = provider
524−
525− return a.provider.Model(), nil
526−}
527−
528640func (a *agent) Summarize(ctx context.Context, sessionID string) error {
529641 if a.summarizeProvider == nil {
530642 return fmt.Errorf("summarize provider not available")
@@ -561,6 +673,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
561673 a.Publish(pubsub.CreatedEvent, event)
562674 return
563675 }
676+ summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
564677
565678 if len(msgs) == 0 {
566679 event = AgentEvent{
@@ -654,7 +767,8 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
654767 Time: time.Now().Unix(),
655768 },
656769 },
657− Model: a.summarizeProvider.Model().ID,
770+ Model: a.summarizeProvider.Model().ID,
771+ Provider: a.summarizeProviderID,
658772 })
659773 if err != nil {
660774 event = AgentEvent{
@@ -706,56 +820,98 @@ func (a *agent) CancelAll() {
706820 })
707821}
708822
709−func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
823+func (a *agent) UpdateModel() error {
710824 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− }
719825
720− providerCfg, ok := cfg.Providers[model.Provider]
721− if !ok {
722− return nil, fmt.Errorf("provider %s not supported", model.Provider)
826+ // Get current provider configuration
827+ currentProviderCfg := config.GetAgentProvider(a.agentCfg.ID)
828+ if currentProviderCfg.ID == "" {
829+ return fmt.Errorf("provider for agent %s not found in config", a.agentCfg.Name)
723830 }
724− if providerCfg.Disabled {
725− return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
831+
832+ // Check if provider has changed
833+ if string(currentProviderCfg.ID) != a.providerID {
834+ // Provider changed, need to recreate the main provider
835+ model := config.GetAgentModel(a.agentCfg.ID)
836+ if model.ID == "" {
837+ return fmt.Errorf("model not found for agent %s", a.agentCfg.Name)
838+ }
839+
840+ promptID := agentPromptMap[a.agentCfg.ID]
841+ if promptID == "" {
842+ promptID = prompt.PromptDefault
843+ }
844+
845+ opts := []provider.ProviderClientOption{
846+ provider.WithModel(a.agentCfg.Model),
847+ provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID)),
848+ }
849+
850+ newProvider, err := provider.NewProvider(currentProviderCfg, opts...)
851+ if err != nil {
852+ return fmt.Errorf("failed to create new provider: %w", err)
853+ }
854+
855+ // Update the provider and provider ID
856+ a.provider = newProvider
857+ a.providerID = string(currentProviderCfg.ID)
726858 }
727− maxTokens := model.DefaultMaxTokens
728− if agentConfig.MaxTokens > 0 {
729− maxTokens = agentConfig.MaxTokens
859+
860+ // Check if small model provider has changed (affects title and summarize providers)
861+ smallModelCfg := cfg.Models.Small
862+ var smallModelProviderCfg config.ProviderConfig
863+
864+ for _, p := range cfg.Providers {
865+ if p.ID == smallModelCfg.Provider {
866+ smallModelProviderCfg = p
867+ break
868+ }
730869 }
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− if (model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal) && model.CanReason {
738− opts = append(
739− opts,
740− provider.WithOpenAIOptions(
741− provider.WithReasoningEffort(agentConfig.ReasoningEffort),
742− ),
743− )
744− } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
745− opts = append(
746− opts,
747− provider.WithAnthropicOptions(
748− provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn),
749− ),
750− )
870+
871+ if smallModelProviderCfg.ID == "" {
872+ return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
751873 }
752− agentProvider, err := provider.NewProvider(
753− model.Provider,
754− opts...,
755− )
756− if err != nil {
757− return nil, fmt.Errorf("could not create provider: %v", err)
874+
875+ // Check if summarize provider has changed
876+ if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
877+ var smallModel config.Model
878+ for _, m := range smallModelProviderCfg.Models {
879+ if m.ID == smallModelCfg.ModelID {
880+ smallModel = m
881+ break
882+ }
883+ }
884+ if smallModel.ID == "" {
885+ return fmt.Errorf("model %s not found in provider %s", smallModelCfg.ModelID, smallModelProviderCfg.ID)
886+ }
887+
888+ // Recreate title provider
889+ titleOpts := []provider.ProviderClientOption{
890+ provider.WithModel(config.SmallModel),
891+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
892+ // We want the title to be short, so we limit the max tokens
893+ provider.WithMaxTokens(40),
894+ }
895+ newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
896+ if err != nil {
897+ return fmt.Errorf("failed to create new title provider: %w", err)
898+ }
899+
900+ // Recreate summarize provider
901+ summarizeOpts := []provider.ProviderClientOption{
902+ provider.WithModel(config.SmallModel),
903+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
904+ }
905+ newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
906+ if err != nil {
907+ return fmt.Errorf("failed to create new summarize provider: %w", err)
908+ }
909+
910+ // Update the providers and provider ID
911+ a.titleProvider = newTitleProvider
912+ a.summarizeProvider = newSummarizeProvider
913+ a.summarizeProviderID = string(smallModelProviderCfg.ID)
758914 }
759915
760− return agentProvider, nil
916+ return nil
761917}
internal/llm/agent/mcp-tools.go+13−5
@@ -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
@@ -32,12 +32,20 @@ type MCPClient interface {
3232 Close() error
3333}
3434
35+func (b *mcpTool) Name() string {
36+ return fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name)
37+}
38+
3539func (b *mcpTool) Info() tools.ToolInfo {
40+ required := b.tool.InputSchema.Required
41+ if required == nil {
42+ required = make([]string, 0)
43+ }
3644 return tools.ToolInfo{
3745 Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name),
3846 Description: b.tool.Description,
3947 Parameters: b.tool.InputSchema.Properties,
40− Required: b.tool.InputSchema.Required,
48+ Required: required,
4149 }
4250}
4351
@@ -124,7 +132,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
124132 return tools.NewTextErrorResponse("invalid mcp type"), nil
125133}
126134
127−func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCPServer) tools.BaseTool {
135+func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCP) tools.BaseTool {
128136 return &mcpTool{
129137 mcpName: name,
130138 tool: tool,
@@ -135,7 +143,7 @@ func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpC
135143
136144var mcpTools []tools.BaseTool
137145
138−func getTools(ctx context.Context, name string, m config.MCPServer, permissions permission.Service, c MCPClient) []tools.BaseTool {
146+func getTools(ctx context.Context, name string, m config.MCP, permissions permission.Service, c MCPClient) []tools.BaseTool {
139147 var stdioTools []tools.BaseTool
140148 initRequest := mcp.InitializeRequest{}
141149 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
@@ -166,7 +174,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
166174 if len(mcpTools) > 0 {
167175 return mcpTools
168176 }
169− for name, m := range config.Get().MCPServers {
177+ for name, m := range config.Get().MCP {
170178 switch m.Type {
171179 case config.MCPStdio:
172180 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 ModelProvider = "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 ModelProvider = "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 ModelProvider = "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 ModelProvider = "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 ModelProvider = "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−60
@@ -1,60 +0,0 @@
1−package models
2−
3−import "maps"
4−
5−type (
6− ModelID string
7− ModelProvider string
8−)
9−
10−type Model struct {
11− ID ModelID `json:"id"`
12− Name string `json:"name"`
13− Provider ModelProvider `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 ModelProvider = "bedrock"
33− // ForTests
34− ProviderMock ModelProvider = "__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−func init() {
52− maps.Copy(SupportedModels, AnthropicModels)
53− maps.Copy(SupportedModels, OpenAIModels)
54− maps.Copy(SupportedModels, GeminiModels)
55− maps.Copy(SupportedModels, GroqModels)
56− maps.Copy(SupportedModels, AzureModels)
57− maps.Copy(SupportedModels, OpenRouterModels)
58− maps.Copy(SupportedModels, XAIModels)
59− maps.Copy(SupportedModels, VertexAIGeminiModels)
60−}
internal/llm/models/openai.godeleted−181
@@ -1,181 +0,0 @@
1−package models
2−
3−const (
4− ProviderOpenAI ModelProvider = "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 ModelProvider = "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 ModelProvider = "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 ModelProvider = "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+247−35
@@ -9,62 +9,119 @@ 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.ModelProvider) string {
17− basePrompt := baseAnthropicCoderPrompt
18− switch provider {
19− case models.ProviderOpenAI:
17+func CoderPrompt(p provider.InferenceProvider, contextFiles ...string) string {
18+ var basePrompt string
19+ switch p {
20+ case provider.InferenceProviderOpenAI:
2021 basePrompt = baseOpenAICoderPrompt
22+ case provider.InferenceProviderGemini, provider.InferenceProviderVertexAI:
23+ basePrompt = baseGeminiCoderPrompt
24+ default:
25+ basePrompt = baseAnthropicCoderPrompt
2126 }
2227 envInfo := getEnvironmentInfo()
2328
24− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
29+ basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
30+
31+ contextContent := getContextFromPaths(contextFiles)
32+ logging.Debug("Context content", "Context", contextContent)
33+ if contextContent != "" {
34+ return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
35+ }
36+ return basePrompt
2537}
2638
2739const baseOpenAICoderPrompt = `
28−You are operating as and within the Crush CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
29−
30−You can:
31−- Receive user prompts, project context, and files.
32−- Stream responses and emit function calls (e.g., shell commands, code edits).
40+Please resolve the user's task by editing and testing the code files in your current code execution session.
41+You are a deployed coding agent.
42+Your session allows you to easily modify and run code in the user's local environment.
43+The repo(s) are already available in your working directory, and you must fully solve the problem for your answer to be considered correct.
3344
45+IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
3446
35−You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
47+# Memory
48+If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
49+1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
50+2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
51+3. Maintaining useful information about the codebase structure and organization
3652
37−Please resolve the user's task by editing and testing the code files in your current code execution session. You are a deployed coding agent. Your session allows for you to modify and run code. The repo(s) are already cloned in your working directory, and you must fully solve the problem for your answer to be considered correct.
53+When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
3854
3955You MUST adhere to the following criteria when executing the task:
56+
4057- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
4158- Analyzing code for vulnerabilities is allowed.
4259- Showing user code and tool call details is allowed.
43−- User instructions may overwrite the *CODING GUIDELINES* section in this developer message.
60+- User instructions may overwrite the _CODING GUIDELINES_ section in this developer message.
61+- Do not use ` + "`ls -R`" + `, ` + "`find`" + `, or ` + "`grep`" + ` - these are slow in large repos. Use the Agent tool for searching instead.
62+- Use the ` + "`edit`" + ` tool to modify files: provide file_path, old_string (with sufficient context), and new_string. The edit tool requires:
63+ - Absolute file paths (starting with /)
64+ - Unique old_string matches with 3-5 lines of context before and after
65+ - Exact whitespace and indentation matching
66+ - For new files: provide file_path and new_string, leave old_string empty
67+ - For deleting content: provide file_path and old_string, leave new_string empty
68+
69+# Following conventions
70+When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
71+- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
72+- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
73+- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
74+- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
75+
76+# Code style
77+- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
78+
4479- If completing the user's task requires writing or modifying files:
45− - Your code and final answer should follow these *CODING GUIDELINES*:
46− - Fix the problem at the root cause rather than applying surface-level patches, when possible.
47− - Avoid unneeded complexity in your solution.
48− - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
49− - Update documentation as necessary.
50− - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
51− - Use "git log" and "git blame" to search the history of the codebase if additional context is required; internet access is disabled.
52− - NEVER add copyright or license headers unless specifically requested.
53− - You do not need to "git commit" your changes; this will be done automatically for you.
54− - Once you finish coding, you must
55− - Check "git status" to sanity check your changes; revert any scratch files or changes.
56− - Remove all inline comments you added as much as possible, even if they look normal. Check using "git diff". Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
57− - Check if you accidentally add copyright or license headers. If so, remove them.
58− - For smaller tasks, describe in brief bullet points
59− - For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
80+ - Your code and final answer should follow these _CODING GUIDELINES_:
81+ - Fix the problem at the root cause rather than applying surface-level patches, when possible.
82+ - Avoid unneeded complexity in your solution.
83+ - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
84+ - Update documentation as necessary.
85+ - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
86+ - Use ` + "`git log`" + ` and ` + "`git blame`" + ` to search the history of the codebase if additional context is required.
87+ - NEVER add copyright or license headers unless specifically requested.
88+ - You do not need to ` + "`git commit`" + ` your changes; this will be done automatically for you.
89+ - If there is a .pre-commit-config.yaml, use ` + "`pre-commit run --files ...`" + ` to check that your changes pass the pre-commit checks. However, do not fix pre-existing errors on lines you didn't touch.
90+ - If pre-commit doesn't work after a few retries, politely inform the user that the pre-commit setup is broken.
91+ - Once you finish coding, you must
92+ - Check ` + "`git status`" + ` to sanity check your changes; revert any scratch files or changes.
93+ - Remove all inline comments you added as much as possible, even if they look normal. Check using ` + "`git diff`" + `. Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
94+ - Check if you accidentally add copyright or license headers. If so, remove them.
95+ - Try to run pre-commit if it is available.
96+ - For smaller tasks, describe in brief bullet points
97+ - For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
98+
99+# Doing tasks
100+The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
101+1. Use the available search tools to understand the codebase and the user's query.
102+2. Implement the solution using all tools available to you
103+3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
104+4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
105+
106+NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
107+
108+# Tool usage policy
109+- When doing file search, prefer to use the Agent tool in order to reduce context usage.
110+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
111+- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
112+
113+# Proactiveness
114+You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
115+1. Doing the right thing when asked, including taking actions and follow-up actions
116+2. Not surprising the user with actions you take without asking
117+For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
118+3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
119+
60120- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
61− - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
121+ - Respond in a friendly tone as a remote teammate, who is knowledgeable, capable and eager to help with coding.
62122- When your task involves writing or modifying files:
63− - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "edit/write". Instead, reference the file as already saved.
64− - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
65−- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
66−- If you send a path not including the working dir, the working dir will be prepended to it.
67−- Remember the user does not see the full output of tools
123+ - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using ` + "`edit`" + `. Instead, reference the file as already saved.
124+ - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
68125- NEVER use emojis in your responses
69126`
70127
@@ -167,6 +224,161 @@ VERY IMPORTANT NEVER use emojis in your responses.
167224
168225You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
169226
227+const baseGeminiCoderPrompt = `
228+You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
229+
230+IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
231+
232+# Memory
233+If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
234+1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
235+2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
236+3. Maintaining useful information about the codebase structure and organization
237+
238+When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
239+
240+# Core Mandates
241+
242+- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
243+- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
244+- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
245+- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
246+- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments.
247+- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
248+- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.
249+- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
250+- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
251+
252+# Primary Workflows
253+
254+## Software Engineering Tasks
255+When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
256+1. **Understand:** Think about the user's request and the relevant codebase context. Use ` + "`grep`" + ` and ` + "`glob`" + ` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use ` + "`view`" + ` to understand context and validate any assumptions you may have.
257+2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
258+3. **Implement:** Use the available tools (e.g., ` + "`edit`" + `, ` + "`write`" + ` ` + "`bash`" + ` ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
259+4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
260+5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
261+
262+NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
263+
264+# Operational Guidelines
265+
266+## Tone and Style (CLI Interaction)
267+- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
268+- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
269+- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
270+- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
271+- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
272+- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
273+- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
274+
275+## Security and Safety Rules
276+- **Explain Critical Commands:** Before executing commands with ` + "`bash`" + ` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety.
277+- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
278+
279+## Tool Usage
280+- **File Paths:** Always use absolute paths when referring to files with tools like ` + "`view`" + ` or ` + "`write`" + `. Relative paths are not supported. You must provide an absolute path.
281+- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
282+- **Command Execution:** Use the ` + "`bash`" + ` tool for running shell commands, remembering the safety rule to explain modifying commands first.
283+- **Background Processes:** Use background processes (via ` + "`&`" + `) for commands that are unlikely to stop on their own, e.g. ` + "`node server.js &`" + `. If unsure, ask the user.
284+- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. ` + "`git rebase -i`" + `). Use non-interactive versions of commands (e.g. ` + "`npm init -y`" + ` instead of ` + "`npm init`" + `) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
285+
286+# Examples (Illustrating Tone and Workflow)
287+
288+<example>
289+user: 1 + 2
290+model: 3
291+</example>
292+
293+<example>
294+user: is 13 a prime number?
295+model: true
296+</example>
297+
298+<example>
299+user: list files here.
300+model: [tool_call: ls for path '.']
301+</example>
302+
303+<example>
304+user: start the server implemented in server.js
305+model: [tool_call: bash for 'node server.js &' because it must run in the background]
306+</example>
307+
308+<example>
309+user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
310+model: Okay, I can refactor 'src/auth.py'.
311+First, I'll analyze the code and check for a test safety net before planning any changes.
312+[tool_call: glob for pattern 'tests/test_auth.py']
313+[tool_call: view for absolute_path '/path/to/tests/test_auth.py']
314+(After analysis)
315+Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
316+I'll also confirm 'requests' is a dependency.
317+[tool_call: view for absolute_path '/path/to/requirements.txt']
318+(After analysis)
319+Looks good, 'requests' is available.
320+
321+Here's the plan:
322+1. Replace the 'urllib' calls with 'requests'.
323+2. Add proper 'try...except' error handling for the new network calls.
324+3. Remove the old 'urllib' import.
325+4. Run the project's linter and tests to verify the changes.
326+
327+Should I proceed?
328+user: Yes
329+model:
330+[tool_call: edit to apply the refactoring to 'src/auth.py']
331+Refactoring complete. Running verification...
332+[tool_call: bash for 'ruff check src/auth.py && pytest']
333+(After verification passes)
334+All checks passed. This is a stable checkpoint.
335+</example>
336+
337+<example>
338+user: Delete the temp directory.
339+model: I can run ` + "`rm -rf ./temp`" + `. This will permanently delete the directory and all its contents.
340+</example>
341+
342+<example>
343+user: Write tests for someFile.ts
344+model:
345+Okay, I can write those tests. First, I'll read ` + "`someFile.ts`" + ` to understand its functionality.
346+[tool_call: view for absolute_path '/path/to/someFile.ts' or use glob to find ` + "`someFile.ts`" + ` if its location is unknown]
347+Now I'll look for existing or related test files to understand current testing conventions and dependencies.
348+[tool_call: glob for pattern '**/*.test.ts' and '**/*.spec.ts']
349+(After reviewing existing tests and the file content)
350+[tool_call: write to create /path/to/someFile.test.ts with the test code]
351+I've written the tests. Now I'll run the project's test command to verify them.
352+[tool_call: bash for 'npm run test']
353+</example>
354+
355+<example>
356+user: How do I update the user's profile information in this system?
357+model:
358+I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
359+[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
360+(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
361+Okay, ` + "`/path/to/UserProfileService.java`" + ` seems like the most relevant file. I'll read its content to understand how updates are handled.
362+[tool_call: view for absolute_path '/path/to/UserProfileService.java']
363+(After reading the file)
364+It appears the ` + "`updateUserProfile`" + ` method in ` + "`UserProfileService.java`" + ` is responsible for this. It expects a user ID and a ` + "`UserProfileDTO`" + ` object...
365+</example>
366+
367+<example>
368+user: Where are all the 'app.config' files in this project? I need to check their settings.
369+model:
370+[tool_call: glob for pattern '**/app.config']
371+(Assuming glob returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
372+I found the following 'app.config' files:
373+- /path/to/moduleA/app.config
374+- /path/to/moduleB/app.config
375+To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
376+</example>
377+
378+# Final Reminder
379+Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use ` + "`view`" + ` to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
380+`
381+
170382func getEnvironmentInfo() string {
171383 cwd := config.WorkingDirectory()
172384 isGit := isGitRepo(cwd)
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.ModelProvider) 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.ModelProvider) 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.ModelProvider) 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.ModelProvider) 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+53−68
@@ -6,55 +6,42 @@ import (
66 "errors"
77 "fmt"
88 "io"
9− "strings"
109 "time"
1110
1211 "github.com/anthropics/anthropic-sdk-go"
1312 "github.com/anthropics/anthropic-sdk-go/bedrock"
1413 "github.com/anthropics/anthropic-sdk-go/option"
1514 "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/llm/models"
15+ "github.com/charmbracelet/crush/internal/fur/provider"
1716 "github.com/charmbracelet/crush/internal/llm/tools"
1817 "github.com/charmbracelet/crush/internal/logging"
1918 "github.com/charmbracelet/crush/internal/message"
2019)
2120
22−type anthropicOptions struct {
23− useBedrock bool
24− disableCache bool
25− shouldThink func(userMessage string) bool
26−}
27−
28−type AnthropicOption func(*anthropicOptions)
29−
3021type anthropicClient struct {
3122 providerOptions providerClientOptions
32− options anthropicOptions
23+ useBedrock bool
3324 client anthropic.Client
3425}
3526
3627type AnthropicClient ProviderClient
3728
38−func newAnthropicClient(opts providerClientOptions) AnthropicClient {
39− anthropicOpts := anthropicOptions{}
40− for _, o := range opts.anthropicOptions {
41− o(&anthropicOpts)
29+func newAnthropicClient(opts providerClientOptions, useBedrock bool) AnthropicClient {
30+ return &anthropicClient{
31+ providerOptions: opts,
32+ client: createAnthropicClient(opts, useBedrock),
4233 }
34+}
4335
36+func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
4437 anthropicClientOptions := []option.RequestOption{}
4538 if opts.apiKey != "" {
4639 anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
4740 }
48− if anthropicOpts.useBedrock {
41+ if useBedrock {
4942 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
5043 }
51−
52− client := anthropic.NewClient(anthropicClientOptions...)
53− return &anthropicClient{
54− providerOptions: opts,
55− options: anthropicOpts,
56− client: client,
57− }
44+ return anthropic.NewClient(anthropicClientOptions...)
5845}
5946
6047func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
@@ -66,7 +53,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
6653 switch msg.Role {
6754 case message.User:
6855 content := anthropic.NewTextBlock(msg.Content().String())
69− if cache && !a.options.disableCache {
56+ if cache && !a.providerOptions.disableCache {
7057 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
7158 Type: "ephemeral",
7259 }
@@ -74,7 +61,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
7461 var contentBlocks []anthropic.ContentBlockParamUnion
7562 contentBlocks = append(contentBlocks, content)
7663 for _, binaryContent := range msg.BinaryContent() {
77− base64Image := binaryContent.String(models.ProviderAnthropic)
64+ base64Image := binaryContent.String(provider.InferenceProviderAnthropic)
7865 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
7966 contentBlocks = append(contentBlocks, imageBlock)
8067 }
@@ -84,7 +71,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
8471 blocks := []anthropic.ContentBlockParamUnion{}
8572 if msg.Content().String() != "" {
8673 content := anthropic.NewTextBlock(msg.Content().String())
87− if cache && !a.options.disableCache {
74+ if cache && !a.providerOptions.disableCache {
8875 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
8976 Type: "ephemeral",
9077 }
@@ -132,7 +119,7 @@ func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolU
132119 },
133120 }
134121
135− if i == len(tools)-1 && !a.options.disableCache {
122+ if i == len(tools)-1 && !a.providerOptions.disableCache {
136123 toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
137124 Type: "ephemeral",
138125 }
@@ -160,26 +147,33 @@ func (a *anthropicClient) finishReason(reason string) message.FinishReason {
160147}
161148
162149func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
150+ model := a.providerOptions.model(a.providerOptions.modelType)
163151 var thinkingParam anthropic.ThinkingConfigParamUnion
164− lastMessage := messages[len(messages)-1]
165− isUser := lastMessage.Role == anthropic.MessageParamRoleUser
166− messageContent := ""
152+ cfg := config.Get()
153+ modelConfig := cfg.Models.Large
154+ if a.providerOptions.modelType == config.SmallModel {
155+ modelConfig = cfg.Models.Small
156+ }
167157 temperature := anthropic.Float(0)
168− if isUser {
169− for _, m := range lastMessage.Content {
170− if m.OfText != nil && m.OfText.Text != "" {
171− messageContent = m.OfText.Text
172− }
173− }
174− if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
175− thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
176− temperature = anthropic.Float(1)
177− }
158+
159+ if a.Model().CanReason && modelConfig.Think {
160+ thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
161+ temperature = anthropic.Float(1)
162+ }
163+
164+ maxTokens := model.DefaultMaxTokens
165+ if modelConfig.MaxTokens > 0 {
166+ maxTokens = modelConfig.MaxTokens
167+ }
168+
169+ // Override max tokens if set in provider options
170+ if a.providerOptions.maxTokens > 0 {
171+ maxTokens = a.providerOptions.maxTokens
178172 }
179173
180174 return anthropic.MessageNewParams{
181− Model: anthropic.Model(a.providerOptions.model.APIModel),
182− MaxTokens: a.providerOptions.maxTokens,
175+ Model: anthropic.Model(model.ID),
176+ MaxTokens: maxTokens,
183177 Temperature: temperature,
184178 Messages: messages,
185179 Tools: tools,
@@ -198,7 +192,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
198192func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
199193 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200194 cfg := config.Get()
201− if cfg.Debug {
195+ if cfg.Options.Debug {
202196 jsonData, _ := json.Marshal(preparedMessages)
203197 logging.Debug("Prepared messages", "messages", string(jsonData))
204198 }
@@ -247,7 +241,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
247241func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
248242 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
249243 cfg := config.Get()
250− if cfg.Debug {
244+ if cfg.Options.Debug {
251245 // jsonData, _ := json.Marshal(preparedMessages)
252246 // logging.Debug("Prepared messages", "messages", string(jsonData))
253247 }
@@ -383,12 +377,21 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
383377}
384378
385379func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
386− var apierr *anthropic.Error
387− if !errors.As(err, &apierr) {
380+ var apiErr *anthropic.Error
381+ if !errors.As(err, &apiErr) {
388382 return false, 0, err
389383 }
390384
391− if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
385+ if apiErr.StatusCode == 401 {
386+ a.providerOptions.apiKey, err = config.ResolveAPIKey(a.providerOptions.config.APIKey)
387+ if err != nil {
388+ return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
389+ }
390+ a.client = createAnthropicClient(a.providerOptions, a.useBedrock)
391+ return true, 0, nil
392+ }
393+
394+ if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 {
392395 return false, 0, err
393396 }
394397
@@ -397,7 +400,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err
397400 }
398401
399402 retryMs := 0
400− retryAfterValues := apierr.Response.Header.Values("Retry-After")
403+ retryAfterValues := apiErr.Response.Header.Values("Retry-After")
401404
402405 backoffMs := 2000 * (1 << (attempts - 1))
403406 jitterMs := int(float64(backoffMs) * 0.2)
@@ -439,24 +442,6 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
439442 }
440443}
441444
442−func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
443− return func(options *anthropicOptions) {
444− options.useBedrock = useBedrock
445− }
446−}
447−
448−func WithAnthropicDisableCache() AnthropicOption {
449− return func(options *anthropicOptions) {
450− options.disableCache = true
451− }
452−}
453−
454−func DefaultShouldThinkFn(s string) bool {
455− return strings.Contains(strings.ToLower(s), "think")
456−}
457−
458−func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
459− return func(options *anthropicOptions) {
460− options.shouldThink = fn
461− }
445+func (a *anthropicClient) Model() config.Model {
446+ return a.providerOptions.model(a.providerOptions.modelType)
462447}
internal/llm/provider/bedrock.go+21−29
@@ -4,65 +4,54 @@ import (
44 "context"
55 "errors"
66 "fmt"
7− "os"
87 "strings"
98
9+ "github.com/charmbracelet/crush/internal/config"
1010 "github.com/charmbracelet/crush/internal/llm/tools"
1111 "github.com/charmbracelet/crush/internal/message"
1212)
1313
14−type bedrockOptions struct {
15− // Bedrock specific options can be added here
16−}
17−
18−type BedrockOption func(*bedrockOptions)
19−
2014type bedrockClient struct {
2115 providerOptions providerClientOptions
22− options bedrockOptions
2316 childProvider ProviderClient
2417}
2518
2619type BedrockClient ProviderClient
2720
2821func newBedrockClient(opts providerClientOptions) BedrockClient {
29− bedrockOpts := bedrockOptions{}
30− // Apply bedrock specific options if they are added in the future
31−
3222 // Get AWS region from environment
33− region := os.Getenv("AWS_REGION")
34− if region == "" {
35− region = os.Getenv("AWS_DEFAULT_REGION")
36− }
37−
23+ region := opts.extraParams["region"]
3824 if region == "" {
3925 region = "us-east-1" // default region
4026 }
4127 if len(region) < 2 {
4228 return &bedrockClient{
4329 providerOptions: opts,
44− options: bedrockOpts,
4530 childProvider: nil, // Will cause an error when used
4631 }
4732 }
4833
49− // Prefix the model name with region
50− regionPrefix := region[:2]
51− modelName := opts.model.APIModel
52− opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
34+ opts.model = func(modelType config.ModelType) config.Model {
35+ model := config.GetModel(modelType)
36+
37+ // Prefix the model name with region
38+ regionPrefix := region[:2]
39+ modelName := model.ID
40+ model.ID = fmt.Sprintf("%s.%s", regionPrefix, modelName)
41+ return model
42+ }
43+
44+ model := opts.model(opts.modelType)
5345
5446 // Determine which provider to use based on the model
55− if strings.Contains(string(opts.model.APIModel), "anthropic") {
47+ if strings.Contains(string(model.ID), "anthropic") {
5648 // Create Anthropic client with Bedrock configuration
5749 anthropicOpts := opts
58− anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
59− WithAnthropicBedrock(true),
60− WithAnthropicDisableCache(),
61− )
50+ // TODO: later find a way to check if the AWS account has caching enabled
51+ opts.disableCache = true // Disable cache for Bedrock
6252 return &bedrockClient{
6353 providerOptions: opts,
64− options: bedrockOpts,
65− childProvider: newAnthropicClient(anthropicOpts),
54+ childProvider: newAnthropicClient(anthropicOpts, true),
6655 }
6756 }
6857
@@ -70,7 +59,6 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
7059 // This will cause an error when used
7160 return &bedrockClient{
7261 providerOptions: opts,
73− options: bedrockOpts,
7462 childProvider: nil,
7563 }
7664}
@@ -98,3 +86,7 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
9886
9987 return b.childProvider.stream(ctx, messages, tools)
10088}
89+
90+func (b *bedrockClient) Model() config.Model {
91+ return b.providerOptions.model(b.providerOptions.modelType)
92+}
internal/llm/provider/gemini.go+54−46
@@ -17,27 +17,15 @@ import (
1717 "google.golang.org/genai"
1818)
1919
20−type geminiOptions struct {
21− disableCache bool
22−}
23−
24−type GeminiOption func(*geminiOptions)
25−
2620type geminiClient struct {
2721 providerOptions providerClientOptions
28− options geminiOptions
2922 client *genai.Client
3023}
3124
3225type GeminiClient ProviderClient
3326
3427func newGeminiClient(opts providerClientOptions) GeminiClient {
35− geminiOpts := geminiOptions{}
36− for _, o := range opts.geminiOptions {
37− o(&geminiOpts)
38− }
39−
40− client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
28+ client, err := createGeminiClient(opts)
4129 if err != nil {
4230 logging.Error("Failed to create Gemini client", "error", err)
4331 return nil
@@ -45,11 +33,18 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
4533
4634 return &geminiClient{
4735 providerOptions: opts,
48− options: geminiOpts,
4936 client: client,
5037 }
5138}
5239
40+func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
41+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
42+ if err != nil {
43+ return nil, err
44+ }
45+ return client, nil
46+}
47+
5348func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
5449 var history []*genai.Content
5550 for _, msg := range messages {
@@ -168,17 +163,26 @@ func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishRea
168163func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
169164 // Convert messages
170165 geminiMessages := g.convertMessages(messages)
171−
166+ model := g.providerOptions.model(g.providerOptions.modelType)
172167 cfg := config.Get()
173− if cfg.Debug {
168+ if cfg.Options.Debug {
174169 jsonData, _ := json.Marshal(geminiMessages)
175170 logging.Debug("Prepared messages", "messages", string(jsonData))
176171 }
177172
173+ modelConfig := cfg.Models.Large
174+ if g.providerOptions.modelType == config.SmallModel {
175+ modelConfig = cfg.Models.Small
176+ }
177+
178+ maxTokens := model.DefaultMaxTokens
179+ if modelConfig.MaxTokens > 0 {
180+ maxTokens = modelConfig.MaxTokens
181+ }
178182 history := geminiMessages[:len(geminiMessages)-1] // All but last message
179183 lastMsg := geminiMessages[len(geminiMessages)-1]
180184 config := &genai.GenerateContentConfig{
181− MaxOutputTokens: int32(g.providerOptions.maxTokens),
185+ MaxOutputTokens: int32(maxTokens),
182186 SystemInstruction: &genai.Content{
183187 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
184188 },
@@ -186,7 +190,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
186190 if len(tools) > 0 {
187191 config.Tools = g.convertTools(tools)
188192 }
189− chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
193+ chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
190194
191195 attempts := 0
192196 for {
@@ -257,16 +261,30 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
257261 // Convert messages
258262 geminiMessages := g.convertMessages(messages)
259263
264+ model := g.providerOptions.model(g.providerOptions.modelType)
260265 cfg := config.Get()
261− if cfg.Debug {
266+ if cfg.Options.Debug {
262267 jsonData, _ := json.Marshal(geminiMessages)
263268 logging.Debug("Prepared messages", "messages", string(jsonData))
264269 }
265270
271+ modelConfig := cfg.Models.Large
272+ if g.providerOptions.modelType == config.SmallModel {
273+ modelConfig = cfg.Models.Small
274+ }
275+ maxTokens := model.DefaultMaxTokens
276+ if modelConfig.MaxTokens > 0 {
277+ maxTokens = modelConfig.MaxTokens
278+ }
279+
280+ // Override max tokens if set in provider options
281+ if g.providerOptions.maxTokens > 0 {
282+ maxTokens = g.providerOptions.maxTokens
283+ }
266284 history := geminiMessages[:len(geminiMessages)-1] // All but last message
267285 lastMsg := geminiMessages[len(geminiMessages)-1]
268286 config := &genai.GenerateContentConfig{
269− MaxOutputTokens: int32(g.providerOptions.maxTokens),
287+ MaxOutputTokens: int32(maxTokens),
270288 SystemInstruction: &genai.Content{
271289 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
272290 },
@@ -274,7 +292,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
274292 if len(tools) > 0 {
275293 config.Tools = g.convertTools(tools)
276294 }
277− chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
295+ chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
278296
279297 attempts := 0
280298 eventChan := make(chan ProviderEvent)
@@ -404,6 +422,19 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error)
404422 errMsg := err.Error()
405423 isRateLimit := contains(errMsg, "rate limit", "quota exceeded", "too many requests")
406424
425+ // Check for token expiration (401 Unauthorized)
426+ if contains(errMsg, "unauthorized", "invalid api key", "api key expired") {
427+ g.providerOptions.apiKey, err = config.ResolveAPIKey(g.providerOptions.config.APIKey)
428+ if err != nil {
429+ return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
430+ }
431+ g.client, err = createGeminiClient(g.providerOptions)
432+ if err != nil {
433+ return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err)
434+ }
435+ return true, 0, nil
436+ }
437+
407438 // Check for common rate limit error messages
408439
409440 if !isRateLimit {
@@ -418,27 +449,6 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error)
418449 return true, int64(retryMs), nil
419450}
420451
421−func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.ToolCall {
422− var toolCalls []message.ToolCall
423−
424− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
425− for _, part := range resp.Candidates[0].Content.Parts {
426− if part.FunctionCall != nil {
427− id := "call_" + uuid.New().String()
428− args, _ := json.Marshal(part.FunctionCall.Args)
429− toolCalls = append(toolCalls, message.ToolCall{
430− ID: id,
431− Name: part.FunctionCall.Name,
432− Input: string(args),
433− Type: "function",
434− })
435− }
436− }
437− }
438−
439− return toolCalls
440−}
441−
442452func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
443453 if resp == nil || resp.UsageMetadata == nil {
444454 return TokenUsage{}
@@ -452,10 +462,8 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
452462 }
453463}
454464
455−func WithGeminiDisableCache() GeminiOption {
456− return func(options *geminiOptions) {
457− options.disableCache = true
458− }
465+func (g *geminiClient) Model() config.Model {
466+ return g.providerOptions.model(g.providerOptions.modelType)
459467}
460468
461469// Helper functions
internal/llm/provider/openai.go+58−68
@@ -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"
@@ -18,51 +18,36 @@ import (
1818 "github.com/openai/openai-go/shared"
1919)
2020
21−type openaiOptions struct {
22− baseURL string
23− disableCache bool
24− reasoningEffort string
25− extraHeaders map[string]string
26−}
27−
28−type OpenAIOption func(*openaiOptions)
29−
3021type openaiClient struct {
3122 providerOptions providerClientOptions
32− options openaiOptions
3323 client openai.Client
3424}
3525
3626type OpenAIClient ProviderClient
3727
3828func newOpenAIClient(opts providerClientOptions) OpenAIClient {
39− openaiOpts := openaiOptions{
40− reasoningEffort: "medium",
41− }
42− for _, o := range opts.openaiOptions {
43− o(&openaiOpts)
29+ return &openaiClient{
30+ providerOptions: opts,
31+ client: createOpenAIClient(opts),
4432 }
33+}
4534
35+func createOpenAIClient(opts providerClientOptions) openai.Client {
4636 openaiClientOptions := []option.RequestOption{}
4737 if opts.apiKey != "" {
4838 openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
4939 }
50− if openaiOpts.baseURL != "" {
51− openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
40+ if opts.baseURL != "" {
41+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(opts.baseURL))
5242 }
5343
54− if openaiOpts.extraHeaders != nil {
55− for key, value := range openaiOpts.extraHeaders {
44+ if opts.extraHeaders != nil {
45+ for key, value := range opts.extraHeaders {
5646 openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
5747 }
5848 }
5949
60− client := openai.NewClient(openaiClientOptions...)
61− return &openaiClient{
62− providerOptions: opts,
63− options: openaiOpts,
64− client: client,
65− }
50+ return openai.NewClient(openaiClientOptions...)
6651}
6752
6853func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
@@ -76,7 +61,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7661 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
7762 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
7863 for _, binaryContent := range msg.BinaryContent() {
79− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
64+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(provider.InferenceProviderOpenAI)}
8065 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
8166
8267 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
@@ -160,15 +145,37 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
160145}
161146
162147func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
148+ model := o.providerOptions.model(o.providerOptions.modelType)
149+ cfg := config.Get()
150+
151+ modelConfig := cfg.Models.Large
152+ if o.providerOptions.modelType == config.SmallModel {
153+ modelConfig = cfg.Models.Small
154+ }
155+
156+ reasoningEffort := model.ReasoningEffort
157+ if modelConfig.ReasoningEffort != "" {
158+ reasoningEffort = modelConfig.ReasoningEffort
159+ }
160+
163161 params := openai.ChatCompletionNewParams{
164− Model: openai.ChatModel(o.providerOptions.model.APIModel),
162+ Model: openai.ChatModel(model.ID),
165163 Messages: messages,
166164 Tools: tools,
167165 }
168166
169− if o.providerOptions.model.CanReason {
170− params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
171− switch o.options.reasoningEffort {
167+ maxTokens := model.DefaultMaxTokens
168+ if modelConfig.MaxTokens > 0 {
169+ maxTokens = modelConfig.MaxTokens
170+ }
171+
172+ // Override max tokens if set in provider options
173+ if o.providerOptions.maxTokens > 0 {
174+ maxTokens = o.providerOptions.maxTokens
175+ }
176+ if model.CanReason {
177+ params.MaxCompletionTokens = openai.Int(maxTokens)
178+ switch reasoningEffort {
172179 case "low":
173180 params.ReasoningEffort = shared.ReasoningEffortLow
174181 case "medium":
@@ -176,10 +183,10 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
176183 case "high":
177184 params.ReasoningEffort = shared.ReasoningEffortHigh
178185 default:
179− params.ReasoningEffort = shared.ReasoningEffortMedium
186+ params.ReasoningEffort = shared.ReasoningEffort(reasoningEffort)
180187 }
181188 } else {
182− params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
189+ params.MaxTokens = openai.Int(maxTokens)
183190 }
184191
185192 return params
@@ -188,7 +195,7 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
188195func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
189196 params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
190197 cfg := config.Get()
191− if cfg.Debug {
198+ if cfg.Options.Debug {
192199 jsonData, _ := json.Marshal(params)
193200 logging.Debug("Prepared messages", "messages", string(jsonData))
194201 }
@@ -245,7 +252,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
245252 }
246253
247254 cfg := config.Get()
248− if cfg.Debug {
255+ if cfg.Options.Debug {
249256 jsonData, _ := json.Marshal(params)
250257 logging.Debug("Prepared messages", "messages", string(jsonData))
251258 }
@@ -335,12 +342,22 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
335342}
336343
337344func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
338− var apierr *openai.Error
339− if !errors.As(err, &apierr) {
345+ var apiErr *openai.Error
346+ if !errors.As(err, &apiErr) {
340347 return false, 0, err
341348 }
342349
343− if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
350+ // Check for token expiration (401 Unauthorized)
351+ if apiErr.StatusCode == 401 {
352+ o.providerOptions.apiKey, err = config.ResolveAPIKey(o.providerOptions.config.APIKey)
353+ if err != nil {
354+ return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
355+ }
356+ o.client = createOpenAIClient(o.providerOptions)
357+ return true, 0, nil
358+ }
359+
360+ if apiErr.StatusCode != 429 && apiErr.StatusCode != 500 {
344361 return false, 0, err
345362 }
346363
@@ -349,7 +366,7 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
349366 }
350367
351368 retryMs := 0
352− retryAfterValues := apierr.Response.Header.Values("Retry-After")
369+ retryAfterValues := apiErr.Response.Header.Values("Retry-After")
353370
354371 backoffMs := 2000 * (1 << (attempts - 1))
355372 jitterMs := int(float64(backoffMs) * 0.2)
@@ -393,33 +410,6 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
393410 }
394411}
395412
396−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
397− return func(options *openaiOptions) {
398− options.baseURL = baseURL
399− }
400−}
401−
402−func WithOpenAIExtraHeaders(headers map[string]string) OpenAIOption {
403− return func(options *openaiOptions) {
404− options.extraHeaders = headers
405− }
406−}
407−
408−func WithOpenAIDisableCache() OpenAIOption {
409− return func(options *openaiOptions) {
410− options.disableCache = true
411− }
412−}
413−
414−func WithReasoningEffort(effort string) OpenAIOption {
415− return func(options *openaiOptions) {
416− defaultReasoningEffort := "medium"
417− switch effort {
418− case "low", "medium", "high":
419− defaultReasoningEffort = effort
420− default:
421− logging.Warn("Invalid reasoning effort, using default: medium")
422− }
423− options.reasoningEffort = defaultReasoningEffort
424− }
413+func (a *openaiClient) Model() config.Model {
414+ return a.providerOptions.model(a.providerOptions.modelType)
425415}
internal/llm/provider/provider.go+74−115
@@ -3,9 +3,9 @@ package provider
33import (
44 "context"
55 "fmt"
6− "os"
76
8− "github.com/charmbracelet/crush/internal/llm/models"
7+ "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,19 +55,20 @@ type Provider interface {
5555
5656 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
5757
58− Model() models.Model
58+ Model() config.Model
5959}
6060
6161type providerClientOptions struct {
62+ baseURL string
63+ config config.ProviderConfig
6264 apiKey string
63− model models.Model
64− maxTokens int64
65+ modelType config.ModelType
66+ model func(config.ModelType) config.Model
67+ disableCache bool
6568 systemMessage string
66−
67− anthropicOptions []AnthropicOption
68− openaiOptions []OpenAIOption
69− geminiOptions []GeminiOption
70− bedrockOptions []BedrockOption
69+ maxTokens int64
70+ extraHeaders map[string]string
71+ extraParams map[string]string
7172}
7273
7374type ProviderClientOption func(*providerClientOptions)
@@ -75,6 +76,8 @@ type ProviderClientOption func(*providerClientOptions)
7576type ProviderClient interface {
7677 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
7778 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
79+
80+ Model() config.Model
7881}
7982
8083type baseProvider[C ProviderClient] struct {
@@ -82,85 +85,6 @@ type baseProvider[C ProviderClient] struct {
8285 client C
8386}
8487
85−func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) {
86− clientOptions := providerClientOptions{}
87− for _, o := range opts {
88− o(&clientOptions)
89− }
90− switch providerName {
91− case models.ProviderAnthropic:
92− return &baseProvider[AnthropicClient]{
93− options: clientOptions,
94− client: newAnthropicClient(clientOptions),
95− }, nil
96− case models.ProviderOpenAI:
97− return &baseProvider[OpenAIClient]{
98− options: clientOptions,
99− client: newOpenAIClient(clientOptions),
100− }, nil
101− case models.ProviderGemini:
102− return &baseProvider[GeminiClient]{
103− options: clientOptions,
104− client: newGeminiClient(clientOptions),
105− }, nil
106− case models.ProviderBedrock:
107− return &baseProvider[BedrockClient]{
108− options: clientOptions,
109− client: newBedrockClient(clientOptions),
110− }, nil
111− case models.ProviderGROQ:
112− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
113− WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
114− )
115− return &baseProvider[OpenAIClient]{
116− options: clientOptions,
117− client: newOpenAIClient(clientOptions),
118− }, nil
119− case models.ProviderAzure:
120− return &baseProvider[AzureClient]{
121− options: clientOptions,
122− client: newAzureClient(clientOptions),
123− }, nil
124− case models.ProviderVertexAI:
125− return &baseProvider[VertexAIClient]{
126− options: clientOptions,
127− client: newVertexAIClient(clientOptions),
128− }, nil
129− case models.ProviderOpenRouter:
130− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
131− WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
132− WithOpenAIExtraHeaders(map[string]string{
133− "HTTP-Referer": "crush.charm.land",
134− "X-Title": "Crush",
135− }),
136− )
137− return &baseProvider[OpenAIClient]{
138− options: clientOptions,
139− client: newOpenAIClient(clientOptions),
140− }, nil
141− case models.ProviderXAI:
142− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
143− WithOpenAIBaseURL("https://api.x.ai/v1"),
144− )
145− return &baseProvider[OpenAIClient]{
146− options: clientOptions,
147− client: newOpenAIClient(clientOptions),
148− }, nil
149− case models.ProviderLocal:
150− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
151− WithOpenAIBaseURL(os.Getenv("LOCAL_ENDPOINT")),
152− )
153− return &baseProvider[OpenAIClient]{
154− options: clientOptions,
155− client: newOpenAIClient(clientOptions),
156− }, nil
157− case models.ProviderMock:
158− // TODO: implement mock client for test
159− panic("not implemented")
160− }
161− return nil, fmt.Errorf("provider not supported: %s", providerName)
162−}
163−
16488func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
16589 for _, msg := range messages {
16690 // The message has no content
@@ -177,30 +101,24 @@ func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.M
177101 return p.client.send(ctx, messages, tools)
178102}
179103
180−func (p *baseProvider[C]) Model() models.Model {
181− return p.options.model
182−}
183−
184104func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
185105 messages = p.cleanMessages(messages)
186106 return p.client.stream(ctx, messages, tools)
187107}
188108
189−func WithAPIKey(apiKey string) ProviderClientOption {
190− return func(options *providerClientOptions) {
191− options.apiKey = apiKey
192− }
109+func (p *baseProvider[C]) Model() config.Model {
110+ return p.client.Model()
193111}
194112
195−func WithModel(model models.Model) ProviderClientOption {
113+func WithModel(model config.ModelType) ProviderClientOption {
196114 return func(options *providerClientOptions) {
197− options.model = model
115+ options.modelType = model
198116 }
199117}
200118
201−func WithMaxTokens(maxTokens int64) ProviderClientOption {
119+func WithDisableCache(disableCache bool) ProviderClientOption {
202120 return func(options *providerClientOptions) {
203− options.maxTokens = maxTokens
121+ options.disableCache = disableCache
204122 }
205123}
206124
@@ -210,26 +128,67 @@ func WithSystemMessage(systemMessage string) ProviderClientOption {
210128 }
211129}
212130
213−func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
131+func WithMaxTokens(maxTokens int64) ProviderClientOption {
214132 return func(options *providerClientOptions) {
215− options.anthropicOptions = anthropicOptions
133+ options.maxTokens = maxTokens
216134 }
217135}
218136
219−func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
220− return func(options *providerClientOptions) {
221− options.openaiOptions = openaiOptions
137+func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
138+ resolvedAPIKey, err := config.ResolveAPIKey(cfg.APIKey)
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
222141 }
223−}
224142
225−func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
226− return func(options *providerClientOptions) {
227− options.geminiOptions = geminiOptions
143+ clientOptions := providerClientOptions{
144+ baseURL: cfg.BaseURL,
145+ config: cfg,
146+ apiKey: resolvedAPIKey,
147+ extraHeaders: cfg.ExtraHeaders,
148+ model: func(tp config.ModelType) config.Model {
149+ return config.GetModel(tp)
150+ },
228151 }
229−}
230−
231−func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
232− return func(options *providerClientOptions) {
233− options.bedrockOptions = bedrockOptions
152+ for _, o := range opts {
153+ o(&clientOptions)
154+ }
155+ switch cfg.ProviderType {
156+ case provider.TypeAnthropic:
157+ return &baseProvider[AnthropicClient]{
158+ options: clientOptions,
159+ client: newAnthropicClient(clientOptions, false),
160+ }, nil
161+ case provider.TypeOpenAI:
162+ return &baseProvider[OpenAIClient]{
163+ options: clientOptions,
164+ client: newOpenAIClient(clientOptions),
165+ }, nil
166+ case provider.TypeGemini:
167+ return &baseProvider[GeminiClient]{
168+ options: clientOptions,
169+ client: newGeminiClient(clientOptions),
170+ }, nil
171+ case provider.TypeBedrock:
172+ return &baseProvider[BedrockClient]{
173+ options: clientOptions,
174+ client: newBedrockClient(clientOptions),
175+ }, nil
176+ case provider.TypeAzure:
177+ return &baseProvider[AzureClient]{
178+ options: clientOptions,
179+ client: newAzureClient(clientOptions),
180+ }, nil
181+ case provider.TypeVertexAI:
182+ return &baseProvider[VertexAIClient]{
183+ options: clientOptions,
184+ client: newVertexAIClient(clientOptions),
185+ }, nil
186+ case provider.TypeXAI:
187+ clientOptions.baseURL = "https://api.x.ai/v1"
188+ return &baseProvider[OpenAIClient]{
189+ options: clientOptions,
190+ client: newOpenAIClient(clientOptions),
191+ }, nil
234192 }
193+ return nil, fmt.Errorf("provider not supported: %s", cfg.ProviderType)
235194}
internal/llm/provider/vertexai.go+4−9
@@ -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,14 +10,11 @@ import (
1110type VertexAIClient ProviderClient
1211
1312func newVertexAIClient(opts providerClientOptions) VertexAIClient {
14− geminiOpts := geminiOptions{}
15− for _, o := range opts.geminiOptions {
16− o(&geminiOpts)
17− }
18−
13+ project := opts.extraHeaders["project"]
14+ location := opts.extraHeaders["location"]
1915 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
20− Project: os.Getenv("VERTEXAI_PROJECT"),
21− Location: os.Getenv("VERTEXAI_LOCATION"),
16+ Project: project,
17+ Location: location,
2218 Backend: genai.BackendVertexAI,
2319 })
2420 if err != nil {
@@ -28,7 +24,6 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
2824
2925 return &geminiClient{
3026 providerOptions: opts,
31− options: geminiOpts,
3227 client: client,
3328 }
3429}
internal/llm/tools/bash.go+4
@@ -250,6 +250,10 @@ func NewBashTool(permission permission.Service) BaseTool {
250250 }
251251}
252252
253+func (b *bashTool) Name() string {
254+ return BashToolName
255+}
256+
253257func (b *bashTool) Info() ToolInfo {
254258 return ToolInfo{
255259 Name: BashToolName,
internal/llm/tools/diagnostics.go+4
@@ -51,6 +51,10 @@ func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
5151 }
5252}
5353
54+func (b *diagnosticsTool) Name() string {
55+ return DiagnosticsToolName
56+}
57+
5458func (b *diagnosticsTool) Info() ToolInfo {
5559 return ToolInfo{
5660 Name: DiagnosticsToolName,
internal/llm/tools/edit.go+4
@@ -106,6 +106,10 @@ func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Servi
106106 }
107107}
108108
109+func (e *editTool) Name() string {
110+ return EditToolName
111+}
112+
109113func (e *editTool) Info() ToolInfo {
110114 return ToolInfo{
111115 Name: EditToolName,
internal/llm/tools/fetch.go+4
@@ -79,6 +79,10 @@ func NewFetchTool(permissions permission.Service) BaseTool {
7979 }
8080}
8181
82+func (t *fetchTool) Name() string {
83+ return FetchToolName
84+}
85+
8286func (t *fetchTool) Info() ToolInfo {
8387 return ToolInfo{
8488 Name: FetchToolName,
internal/llm/tools/glob.go+4
@@ -74,6 +74,10 @@ func NewGlobTool() BaseTool {
7474 return &globTool{}
7575}
7676
77+func (g *globTool) Name() string {
78+ return GlobToolName
79+}
80+
7781func (g *globTool) Info() ToolInfo {
7882 return ToolInfo{
7983 Name: GlobToolName,
internal/llm/tools/grep.go+4
@@ -140,6 +140,10 @@ func NewGrepTool() BaseTool {
140140 return &grepTool{}
141141}
142142
143+func (g *grepTool) Name() string {
144+ return GrepToolName
145+}
146+
143147func (g *grepTool) Info() ToolInfo {
144148 return ToolInfo{
145149 Name: GrepToolName,
internal/llm/tools/ls.go+4
@@ -74,6 +74,10 @@ func NewLsTool() BaseTool {
7474 return &lsTool{}
7575}
7676
77+func (l *lsTool) Name() string {
78+ return LSToolName
79+}
80+
7781func (l *lsTool) Info() ToolInfo {
7882 return ToolInfo{
7983 Name: LSToolName,
internal/llm/tools/sourcegraph.go+4
@@ -138,6 +138,10 @@ func NewSourcegraphTool() BaseTool {
138138 }
139139}
140140
141+func (t *sourcegraphTool) Name() string {
142+ return SourcegraphToolName
143+}
144+
141145func (t *sourcegraphTool) Info() ToolInfo {
142146 return ToolInfo{
143147 Name: SourcegraphToolName,
internal/llm/tools/tools.go+1
@@ -68,6 +68,7 @@ type ToolCall struct {
6868
6969type BaseTool interface {
7070 Info() ToolInfo
71+ Name() string
7172 Run(ctx context.Context, params ToolCall) (ToolResponse, error)
7273}
7374
internal/llm/tools/view.go+4
@@ -77,6 +77,10 @@ func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
7777 }
7878}
7979
80+func (v *viewTool) Name() string {
81+ return ViewToolName
82+}
83+
8084func (v *viewTool) Info() ToolInfo {
8185 return ToolInfo{
8286 Name: ViewToolName,
internal/llm/tools/write.go+4
@@ -84,6 +84,10 @@ func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Serv
8484 }
8585}
8686
87+func (w *writeTool) Name() string {
88+ return WriteToolName
89+}
90+
8791func (w *writeTool) Info() ToolInfo {
8892 return ToolInfo{
8993 Name: WriteToolName,
internal/logging/logger.go+133−2
@@ -4,16 +4,35 @@ import (
44 "fmt"
55 "log/slog"
66 "os"
7+
8+ // "path/filepath"
9+ "encoding/json"
10+ "runtime"
711 "runtime/debug"
12+ "sync"
813 "time"
914)
1015
16+func getCaller() string {
17+ var caller string
18+ if _, file, line, ok := runtime.Caller(2); ok {
19+ // caller = fmt.Sprintf("%s:%d", filepath.Base(file), line)
20+ caller = fmt.Sprintf("%s:%d", file, line)
21+ } else {
22+ caller = "unknown"
23+ }
24+ return caller
25+}
26+
1127func Info(msg string, args ...any) {
12− slog.Info(msg, args...)
28+ source := getCaller()
29+ slog.Info(msg, append([]any{"source", source}, args...)...)
1330}
1431
1532func Debug(msg string, args ...any) {
16− slog.Debug(msg, args...)
33+ // slog.Debug(msg, args...)
34+ source := getCaller()
35+ slog.Debug(msg, append([]any{"source", source}, args...)...)
1736}
1837
1938func Warn(msg string, args ...any) {
@@ -76,3 +95,115 @@ func RecoverPanic(name string, cleanup func()) {
7695 }
7796 }
7897}
98+
99+// Message Logging for Debug
100+var MessageDir string
101+
102+func GetSessionPrefix(sessionId string) string {
103+ return sessionId[:8]
104+}
105+
106+var sessionLogMutex sync.Mutex
107+
108+func AppendToSessionLogFile(sessionId string, filename string, content string) string {
109+ if MessageDir == "" || sessionId == "" {
110+ return ""
111+ }
112+ sessionPrefix := GetSessionPrefix(sessionId)
113+
114+ sessionLogMutex.Lock()
115+ defer sessionLogMutex.Unlock()
116+
117+ sessionPath := fmt.Sprintf("%s/%s", MessageDir, sessionPrefix)
118+ if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
119+ if err := os.MkdirAll(sessionPath, 0o766); err != nil {
120+ Error("Failed to create session directory", "dirpath", sessionPath, "error", err)
121+ return ""
122+ }
123+ }
124+
125+ filePath := fmt.Sprintf("%s/%s", sessionPath, filename)
126+
127+ f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
128+ if err != nil {
129+ Error("Failed to open session log file", "filepath", filePath, "error", err)
130+ return ""
131+ }
132+ defer f.Close()
133+
134+ // Append chunk to file
135+ _, err = f.WriteString(content)
136+ if err != nil {
137+ Error("Failed to write chunk to session log file", "filepath", filePath, "error", err)
138+ return ""
139+ }
140+ return filePath
141+}
142+
143+func WriteRequestMessageJson(sessionId string, requestSeqId int, message any) string {
144+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
145+ return ""
146+ }
147+ msgJson, err := json.Marshal(message)
148+ if err != nil {
149+ Error("Failed to marshal message", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
150+ return ""
151+ }
152+ return WriteRequestMessage(sessionId, requestSeqId, string(msgJson))
153+}
154+
155+func WriteRequestMessage(sessionId string, requestSeqId int, message string) string {
156+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
157+ return ""
158+ }
159+ filename := fmt.Sprintf("%d_request.json", requestSeqId)
160+
161+ return AppendToSessionLogFile(sessionId, filename, message)
162+}
163+
164+func AppendToStreamSessionLogJson(sessionId string, requestSeqId int, jsonableChunk any) string {
165+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
166+ return ""
167+ }
168+ chunkJson, err := json.Marshal(jsonableChunk)
169+ if err != nil {
170+ Error("Failed to marshal message", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
171+ return ""
172+ }
173+ return AppendToStreamSessionLog(sessionId, requestSeqId, string(chunkJson))
174+}
175+
176+func AppendToStreamSessionLog(sessionId string, requestSeqId int, chunk string) string {
177+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
178+ return ""
179+ }
180+ filename := fmt.Sprintf("%d_response_stream.log", requestSeqId)
181+ return AppendToSessionLogFile(sessionId, filename, chunk)
182+}
183+
184+func WriteChatResponseJson(sessionId string, requestSeqId int, response any) string {
185+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
186+ return ""
187+ }
188+ responseJson, err := json.Marshal(response)
189+ if err != nil {
190+ Error("Failed to marshal response", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
191+ return ""
192+ }
193+ filename := fmt.Sprintf("%d_response.json", requestSeqId)
194+
195+ return AppendToSessionLogFile(sessionId, filename, string(responseJson))
196+}
197+
198+func WriteToolResultsJson(sessionId string, requestSeqId int, toolResults any) string {
199+ if MessageDir == "" || sessionId == "" || requestSeqId <= 0 {
200+ return ""
201+ }
202+ toolResultsJson, err := json.Marshal(toolResults)
203+ if err != nil {
204+ Error("Failed to marshal tool results", "session_id", sessionId, "request_seq_id", requestSeqId, "error", err)
205+ return ""
206+ }
207+ filename := fmt.Sprintf("%d_tool_results.json", requestSeqId)
208+ return AppendToSessionLogFile(sessionId, filename, string(toolResultsJson))
209+}
internal/logging/writer.go+1
@@ -45,6 +45,7 @@ type writer struct{}
4545
4646func (w *writer) Write(p []byte) (int, error) {
4747 d := logfmt.NewDecoder(bytes.NewReader(p))
48+
4849 for d.ScanRecord() {
4950 msg := LogMessage{
5051 ID: fmt.Sprintf("%d", time.Now().UnixNano()),
internal/lsp/client.go+16−16
@@ -96,10 +96,10 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
9696 go func() {
9797 scanner := bufio.NewScanner(stderr)
9898 for scanner.Scan() {
99− fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
99+ logging.Error("LSP Server", "err", scanner.Text())
100100 }
101101 if err := scanner.Err(); err != nil {
102− fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
102+ logging.Error("Error reading", "err", err)
103103 }
104104 }()
105105
@@ -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.ModelProvider) 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+7−11
@@ -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"
@@ -76,7 +75,7 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
7675}
7776
7877func (m *sidebarCmp) Init() tea.Cmd {
79− m.logo = m.logoBlock(false)
78+ m.logo = m.logoBlock()
8079 m.cwd = cwd()
8180 return nil
8281}
@@ -231,9 +230,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
231230
232231func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233232 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
234− m.logo = m.logoBlock(true)
233+ m.logo = m.logoBlock()
235234 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
236− m.logo = m.logoBlock(false)
235+ m.logo = m.logoBlock()
237236 }
238237
239238 m.width = width
@@ -245,9 +244,9 @@ func (m *sidebarCmp) GetSize() (int, int) {
245244 return m.width, m.height
246245}
247246
248−func (m *sidebarCmp) logoBlock(compact bool) string {
247+func (m *sidebarCmp) logoBlock() string {
249248 t := styles.CurrentTheme()
250− return logo.Render(version.Version, compact, logo.Opts{
249+ return logo.Render(version.Version, true, logo.Opts{
251250 FieldColor: t.Primary,
252251 TitleColorA: t.Secondary,
253252 TitleColorB: t.Primary,
@@ -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/chat/splash/keys.gonew file+18
@@ -0,0 +1,18 @@
1+package splash
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+)
6+
7+type KeyMap struct {
8+ Cancel key.Binding
9+}
10+
11+func DefaultKeyMap() KeyMap {
12+ return KeyMap{
13+ Cancel: key.NewBinding(
14+ key.WithKeys("esc"),
15+ key.WithHelp("esc", "cancel"),
16+ ),
17+ }
18+}
internal/tui/components/chat/splash/splash.gonew file+85
@@ -0,0 +1,85 @@
1+package splash
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
7+ "github.com/charmbracelet/crush/internal/tui/components/logo"
8+ "github.com/charmbracelet/crush/internal/tui/styles"
9+ "github.com/charmbracelet/crush/internal/tui/util"
10+ "github.com/charmbracelet/crush/internal/version"
11+ "github.com/charmbracelet/lipgloss/v2"
12+)
13+
14+type Splash interface {
15+ util.Model
16+ layout.Sizeable
17+ layout.Help
18+}
19+
20+type splashCmp struct {
21+ width, height int
22+ keyMap KeyMap
23+ logoRendered string
24+}
25+
26+func New() Splash {
27+ return &splashCmp{
28+ width: 0,
29+ height: 0,
30+ keyMap: DefaultKeyMap(),
31+ logoRendered: "",
32+ }
33+}
34+
35+// GetSize implements SplashPage.
36+func (s *splashCmp) GetSize() (int, int) {
37+ return s.width, s.height
38+}
39+
40+// Init implements SplashPage.
41+func (s *splashCmp) Init() tea.Cmd {
42+ return nil
43+}
44+
45+// SetSize implements SplashPage.
46+func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
47+ s.width = width
48+ s.height = height
49+ s.logoRendered = s.logoBlock()
50+ return nil
51+}
52+
53+// Update implements SplashPage.
54+func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55+ switch msg := msg.(type) {
56+ case tea.WindowSizeMsg:
57+ return s, s.SetSize(msg.Width, msg.Height)
58+ }
59+ return s, nil
60+}
61+
62+// View implements SplashPage.
63+func (s *splashCmp) View() tea.View {
64+ content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
65+ return tea.NewView(content)
66+}
67+
68+func (m *splashCmp) logoBlock() string {
69+ t := styles.CurrentTheme()
70+ return logo.Render(version.Version, false, logo.Opts{
71+ FieldColor: t.Primary,
72+ TitleColorA: t.Secondary,
73+ TitleColorB: t.Primary,
74+ CharmColor: t.Secondary,
75+ VersionColor: t.Primary,
76+ Width: m.width - 2, // -2 for padding
77+ })
78+}
79+
80+// Bindings implements SplashPage.
81+func (s *splashCmp) Bindings() []key.Binding {
82+ return []key.Binding{
83+ s.keyMap.Cancel,
84+ }
85+}
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/models/keys.go+8−2
@@ -8,13 +8,14 @@ type KeyMap struct {
88 Select,
99 Next,
1010 Previous,
11+ Tab,
1112 Close key.Binding
1213}
1314
1415func DefaultKeyMap() KeyMap {
1516 return KeyMap{
1617 Select: key.NewBinding(
17− key.WithKeys("enter", "tab", "ctrl+y"),
18+ key.WithKeys("enter", "ctrl+y"),
1819 key.WithHelp("enter", "confirm"),
1920 ),
2021 Next: key.NewBinding(
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
2526 key.WithKeys("up", "ctrl+p"),
2627 key.WithHelp("↑", "previous item"),
2728 ),
29+ Tab: key.NewBinding(
30+ key.WithKeys("tab"),
31+ key.WithHelp("tab", "toggle type"),
32+ ),
2833 Close: key.NewBinding(
2934 key.WithKeys("esc"),
3035 key.WithHelp("esc", "cancel"),
@@ -38,6 +43,7 @@ func (k KeyMap) KeyBindings() []key.Binding {
3843 k.Select,
3944 k.Next,
4045 k.Previous,
46+ k.Tab,
4147 k.Close,
4248 }
4349}
@@ -57,10 +63,10 @@ func (k KeyMap) FullHelp() [][]key.Binding {
5763func (k KeyMap) ShortHelp() []key.Binding {
5864 return []key.Binding{
5965 key.NewBinding(
60−
6166 key.WithKeys("down", "up"),
6267 key.WithHelp("↑↓", "choose"),
6368 ),
69+ k.Tab,
6470 k.Select,
6571 k.Close,
6672 }
internal/tui/components/dialogs/models/models.go+157−99
@@ -7,7 +7,7 @@ import (
77 "github.com/charmbracelet/bubbles/v2/key"
88 tea "github.com/charmbracelet/bubbletea/v2"
99 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/llm/models"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
1111 "github.com/charmbracelet/crush/internal/tui/components/completions"
1212 "github.com/charmbracelet/crush/internal/tui/components/core"
1313 "github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -24,9 +24,15 @@ const (
2424 defaultWidth = 60
2525)
2626
27+const (
28+ LargeModelType int = iota
29+ SmallModelType
30+)
31+
2732// ModelSelectedMsg is sent when a model is selected
2833type ModelSelectedMsg struct {
29− Model models.Model
34+ Model config.PreferredModel
35+ ModelType config.ModelType
3036}
3137
3238// CloseModelDialogMsg is sent when a model is selected
@@ -37,14 +43,20 @@ type ModelDialog interface {
3743 dialogs.DialogModel
3844}
3945
46+type ModelOption struct {
47+ Provider provider.Provider
48+ Model provider.Model
49+}
50+
4051type modelDialogCmp struct {
4152 width int
42− wWidth int // Width of the terminal window
43− wHeight int // Height of the terminal window
53+ wWidth int
54+ wHeight int
4455
4556 modelList list.ListModel
4657 keyMap KeyMap
4758 help help.Model
59+ modelType int
4860}
4961
5062func NewModelDialogCmp() ModelDialog {
@@ -77,49 +89,12 @@ func NewModelDialogCmp() ModelDialog {
7789 width: defaultWidth,
7890 keyMap: DefaultKeyMap(),
7991 help: help,
92+ modelType: LargeModelType,
8093 }
8194}
8295
83−var ProviderPopularity = map[models.ModelProvider]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.ModelProvider]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−
10796func (m *modelDialogCmp) Init() tea.Cmd {
108− cfg := config.Get()
109− enabledProviders := getEnabledProviders(cfg)
110−
111− 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
116− }
117− modelItems = append(modelItems, commands.NewItemSection(name))
118− for _, model := range getModelsForProvider(provider) {
119− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model))
120− }
121− }
122− m.modelList.SetItems(modelItems)
97+ m.SetModelType(m.modelType)
12398 return m.modelList.Init()
12499}
125100
@@ -128,21 +103,41 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128103 case tea.WindowSizeMsg:
129104 m.wWidth = msg.Width
130105 m.wHeight = msg.Height
106+ m.SetModelType(m.modelType)
131107 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
132108 case tea.KeyPressMsg:
133109 switch {
134110 case key.Matches(msg, m.keyMap.Select):
135111 selectedItemInx := m.modelList.SelectedIndex()
136112 if selectedItemInx == list.NoSelection {
137− return m, nil // No item selected, do nothing
113+ return m, nil
138114 }
139115 items := m.modelList.Items()
140− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model)
116+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
117+
118+ var modelType config.ModelType
119+ if m.modelType == LargeModelType {
120+ modelType = config.LargeModel
121+ } else {
122+ modelType = config.SmallModel
123+ }
141124
142125 return m, tea.Sequence(
143126 util.CmdHandler(dialogs.CloseDialogMsg{}),
144− util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
127+ util.CmdHandler(ModelSelectedMsg{
128+ Model: config.PreferredModel{
129+ ModelID: selectedItem.Model.ID,
130+ Provider: selectedItem.Provider.ID,
131+ },
132+ ModelType: modelType,
133+ }),
145134 )
135+ case key.Matches(msg, m.keyMap.Tab):
136+ if m.modelType == LargeModelType {
137+ return m, m.SetModelType(SmallModelType)
138+ } else {
139+ return m, m.SetModelType(LargeModelType)
140+ }
146141 case key.Matches(msg, m.keyMap.Close):
147142 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
148143 default:
@@ -157,9 +152,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157152func (m *modelDialogCmp) View() tea.View {
158153 t := styles.CurrentTheme()
159154 listView := m.modelList.View()
155+ radio := m.modelTypeRadio()
160156 content := lipgloss.JoinVertical(
161157 lipgloss.Left,
162− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)),
158+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-lipgloss.Width(radio)-5)+" "+radio),
163159 listView.String(),
164160 "",
165161 t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
@@ -189,58 +185,6 @@ func (m *modelDialogCmp) listHeight() int {
189185 return min(listHeigh, m.wHeight/2)
190186}
191187
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.ModelProvider {
199− var providers []models.ModelProvider
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.ModelProvider) 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.ModelProvider) []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−
244188func (m *modelDialogCmp) Position() (int, int) {
245189 row := m.wHeight/4 - 2 // just a bit above the center
246190 col := m.wWidth / 2
@@ -259,3 +203,117 @@ func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
259203func (m *modelDialogCmp) ID() dialogs.DialogID {
260204 return ModelsDialogID
261205}
206+
207+func (m *modelDialogCmp) modelTypeRadio() string {
208+ t := styles.CurrentTheme()
209+ choices := []string{"Large Task", "Small Task"}
210+ iconSelected := "◉"
211+ iconUnselected := "○"
212+ if m.modelType == LargeModelType {
213+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
214+ }
215+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
216+}
217+
218+func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
219+ m.modelType = modelType
220+
221+ providers := config.Providers()
222+ modelItems := []util.Model{}
223+ selectIndex := 0
224+
225+ cfg := config.Get()
226+ var currentModel config.PreferredModel
227+ if m.modelType == LargeModelType {
228+ currentModel = cfg.Models.Large
229+ } else {
230+ currentModel = cfg.Models.Small
231+ }
232+
233+ // Create a map to track which providers we've already added
234+ addedProviders := make(map[provider.InferenceProvider]bool)
235+
236+ // First, add any configured providers that are not in the known providers list
237+ // These should appear at the top of the list
238+ knownProviders := provider.KnownProviders()
239+ for providerID, providerConfig := range cfg.Providers {
240+ if providerConfig.Disabled {
241+ continue
242+ }
243+
244+ // Check if this provider is not in the known providers list
245+ if !slices.Contains(knownProviders, providerID) {
246+ // Convert config provider to provider.Provider format
247+ configProvider := provider.Provider{
248+ Name: string(providerID), // Use provider ID as name for unknown providers
249+ ID: providerID,
250+ Models: make([]provider.Model, len(providerConfig.Models)),
251+ }
252+
253+ // Convert models
254+ for i, model := range providerConfig.Models {
255+ configProvider.Models[i] = provider.Model{
256+ ID: model.ID,
257+ Name: model.Name,
258+ CostPer1MIn: model.CostPer1MIn,
259+ CostPer1MOut: model.CostPer1MOut,
260+ CostPer1MInCached: model.CostPer1MInCached,
261+ CostPer1MOutCached: model.CostPer1MOutCached,
262+ ContextWindow: model.ContextWindow,
263+ DefaultMaxTokens: model.DefaultMaxTokens,
264+ CanReason: model.CanReason,
265+ HasReasoningEffort: model.HasReasoningEffort,
266+ DefaultReasoningEffort: model.ReasoningEffort,
267+ SupportsImages: model.SupportsImages,
268+ }
269+ }
270+
271+ // Add this unknown provider to the list
272+ name := configProvider.Name
273+ if name == "" {
274+ name = string(configProvider.ID)
275+ }
276+ modelItems = append(modelItems, commands.NewItemSection(name))
277+ for _, model := range configProvider.Models {
278+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
279+ Provider: configProvider,
280+ Model: model,
281+ }))
282+ if model.ID == currentModel.ModelID && configProvider.ID == currentModel.Provider {
283+ selectIndex = len(modelItems) - 1 // Set the selected index to the current model
284+ }
285+ }
286+ addedProviders[providerID] = true
287+ }
288+ }
289+
290+ // Then add the known providers from the predefined list
291+ for _, provider := range providers {
292+ // Skip if we already added this provider as an unknown provider
293+ if addedProviders[provider.ID] {
294+ continue
295+ }
296+
297+ // Check if this provider is configured and not disabled
298+ if providerConfig, exists := cfg.Providers[provider.ID]; exists && providerConfig.Disabled {
299+ continue
300+ }
301+
302+ name := provider.Name
303+ if name == "" {
304+ name = string(provider.ID)
305+ }
306+ modelItems = append(modelItems, commands.NewItemSection(name))
307+ for _, model := range provider.Models {
308+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
309+ Provider: provider,
310+ Model: model,
311+ }))
312+ if model.ID == currentModel.ModelID && provider.ID == currentModel.Provider {
313+ selectIndex = len(modelItems) - 1 // Set the selected index to the current model
314+ }
315+ }
316+ }
317+
318+ return tea.Sequence(m.modelList.SetItems(modelItems), m.modelList.SetSelected(selectIndex))
319+}
internal/tui/components/logo/logo.go+2−1
@@ -26,6 +26,7 @@ type Opts struct {
2626 TitleColorB color.Color // right gradient ramp point
2727 CharmColor color.Color // Charm™ text color
2828 VersionColor color.Color // Version text color
29+ Width int // width of the rendered logo, used for truncation
2930}
3031
3132// Render renders the Crush logo. Set the argument to true to render the narrow
@@ -76,7 +77,7 @@ func Render(version string, compact bool, o Opts) string {
7677 }
7778
7879 // Right field.
79− const rightWidth = 15
80+ rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
8081 const stepDownAt = 0
8182 rightField := new(strings.Builder)
8283 for i := range fieldHeight {
internal/tui/components/logs/details.go−1
@@ -161,7 +161,6 @@ func (i *detailCmp) GetSize() (int, int) {
161161}
162162
163163func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
164− logging.Info("Setting size for detail component", "width", width, "height", height)
165164 i.width = width
166165 i.height = height
167166 i.viewport.SetWidth(i.width - 4)
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+29−7
@@ -69,7 +69,7 @@ func (a appModel) Init() tea.Cmd {
6969
7070 // Check if we should show the init dialog
7171 cmds = append(cmds, func() tea.Msg {
72− shouldShow, err := config.ShouldShowInitDialog()
72+ shouldShow, err := config.ProjectNeedsInitialization()
7373 if err != nil {
7474 return util.InfoMsg{
7575 Type: util.InfoTypeError,
@@ -172,12 +172,19 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172172
173173 // Model Switch
174174 case models.ModelSelectedMsg:
175− model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
176− if err != nil {
177− return a, util.ReportError(err)
175+ config.UpdatePreferredModel(msg.ModelType, msg.Model)
176+
177+ // Update the agent with the new model/provider configuration
178+ if err := a.app.UpdateAgentModel(); err != nil {
179+ logging.ErrorPersist(fmt.Sprintf("Failed to update agent model: %v", err))
180+ return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.ModelID, err))
178181 }
179182
180− return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
183+ modelTypeName := "large"
184+ if msg.ModelType == config.SmallModel {
185+ modelTypeName = "small"
186+ }
187+ return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.ModelID))
181188
182189 // File Picker
183190 case chat.OpenFilePickerMsg:
@@ -221,8 +228,23 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
221228 if err == nil {
222229 model := a.app.CoderAgent.Model()
223230 contextWindow := model.ContextWindow
224− tokens := session.CompletionTokens + session.PromptTokens
225− if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
231+ usedTokens := session.CompletionTokens + session.PromptTokens
232+ remainingTokens := contextWindow - usedTokens
233+
234+ // Get effective max tokens for this agent (considering overrides)
235+ maxTokens := a.app.CoderAgent.EffectiveMaxTokens()
236+
237+ // Apply 10% margin to max tokens
238+ maxTokensWithMargin := int64(float64(maxTokens) * 1.1)
239+
240+ // Trigger auto-summarize if remaining tokens < max tokens + 10% margin
241+ // Also ensure we have a reasonable minimum threshold to avoid too-frequent summaries
242+ minThreshold := int64(1000) // Minimum 1000 tokens remaining before triggering
243+ if maxTokensWithMargin < minThreshold {
244+ maxTokensWithMargin = minThreshold
245+ }
246+
247+ if remainingTokens < maxTokensWithMargin && !config.Get().Options.DisableAutoSummarize {
226248 // Show compact confirmation dialog
227249 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
228250 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),