on main help sign in
Merge pull request #83 from charmbracelet/config-provider-changes
6f2387cKujtim Hoxha
CRUSH.md+23
@@ -24,6 +24,29 @@
24- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
24- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
25- **Comments**: End comments in periods unless comments are at the end of the line.
25- **Comments**: End comments in periods unless comments are at the end of the line.
26
26
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+
27## Formatting
50## Formatting
28
51
29- ALWAYS format any Go code you write.
52- ALWAYS format any Go code you write.
Taskfile.yaml+8−2
@@ -6,12 +6,12 @@ tasks:
6 lint:
6 lint:
7 desc: Run base linters
7 desc: Run base linters
8 cmds:
8 cmds:
9− - golangci-lint run
9+ - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
10
10
11 lint-fix:
11 lint-fix:
12 desc: Run base linters and fix issues
12 desc: Run base linters and fix issues
13 cmds:
13 cmds:
14− - golangci-lint run --fix
14+ - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m --fix
15
15
16 test:
16 test:
17 desc: Run tests
17 desc: Run tests
@@ -44,3 +44,9 @@ tasks:
44 desc: Allocations profile
44 desc: Allocations profile
45 cmds:
45 cmds:
46 - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/allocs'
46 - 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
72 }
72 }
73 cwd = c
73 cwd = c
74 }
74 }
75+
75− _, err := config.Load(cwd, debug)
76+ _, err := config.Init(cwd, debug)
76 if err != nil {
77 if err != nil {
77 return err
78 return err
78 }
79 }
cmd/schema/README.md+145−27
@@ -1,64 +1,182 @@
1# Crush Configuration Schema Generator
1# Crush Configuration Schema Generator
2
2
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
4
12
5## Usage
13## Usage
6
14
7```bash
15```bash
16+# Generate the schema
8go run cmd/schema/main.go > crush-schema.json
17go run cmd/schema/main.go > crush-schema.json
18+
19+# Or use the task runner
20+task schema
9```
21```
10
22
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
12
32
13## Schema Features
33## Schema Features
14
34
15The generated schema includes:
35The generated schema includes:
16
36
17−- All configuration options with descriptions
37+- **Type Safety**: Proper type definitions for all configuration fields
18−- Default values where applicable
38+- **Validation**: Required fields, enum constraints, and format validation
19−- Validation for enum values (e.g., model IDs, provider types)
39+- **Documentation**: Descriptions for all configuration options
20−- Required fields
40+- **Defaults**: Default values for optional settings
21−- Type checking
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
22
70
23## Using the Schema
71## Using the Schema
24
72
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+```
26
88
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
28
90
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
30
92
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
32
97
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+```
34
102
35−Here's an example configuration that conforms to the schema:
103+### Configuration Example
36
104
37```json
105```json
38{
106{
39− "data": {
107+ "models": {
40− "directory": ".crush"
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+ }
41 },
118 },
42− "debug": false,
43 "providers": {
119 "providers": {
44 "anthropic": {
120 "anthropic": {
121+ "id": "anthropic",
122+ "provider_type": "anthropic",
45− "apiKey": "your-api-key"
123+ "api_key": "your-api-key",
124+ "disabled": false
46 }
125 }
47 },
126 },
48 "agents": {
127 "agents": {
49 "coder": {
128 "coder": {
50− "model": "claude-3.7-sonnet",
129+ "id": "coder",
51− "maxTokens": 5000,
130+ "name": "Coder",
52− "reasoningEffort": "medium"
131+ "model": "large",
53− },
132+ "disabled": false
54− "task": {
55− "model": "claude-3.7-sonnet",
56− "maxTokens": 5000
57 },
133 },
58− "title": {
134+ "custom-agent": {
59− "model": "claude-3.7-sonnet",
135+ "id": "custom-agent",
60− "maxTokens": 80
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
61 }
169 }
62 }
170 }
63}
171}
64```
172```
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 (
6 "os"
6 "os"
7
7
8 "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/llm/models"
9+ "github.com/invopop/jsonschema"
10)
10)
11
11
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−
26func main() {
12func 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"
28
32
29 // Pretty print the schema
33 // Pretty print the schema
30 encoder := json.NewEncoder(os.Stdout)
34 encoder := json.NewEncoder(os.Stdout)
@@ -35,276 +39,117 @@ func main() {
35 }
39 }
36}
40}
37
41
38−func generateSchema() map[string]any {
42+// enhanceSchema adds additional enhancements to the generated schema
39− schema := map[string]any{
43+func enhanceSchema(schema *jsonschema.Schema) {
40− "$schema": "http://json-schema.org/draft-07/schema#",
44+ // Add provider enums
41− "title": "Crush Configuration",
45+ addProviderEnums(schema)
42− "description": "Configuration schema for the Crush application",
43− "type": "object",
44− "properties": map[string]any{},
45− }
46
46
47− // Add Data configuration
47+ // Add model enums
48− schema["properties"].(map[string]any)["data"] = map[string]any{
48+ addModelEnums(schema)
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− }
60
49
61− // Add working directory
50+ // Add tool enums
62− schema["properties"].(map[string]any)["wd"] = map[string]any{
51+ addToolEnums(schema)
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− }
125
52
126− // Add MCP servers
53+ // Add default context paths
127− schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
54+ addDefaultContextPaths(schema)
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− }
55+}
173
56
174− // Add providers
57+// addProviderEnums adds provider enums to the schema
175− providerSchema := map[string]any{
58+func addProviderEnums(schema *jsonschema.Schema) {
176− "type": "object",
59+ providers := config.Providers()
177− "description": "LLM provider configurations",
60+ var providerIDs []any
178− "additionalProperties": map[string]any{
61+ for _, p := range providers {
179− "type": "object",
62+ providerIDs = append(providerIDs, string(p.ID))
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− },
193 }
63 }
194
64
195− // Add known providers
65+ // Add to PreferredModel provider field
196− knownProviders := []string{
66+ if schema.Definitions != nil {
197− string(models.ProviderAnthropic),
67+ if preferredModelDef, exists := schema.Definitions["PreferredModel"]; exists {
198− string(models.ProviderOpenAI),
68+ if providerProp, exists := preferredModelDef.Properties.Get("provider"); exists {
199− string(models.ProviderGemini),
69+ providerProp.Enum = providerIDs
200− string(models.ProviderGROQ),
70+ }
201− string(models.ProviderOpenRouter),
202− string(models.ProviderBedrock),
203− string(models.ProviderAzure),
204− string(models.ProviderVertexAI),
205− }
71+ }
206
72
207− providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
73+ // Add to ProviderConfig ID field
208− "type": "string",
74+ if providerConfigDef, exists := schema.Definitions["ProviderConfig"]; exists {
209− "description": "Provider type",
75+ if idProp, exists := providerConfigDef.Properties.Get("id"); exists {
210− "enum": knownProviders,
76+ idProp.Enum = providerIDs
77+ }
78+ }
211 }
79 }
80+}
212
81
213− schema["properties"].(map[string]any)["providers"] = providerSchema
82+// addModelEnums adds model enums to the schema
214−
83+func addModelEnums(schema *jsonschema.Schema) {
215− // Add agents
84+ providers := config.Providers()
216− agentSchema := map[string]any{
85+ var modelIDs []any
217− "type": "object",
86+ for _, p := range providers {
218− "description": "Agent configurations",
87+ for _, m := range p.Models {
219− "additionalProperties": map[string]any{
88+ modelIDs = append(modelIDs, m.ID)
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− },
89+ }
240 }
90 }
241
91
242− // Add model enum
92+ // Add to PreferredModel model_id field
243− modelEnum := []string{}
93+ if schema.Definitions != nil {
244− for modelID := range models.SupportedModels {
94+ if preferredModelDef, exists := schema.Definitions["PreferredModel"]; exists {
245− modelEnum = append(modelEnum, string(modelID))
95+ if modelIDProp, exists := preferredModelDef.Properties.Get("model_id"); exists {
96+ modelIDProp.Enum = modelIDs
97+ }
98+ }
246 }
99 }
247− agentSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["model"].(map[string]any)["enum"] = modelEnum
100+}
248
101
249− // Add specific agent properties
102+// addToolEnums adds tool enums to the schema
250− agentProperties := map[string]any{}
103+func addToolEnums(schema *jsonschema.Schema) {
251− knownAgents := []string{
104+ tools := []any{
252− string(config.AgentCoder),
105+ "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
253− string(config.AgentTask),
254− string(config.AgentTitle),
255 }
106 }
256
107
257− for _, agentName := range knownAgents {
108+ if schema.Definitions != nil {
258− agentProperties[agentName] = map[string]any{
109+ if agentDef, exists := schema.Definitions["Agent"]; exists {
259− "$ref": "#/definitions/agent",
110+ if allowedToolsProp, exists := agentDef.Properties.Get("allowed_tools"); exists {
111+ if allowedToolsProp.Items != nil {
112+ allowedToolsProp.Items.Enum = tools
113+ }
114+ }
260 }
115 }
261 }
116 }
117+}
262
118
263− // Create a combined schema that allows both specific agents and additional ones
119+// addDefaultContextPaths adds default context paths to the schema
264− combinedAgentSchema := map[string]any{
120+func addDefaultContextPaths(schema *jsonschema.Schema) {
265− "type": "object",
121+ defaultContextPaths := []any{
266− "description": "Agent configurations",
122+ ".github/copilot-instructions.md",
267− "properties": agentProperties,
123+ ".cursorrules",
268− "additionalProperties": agentSchema["additionalProperties"],
124+ ".cursor/rules/",
269− }
125+ "CLAUDE.md",
270−
126+ "CLAUDE.local.md",
271− schema["properties"].(map[string]any)["agents"] = combinedAgentSchema
127+ "GEMINI.md",
272− schema["definitions"] = map[string]any{
128+ "gemini.md",
273− "agent": agentSchema["additionalProperties"],
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+ }
274 }
143 }
275
144
276− // Add LSP configuration
145+ // Also add to root properties if they exist
277− schema["properties"].(map[string]any)["lsp"] = map[string]any{
146+ if schema.Properties != nil {
278− "type": "object",
147+ if optionsProp, exists := schema.Properties.Get("options"); exists {
279− "description": "Language Server Protocol configurations",
148+ if optionsProp.Properties != nil {
280− "additionalProperties": map[string]any{
149+ if contextPathsProp, exists := optionsProp.Properties.Get("context_paths"); exists {
281− "type": "object",
150+ contextPathsProp.Default = defaultContextPaths
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− },
151+ }
304− },
152+ }
305− "required": []string{"command"},
306− },
153+ }
307 }
154 }
308−
309− return schema
310}
155}
crush-schema.json+657−335
@@ -1,383 +1,705 @@
1{
1{
2− "$schema": "http://json-schema.org/draft-07/schema#",
2+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3− "definitions": {
3+ "$defs": {
4− "agent": {
4+ "Agent": {
5− "description": "Agent configuration",
6 "properties": {
5 "properties": {
7− "maxTokens": {
6+ "id": {
7+ "type": "string",
8+ "enum": [
9+ "coder",
10+ "task",
11+ "coder",
12+ "task"
13+ ],
14+ "title": "Agent ID",
8− "description": "Maximum tokens for the agent",
15+ "description": "Unique identifier for the agent"
9− "minimum": 1,
16+ },
10− "type": "integer"
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
11 },
32 },
12 "model": {
33 "model": {
13− "description": "Model ID for the agent",
34+ "type": "string",
14 "enum": [
35 "enum": [
15− "gpt-4.1",
36+ "large",
16− "llama-3.3-70b-versatile",
37+ "small",
17− "azure.gpt-4.1",
38+ "large",
18− "openrouter.gpt-4o",
39+ "small"
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"
81 ],
40 ],
82− "type": "string"
41+ "title": "Model Type",
42+ "description": "Type of model to use (large or small)"
83 },
43 },
84− "reasoningEffort": {
44+ "allowed_tools": {
85− "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
45+ "items": {
86− "enum": ["low", "medium", "high"],
87− "type": "string"
46+ "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",
107 "enum": [
47 "enum": [
108− "gpt-4.1",
48+ "bash",
109− "llama-3.3-70b-versatile",
49+ "edit",
110− "azure.gpt-4.1",
50+ "fetch",
111− "openrouter.gpt-4o",
51+ "glob",
112− "openrouter.o1-mini",
52+ "grep",
113− "openrouter.claude-3-haiku",
53+ "ls",
114− "claude-3-opus",
54+ "sourcegraph",
115− "gpt-4o",
55+ "view",
116− "gpt-4o-mini",
56+ "write",
117− "o1",
57+ "agent"
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− ],
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": {
175− "type": "string"
67+ "type": "string"
68+ },
69+ "type": "array"
176 },
70 },
177− "reasoningEffort": {
71+ "type": "object",
178− "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
72+ "title": "Allowed MCP",
179− "enum": ["low", "medium", "high"],
73+ "description": "Map of MCP servers this agent can use and their allowed tools"
74+ },
75+ "allowed_lsp": {
76+ "items": {
180 "type": "string"
77 "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)"
182 },
82 },
183− "required": ["model"],
83+ "context_paths": {
84+ "items": {
85+ "type": "string"
86+ },
184− "type": "object"
87+ "type": "array",
88+ "title": "Context Paths",
89+ "description": "Custom context paths for this agent (additive to global context paths)"
90+ }
185 },
91 },
186− "description": "Agent configurations",
92+ "type": "object",
93+ "required": [
94+ "model"
95+ ]
96+ },
97+ "LSPConfig": {
187 "properties": {
98 "properties": {
188− "coder": {
99+ "enabled": {
189− "$ref": "#/definitions/agent"
100+ "type": "boolean",
101+ "title": "Enabled",
102+ "description": "Whether this LSP server is enabled",
103+ "default": true
190 },
104 },
191− "task": {
105+ "command": {
192− "$ref": "#/definitions/agent"
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"
193 },
117 },
194− "title": {
118+ "options": {
195− "$ref": "#/definitions/agent"
119+ "title": "Options",
120+ "description": "LSP server specific options"
196 }
121 }
197 },
122 },
198− "type": "object"
123+ "type": "object",
124+ "required": [
125+ "command"
126+ ]
199 },
127 },
200− "contextPaths": {
128+ "MCP": {
201− "default": [
129+ "properties": {
202− ".github/copilot-instructions.md",
130+ "command": {
203− ".cursorrules",
131+ "type": "string",
204− ".cursor/rules/",
132+ "title": "Command",
205− "CLAUDE.md",
133+ "description": "Command to execute for stdio MCP servers"
206− "CLAUDE.local.md",
134+ },
207− "GEMINI.md",
135+ "env": {
208− "gemini.md",
136+ "items": {
209− "crush.md",
137+ "type": "string"
210− "crush.local.md",
138+ },
211− "Crush.md",
139+ "type": "array",
212− "Crush.local.md",
140+ "title": "Environment",
213− "CRUSH.md",
141+ "description": "Environment variables for the MCP server"
214− "CRUSH.local.md"
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"
215− ],
158+ ],
216− "description": "Context paths for the application",
159+ "title": "Type",
217− "items": {
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": {
218− "type": "string"
170+ "type": "string"
171+ },
172+ "type": "object",
173+ "title": "Headers",
174+ "description": "HTTP headers for SSE MCP servers"
175+ }
219 },
176 },
220− "type": "array"
177+ "type": "object",
178+ "required": [
179+ "command",
180+ "type"
181+ ]
221 },
182 },
222− "data": {
183+ "Model": {
223− "description": "Storage configuration",
224 "properties": {
184 "properties": {
225− "directory": {
185+ "id": {
226− "default": ".crush",
186+ "type": "string",
227− "description": "Directory where application data is stored",
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": {
228− "type": "string"
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"
229 }
250 }
230 },
251 },
252+ "type": "object",
231− "required": ["directory"],
253+ "required": [
232− "type": "object"
254+ "id",
233− },
255+ "name",
234− "debug": {
256+ "cost_per_1m_out_cached",
235− "default": false,
257+ "context_window",
236− "description": "Enable debug mode",
258+ "default_max_tokens",
237− "type": "boolean"
259+ "can_reason",
260+ "reasoning_effort",
261+ "has_reasoning_effort",
262+ "supports_attachments"
263+ ]
238 },
264 },
239− "debugLSP": {
265+ "Options": {
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": {
266+ "properties": {
248− "args": {
267+ "context_paths": {
249− "description": "Command arguments for the LSP server",
250− "items": {
268+ "items": {
251− "type": "string"
252− },
253− "type": "array"
254− },
255− "command": {
256− "description": "Command to execute for the LSP server",
257 "type": "string"
269 "type": "string"
258 },
270 },
259− "disabled": {
271+ "type": "array",
260− "default": false,
272+ "title": "Context Paths",
261− "description": "Whether the LSP is disabled",
273+ "description": "List of paths to search for context files",
262− "type": "boolean"
274+ "default": [
263− },
275+ ".github/copilot-instructions.md",
264− "options": {
276+ ".cursorrules",
265− "description": "Additional options for the LSP server",
277+ ".cursor/rules/",
266− "type": "object"
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
267− }
306+ },
307+ "disable_auto_summarize": {
308+ "type": "boolean",
309+ "title": "Disable Auto Summarize",
310+ "description": "Disable automatic conversation summarization",
311+ "default": false
268 },
312 },
269− "required": ["command"],
313+ "data_directory": {
270− "type": "object"
314+ "type": "string",
315+ "title": "Data Directory",
316+ "description": "Directory for storing application data",
317+ "default": ".crush"
318+ }
271 },
319 },
272− "description": "Language Server Protocol configurations",
273 "type": "object"
320 "type": "object"
274 },
321 },
275− "mcpServers": {
322+ "PreferredModel": {
276− "additionalProperties": {
323+ "properties": {
277− "description": "MCP server configuration",
324+ "model_id": {
278− "properties": {
325+ "type": "string",
279− "args": {
326+ "enum": [
280− "description": "Command arguments for the MCP server",
327+ "claude-opus-4-20250514",
281− "items": {
328+ "claude-sonnet-4-20250514",
282− "type": "string"
329+ "claude-3-7-sonnet-20250219",
283− },
330+ "claude-3-5-haiku-20241022",
284− "type": "array"
331+ "claude-3-5-sonnet-20240620",
285− },
332+ "claude-3-5-sonnet-20241022",
286− "command": {
333+ "codex-mini-latest",
287− "description": "Command to execute for the MCP server",
334+ "o4-mini",
288− "type": "string"
335+ "o3",
289− },
336+ "o3-pro",
290− "env": {
337+ "gpt-4.1",
291− "description": "Environment variables for the MCP server",
338+ "gpt-4.1-mini",
292− "items": {
339+ "gpt-4.1-nano",
293− "type": "string"
340+ "gpt-4.5-preview",
294− },
341+ "o3-mini",
295− "type": "array"
342+ "gpt-4o",
296− },
343+ "gpt-4o-mini",
297− "headers": {
344+ "gemini-2.5-pro",
298− "additionalProperties": {
345+ "gemini-2.5-flash",
299− "type": "string"
346+ "codex-mini-latest",
300− },
347+ "o4-mini",
301− "description": "HTTP headers for SSE type MCP servers",
348+ "o3",
302− "type": "object"
349+ "o3-pro",
303− },
350+ "gpt-4.1",
304− "type": {
351+ "gpt-4.1-mini",
305− "default": "stdio",
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",
306− "description": "Type of MCP server",
509+ "description": "ID of the preferred model"
510+ },
511+ "provider": {
512+ "type": "string",
307− "enum": ["stdio", "sse"],
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": {
308− "type": "string"
527+ "type": "string",
528+ "title": "Reasoning Effort",
529+ "description": "Override reasoning effort for this model"
309− },
530+ },
310− "url": {
531+ "max_tokens": {
311− "description": "URL for SSE type MCP servers",
532+ "type": "integer",
312− "type": "string"
533+ "minimum": 1,
313− }
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"
314 },
556 },
315− "required": ["command"],
557+ "small": {
316− "type": "object"
558+ "$ref": "#/$defs/PreferredModel",
559+ "title": "Small Model",
560+ "description": "Preferred model configuration for small model type"
561+ }
317 },
562 },
318− "description": "Model Control Protocol server configurations",
319 "type": "object"
563 "type": "object"
320 },
564 },
321− "providers": {
565+ "ProviderConfig": {
322− "additionalProperties": {
323− "description": "Provider configuration",
324− "properties": {
566+ "properties": {
325− "apiKey": {
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",
326− "description": "API key for the provider",
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": {
327 "type": "string"
605 "type": "string"
328 },
606 },
329− "disabled": {
607+ "type": "object",
330− "default": false,
608+ "title": "Extra Headers",
331− "description": "Whether the provider is disabled",
609+ "description": "Additional HTTP headers to send with requests"
332− "type": "boolean"
333− },
610+ },
334− "provider": {
611+ "extra_params": {
335− "description": "Provider type",
612+ "additionalProperties": {
336− "enum": [
337− "anthropic",
338− "openai",
339− "gemini",
340− "groq",
341− "openrouter",
342− "bedrock",
343− "azure",
344− "vertexai"
345− ],
346 "type": "string"
613 "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"
348 },
628 },
629+ "models": {
630+ "items": {
631+ "$ref": "#/$defs/Model"
632+ },
349− "type": "object"
633+ "type": "array",
634+ "title": "Models",
635+ "description": "List of available models for this provider"
636+ }
350 },
637 },
351− "description": "LLM provider configurations",
352− "type": "object"
638+ "type": "object",
639+ "required": [
640+ "provider_type"
641+ ]
353 },
642 },
354− "tui": {
643+ "TUIOptions": {
355− "description": "Terminal User Interface configuration",
356 "properties": {
644 "properties": {
357− "theme": {
645+ "compact_mode": {
358− "default": "crush",
646+ "type": "boolean",
647+ "title": "Compact Mode",
359− "description": "TUI theme name",
648+ "description": "Enable compact mode for the TUI",
360− "enum": [
649+ "default": false
361− "crush",
362− "catppuccin",
363− "dracula",
364− "flexoki",
365− "gruvbox",
366− "monokai",
367− "onedark",
368− "tokyonight",
369− "tron"
370− ],
371− "type": "string"
372 }
650 }
373 },
651 },
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"
375 },
695 },
376− "wd": {
696+ "options": {
697+ "$ref": "#/$defs/Options",
698+ "title": "Options",
377− "description": "Working directory for the application",
699+ "description": "General application options and settings"
378− "type": "string"
379 }
700 }
380 },
701 },
702+ "type": "object",
381 "title": "Crush Configuration",
703 "title": "Crush Configuration",
382− "type": "object"
704+ "description": "Configuration schema for the Crush application"
383}
705}
.crush.json → crush.jsonrenamed+1−4
@@ -1,11 +1,8 @@
1{
1{
2 "$schema": "./crush-schema.json",
2 "$schema": "./crush-schema.json",
3 "lsp": {
3 "lsp": {
4− "Go": {
4+ "go": {
5 "command": "gopls"
5 "command": "gopls"
6 }
6 }
7− },
8− "tui": {
9− "theme": "crush"
10 }
7 }
11}
8}
cspell.json+1−39
@@ -1,39 +1 @@
1−{
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"}
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−}
go.mod+6−9
@@ -1,6 +1,6 @@
1module github.com/charmbracelet/crush
1module github.com/charmbracelet/crush
2
2
3−go 1.24.0
3+go 1.24.3
4
4
5require (
5require (
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
@@ -24,6 +24,7 @@ require (
24 github.com/fsnotify/fsnotify v1.8.0
24 github.com/fsnotify/fsnotify v1.8.0
25 github.com/go-logfmt/logfmt v0.6.0
25 github.com/go-logfmt/logfmt v0.6.0
26 github.com/google/uuid v1.6.0
26 github.com/google/uuid v1.6.0
27+ github.com/invopop/jsonschema v0.13.0
27 github.com/mark3labs/mcp-go v0.17.0
28 github.com/mark3labs/mcp-go v0.17.0
28 github.com/muesli/termenv v0.16.0
29 github.com/muesli/termenv v0.16.0
29 github.com/ncruces/go-sqlite3 v0.25.0
30 github.com/ncruces/go-sqlite3 v0.25.0
@@ -33,7 +34,6 @@ require (
33 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
34 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
34 github.com/sahilm/fuzzy v0.1.1
35 github.com/sahilm/fuzzy v0.1.1
35 github.com/spf13/cobra v1.9.1
36 github.com/spf13/cobra v1.9.1
36− github.com/spf13/viper v1.20.0
37 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
37 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
38 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
38 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
39 github.com/stretchr/testify v1.10.0
39 github.com/stretchr/testify v1.10.0
@@ -65,6 +65,8 @@ require (
65 github.com/aws/smithy-go v1.20.3 // indirect
65 github.com/aws/smithy-go v1.20.3 // indirect
66 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
66 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
67 github.com/aymerick/douceur v0.2.0 // indirect
67 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
68 github.com/charmbracelet/colorprofile v0.3.1 // indirect
70 github.com/charmbracelet/colorprofile v0.3.1 // indirect
69 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
71 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
70 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
72 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
@@ -78,7 +80,6 @@ require (
78 github.com/felixge/httpsnoop v1.0.4 // indirect
80 github.com/felixge/httpsnoop v1.0.4 // indirect
79 github.com/go-logr/logr v1.4.2 // indirect
81 github.com/go-logr/logr v1.4.2 // indirect
80 github.com/go-logr/stdr v1.2.2 // indirect
82 github.com/go-logr/stdr v1.2.2 // indirect
81− github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
82 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
83 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
83 github.com/google/go-cmp v0.7.0 // indirect
84 github.com/google/go-cmp v0.7.0 // indirect
84 github.com/google/s2a-go v0.1.8 // indirect
85 github.com/google/s2a-go v0.1.8 // indirect
@@ -89,6 +90,7 @@ require (
89 github.com/inconshreveable/mousetrap v1.1.0 // indirect
90 github.com/inconshreveable/mousetrap v1.1.0 // indirect
90 github.com/kylelemons/godebug v1.1.0 // indirect
91 github.com/kylelemons/godebug v1.1.0 // indirect
91 github.com/lucasb-eyer/go-colorful v1.2.0
92 github.com/lucasb-eyer/go-colorful v1.2.0
93+ github.com/mailru/easyjson v0.7.7 // indirect
92 github.com/mattn/go-isatty v0.0.20 // indirect
94 github.com/mattn/go-isatty v0.0.20 // indirect
93 github.com/mattn/go-runewidth v0.0.16 // indirect
95 github.com/mattn/go-runewidth v0.0.16 // indirect
94 github.com/mfridman/interpolate v0.0.2 // indirect
96 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -99,23 +101,18 @@ require (
99 github.com/muesli/mango-pflag v0.1.0 // indirect
101 github.com/muesli/mango-pflag v0.1.0 // indirect
100 github.com/muesli/roff v0.1.0 // indirect
102 github.com/muesli/roff v0.1.0 // indirect
101 github.com/ncruces/julianday v1.0.0 // indirect
103 github.com/ncruces/julianday v1.0.0 // indirect
102− github.com/pelletier/go-toml/v2 v2.2.3 // indirect
103 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
104 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
104 github.com/pmezard/go-difflib v1.0.0 // indirect
105 github.com/pmezard/go-difflib v1.0.0 // indirect
105 github.com/rivo/uniseg v0.4.7
106 github.com/rivo/uniseg v0.4.7
106− github.com/sagikazarmark/locafero v0.7.0 // indirect
107 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
107 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
108 github.com/sethvargo/go-retry v0.3.0 // indirect
108 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
112 github.com/spf13/pflag v1.0.6 // indirect
109 github.com/spf13/pflag v1.0.6 // indirect
113− github.com/subosito/gotenv v1.6.0 // indirect
114 github.com/tetratelabs/wazero v1.9.0 // indirect
110 github.com/tetratelabs/wazero v1.9.0 // indirect
115 github.com/tidwall/gjson v1.18.0 // indirect
111 github.com/tidwall/gjson v1.18.0 // indirect
116 github.com/tidwall/match v1.1.1 // indirect
112 github.com/tidwall/match v1.1.1 // indirect
117 github.com/tidwall/pretty v1.2.1 // indirect
113 github.com/tidwall/pretty v1.2.1 // indirect
118 github.com/tidwall/sjson v1.2.5 // indirect
114 github.com/tidwall/sjson v1.2.5 // indirect
115+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
119 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
116 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
120 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
117 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
121 github.com/yuin/goldmark v1.7.8 // indirect
118 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
64github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
64github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
65github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
65github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
66github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
66github.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=
67github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
69github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
70github.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=
69github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
73github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
70github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
74github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
71github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
75github.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
84github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
88github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
85github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
89github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
86github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
90github.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=
89github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c h1:2GELBLPgfSbHU53bsQhR9XIgNuVZ6w+Rz8RWV5Lq+A4=
91github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c h1:2GELBLPgfSbHU53bsQhR9XIgNuVZ6w+Rz8RWV5Lq+A4=
90github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
92github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
91github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
93github.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
114github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
116github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
115github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
117github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
116github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
118github.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=
119github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
119github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
120github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
120github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
121github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
121github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -127,8 +127,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
127github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
127github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
128github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
128github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
129github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
129github.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=
132github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
130github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
133github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
131github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
134github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
132github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -151,6 +149,9 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
151github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
149github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
152github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
150github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
153github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
151github.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=
154github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
155github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
155github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
156github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
156github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
157github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -162,6 +163,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
162github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
163github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
163github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
164github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
164github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
165github.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=
165github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
168github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
166github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
169github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
167github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
170github.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
194github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
197github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
195github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
198github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
196github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
199github.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=
199github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
200github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
200github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
201github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
201github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
202github.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
213github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
214github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
214github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
215github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
215github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
216github.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=
218github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
217github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
219github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
218github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
220github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
219github.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
225github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
224github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
226github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
225github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
227github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
226github.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=
234github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
227github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
235github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
228github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
236github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
229github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
237github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
230github.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=
240github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
231github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
241github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
232github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
242github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
233github.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
247github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
238github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
248github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
239github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
249github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
240github.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=
252github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
241github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
253github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
242github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
254github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
243github.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=
261github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
250github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
262github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
251github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
263github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
252github.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=
264github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
255github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
265github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
256github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
266github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
257github.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) {
55 // Initialize LSP clients in the background
55 // Initialize LSP clients in the background
56 go app.initLSPClients(ctx)
56 go app.initLSPClients(ctx)
57
57
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+
58 var err error
65 var err error
59 app.CoderAgent, err = agent.NewAgent(
66 app.CoderAgent, err = agent.NewAgent(
60− config.AgentCoder,
67+ coderAgentCfg,
68+ app.Permissions,
61 app.Sessions,
69 app.Sessions,
62 app.Messages,
70 app.Messages,
63− agent.CoderAgentTools(
64− app.Permissions,
65− app.Sessions,
66− app.Messages,
67− app.History,
71+ app.History,
68− app.LSPClients,
72+ app.LSPClients,
69− ),
70 )
73 )
71 if err != nil {
74 if err != nil {
72 logging.Error("Failed to create coder agent", err)
75 logging.Error("Failed to create coder agent", err)
@@ -165,3 +168,7 @@ func (app *App) Shutdown() {
165 }
168 }
166 app.CoderAgent.CancelAll()
169 app.CoderAgent.CancelAll()
167}
170}
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.
2package config
1package config
3
2
4import (
3import (
5 "encoding/json"
4 "encoding/json"
5+ "errors"
6 "fmt"
6 "fmt"
7 "log/slog"
7 "log/slog"
8+ "maps"
8 "os"
9 "os"
9 "path/filepath"
10 "path/filepath"
11+ "slices"
10 "strings"
12 "strings"
13+ "sync"
11
14
12− "github.com/charmbracelet/crush/internal/llm/models"
15+ "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/logging"
16 "github.com/charmbracelet/crush/internal/logging"
14− "github.com/spf13/afero"
17+ "github.com/invopop/jsonschema"
15− "github.com/spf13/viper"
16)
18)
17
19
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
93const (
20const (
94 defaultDataDirectory = ".crush"
21 defaultDataDirectory = ".crush"
95 defaultLogLevel = "info"
22 defaultLogLevel = "info"
@@ -114,53 +41,181 @@ var defaultContextPaths = []string{
114 "CRUSH.local.md",
41 "CRUSH.local.md",
115}
42}
116
43
117−// Global configuration instance
44+type AgentID string
118−var cfg *Config
119
45
120−// Load initializes the configuration from environment variables and config files.
46+const (
121−// If debug is true, debug mode is enabled and log level is set to debug.
47+ AgentCoder AgentID = "coder"
122−// It returns an error if configuration loading fails.
48+ AgentTask AgentID = "task"
123−func Load(workingDir string, debug bool) (*Config, error) {
49+)
124− if cfg != nil {
125− return cfg, nil
126− }
127
50
128− cfg = &Config{
51+type ModelType string
129− WorkingDir: workingDir,
130− MCPServers: make(map[string]MCPServer),
131− Providers: make(map[models.ModelProvider]Provider),
132− LSP: make(map[string]LSPConfig),
133− }
134
52
135− configureViper()
53+const (
136− setDefaults(debug)
54+ LargeModel ModelType = "large"
55+ SmallModel ModelType = "small"
56+)
137
57
138− // Read global config
58+type Model struct {
139− if err := readConfig(viper.ReadInConfig()); err != nil {
59+ ID string `json:"id" jsonschema:"title=Model ID,description=Unique identifier for the model, the API model"`
140− return cfg, err
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"`
141− }
71+}
142
72
143− // Load and merge local config
73+type VertexAIOptions struct {
144− mergeLocalConfig(workingDir)
74+ APIKey string `json:"api_key,omitempty"`
75+ Project string `json:"project,omitempty"`
76+ Location string `json:"location,omitempty"`
77+}
145
78
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"`
147
88
148− // Apply configuration to the struct
89+ DefaultLargeModel string `json:"default_large_model,omitempty" jsonschema:"title=Default Large Model,description=Default model ID for large model type"`
149− if err := viper.Unmarshal(cfg); err != nil {
90+ DefaultSmallModel string `json:"default_small_model,omitempty" jsonschema:"title=Default Small Model,description=Default model ID for small model type"`
150− return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
91+
151− }
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
152
200
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
154 defaultLevel := slog.LevelInfo
209 defaultLevel := slog.LevelInfo
155− if cfg.Debug {
210+ if cfg.Options.Debug {
156 defaultLevel = slog.LevelDebug
211 defaultLevel = slog.LevelDebug
157 }
212 }
158 if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
213 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")
160
215
161 // if file does not exist create it
216 // if file does not exist create it
162 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
217 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 {
164 return cfg, fmt.Errorf("failed to create directory: %w", err)
219 return cfg, fmt.Errorf("failed to create directory: %w", err)
165 }
220 }
166 if _, err := os.Create(loggingFile); err != nil {
221 if _, err := os.Create(loggingFile); err != nil {
@@ -168,6 +223,15 @@ func Load(workingDir string, debug bool) (*Config, error) {
168 }
223 }
169 }
224 }
170
225
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+
171 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
235 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
172 if err != nil {
236 if err != nil {
173 return cfg, fmt.Errorf("failed to open log file: %w", err)
237 return cfg, fmt.Errorf("failed to open log file: %w", err)
@@ -184,237 +248,579 @@ func Load(workingDir string, debug bool) (*Config, error) {
184 }))
248 }))
185 slog.SetDefault(logger)
249 slog.SetDefault(logger)
186 }
250 }
187−
251+ var globalCfg *Config
188− // Validate configuration
252+ if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
189− if err := Validate(); err != nil {
253+ // some other error occurred while checking the file
190− return cfg, fmt.Errorf("config validation failed: %w", err)
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+ }
191 }
286 }
192
287
193− if cfg.Agents == nil {
288+ // merge options
194− cfg.Agents = make(map[AgentName]Agent)
289+ mergeOptions(cfg, globalCfg, localConfig)
195− }
196
290
197− // Override the max tokens for title agent
291+ mergeProviderConfigs(cfg, globalCfg, localConfig)
198− cfg.Agents[AgentTitle] = Agent{
292+ // no providers found the app is not initialized yet
199− Model: cfg.Agents[AgentTitle].Model,
293+ if len(cfg.Providers) == 0 {
200− MaxTokens: 80,
294+ return cfg, nil
201 }
295 }
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+
202 return cfg, nil
352 return cfg, nil
203}
353}
204
354
205−type configFinder struct {
355+func Init(workingDir string, debug bool) (*Config, error) {
206− appName string
356+ var err error
207− dotPrefix bool
357+ once.Do(func() {
208− paths []string
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
209}
374}
210
375
211−func (f configFinder) Find(fsys afero.Fs) ([]string, error) {
376+func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
212− var configFiles []string
377+ providers := Providers()
213− configName := fmt.Sprintf("%s.json", f.appName)
378+ for _, p := range providers {
214− if f.dotPrefix {
379+ if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
215− configName = fmt.Sprintf(".%s.json", f.appName)
380+ return &providerConfig
381+ }
216 }
382 }
217− paths := []string{}
383+ // if none found return the first configured provider
218− for _, p := range f.paths {
384+ for _, providerConfig := range configuredProviders {
219− if p == "" {
385+ if !providerConfig.Disabled {
220− continue
386+ return &providerConfig
221 }
387 }
222− paths = append(paths, os.ExpandEnv(p))
223 }
388 }
389+ return nil
390+}
224
391
225− for _, path := range paths {
392+func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
226− if path == "" {
393+ if other.APIKey != "" {
227− continue
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
228 }
400 }
229−
401+ if other.ProviderType != "" {
230− configPath := filepath.Join(path, configName)
402+ base.ProviderType = other.ProviderType
231− if exists, err := afero.Exists(fsys, configPath); err == nil && exists {
403+ }
232− configFiles = append(configFiles, configPath)
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)
233 }
415 }
234 }
416 }
235− return configFiles, nil
236−}
237−
238−// configureViper sets up viper's configuration paths and environment variables.
239−func configureViper() {
240− viper.SetConfigType("json")
241
417
242− // Create the three finders
418+ if other.Disabled {
243− windowsFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
419+ base.Disabled = other.Disabled
244− "$USERPROFILE",
245− fmt.Sprintf("$APPDATA/%s", appName),
246− fmt.Sprintf("$LOCALAPPDATA/%s", appName),
247− }}
420+ }
248
421
249− unixFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
422+ if other.DefaultLargeModel != "" {
250− "$HOME",
423+ base.DefaultLargeModel = other.DefaultLargeModel
251− fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName),
424+ }
252− fmt.Sprintf("$HOME/.config/%s", appName),
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+ }
253− }}
439+ }
440+ }
254
441
255− localFinder := configFinder{appName: appName, dotPrefix: true, paths: []string{
442+ return base
256− ".",
257− }}
443+}
258
444
259− // Use all finders with viper
445+func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
260− viper.SetOptions(viper.WithFinder(viper.Finders(windowsFinder, unixFinder, localFinder)))
446+ if !slices.Contains(provider.KnownProviders(), p) {
261− viper.SetEnvPrefix(strings.ToUpper(appName))
447+ if providerConfig.ProviderType != provider.TypeOpenAI {
262− viper.AutomaticEnv()
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
263}
458}
264
459
265−// setDefaults configures default values for configuration options.
460+func mergeModels(base, global, local *Config) {
266−func setDefaults(debug bool) {
461+ for _, cfg := range []*Config{global, local} {
267− viper.SetDefault("data.directory", defaultDataDirectory)
462+ if cfg == nil {
268− viper.SetDefault("contextPaths", defaultContextPaths)
463+ continue
269− viper.SetDefault("tui.theme", "crush")
464+ }
270− viper.SetDefault("autoCompact", true)
465+ if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
466+ base.Models.Large = cfg.Models.Large
467+ }
271
468
272− if debug {
469+ if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
273− viper.SetDefault("debug", true)
470+ base.Models.Small = cfg.Models.Small
274− viper.Set("log.level", "debug")
471+ }
275− } else {
276− viper.SetDefault("debug", false)
277− viper.SetDefault("log.level", defaultLogLevel)
278 }
472 }
279}
473}
280
474
281−// setProviderDefaults configures LLM provider defaults based on provider provided by
475+func mergeOptions(base, global, local *Config) {
282−// environment variables and configuration file.
476+ for _, cfg := range []*Config{global, local} {
283−func setProviderDefaults() {
477+ if cfg == nil {
284− // Set all API keys we can find in the environment
478+ continue
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− }
479+ }
294− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
480+ baseOptions := base.Options
295− viper.SetDefault("providers.groq.apiKey", apiKey)
481+ other := cfg.Options
296− }
482+ if len(other.ContextPaths) > 0 {
297− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
483+ baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
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− }
484+ }
307
485
308− // Use this order to set the default models
486+ if other.TUI.CompactMode {
309− // 1. Anthropic
487+ baseOptions.TUI.CompactMode = other.TUI.CompactMode
310− // 2. OpenAI
488+ }
311− // 3. Google Gemini
489+
312− // 4. Groq
490+ if other.Debug {
313− // 5. OpenRouter
491+ baseOptions.Debug = other.Debug
314− // 6. AWS Bedrock
492+ }
315− // 7. Azure
493+
316− // 8. Google Cloud VertexAI
494+ if other.DebugLSP {
495+ baseOptions.DebugLSP = other.DebugLSP
496+ }
497+
498+ if other.DisableAutoSummarize {
499+ baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
500+ }
317
501
318− // Anthropic configuration
502+ if other.DataDirectory != "" {
319− if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
503+ baseOptions.DataDirectory = other.DataDirectory
320− viper.SetDefault("agents.coder.model", models.Claude4Sonnet)
504+ }
321− viper.SetDefault("agents.summarizer.model", models.Claude4Sonnet)
505+ base.Options = baseOptions
322− viper.SetDefault("agents.task.model", models.Claude4Sonnet)
323− viper.SetDefault("agents.title.model", models.Claude4Sonnet)
324− return
325 }
506 }
507+}
326
508
327− // OpenAI configuration
509+func mergeAgents(base, global, local *Config) {
328− if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
510+ for _, cfg := range []*Config{global, local} {
329− viper.SetDefault("agents.coder.model", models.GPT41)
511+ if cfg == nil {
330− viper.SetDefault("agents.summarizer.model", models.GPT41)
512+ continue
331− viper.SetDefault("agents.task.model", models.GPT41Mini)
513+ }
332− viper.SetDefault("agents.title.model", models.GPT41Mini)
514+ for agentID, newAgent := range cfg.Agents {
333− return
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+ }
334 }
575 }
576+}
335
577
336− // Google Gemini configuration
578+func mergeMCPs(base, global, local *Config) {
337− if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
579+ for _, cfg := range []*Config{global, local} {
338− viper.SetDefault("agents.coder.model", models.Gemini25)
580+ if cfg == nil {
339− viper.SetDefault("agents.summarizer.model", models.Gemini25)
581+ continue
340− viper.SetDefault("agents.task.model", models.Gemini25Flash)
582+ }
341− viper.SetDefault("agents.title.model", models.Gemini25Flash)
583+ maps.Copy(base.MCP, cfg.MCP)
342− return
343 }
584 }
585+}
344
586
345− // Groq configuration
587+func mergeLSPs(base, global, local *Config) {
346− if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
588+ for _, cfg := range []*Config{global, local} {
347− viper.SetDefault("agents.coder.model", models.QWENQwq)
589+ if cfg == nil {
348− viper.SetDefault("agents.summarizer.model", models.QWENQwq)
590+ continue
349− viper.SetDefault("agents.task.model", models.QWENQwq)
591+ }
350− viper.SetDefault("agents.title.model", models.QWENQwq)
592+ maps.Copy(base.LSP, cfg.LSP)
351− return
352 }
593 }
594+}
353
595
354− // OpenRouter configuration
596+func mergeProviderConfigs(base, global, local *Config) {
355− if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
597+ for _, cfg := range []*Config{global, local} {
356− viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
598+ if cfg == nil {
357− viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
599+ continue
358− viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
600+ }
359− viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
601+ for providerName, p := range cfg.Providers {
360− return
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+ }
361 }
609 }
362
610
363− // XAI configuration
611+ finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
364− if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
612+ for providerName, providerConfig := range base.Providers {
365− viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
613+ err := validateProvider(providerName, providerConfig)
366− viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
614+ if err != nil {
367− viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
615+ logging.Warn("Skipping provider", "name", providerName, "error", err)
368− viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
616+ continue // Skip invalid providers
369− return
617+ }
618+ finalProviders[providerName] = providerConfig
370 }
619 }
620+ base.Providers = finalProviders
621+}
371
622
372− // AWS Bedrock configuration
623+func providerDefaultConfig(providerID provider.InferenceProvider) ProviderConfig {
373− if hasAWSCredentials() {
624+ switch providerID {
374− viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
625+ case provider.InferenceProviderAnthropic:
375− viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
626+ return ProviderConfig{
376− viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
627+ ID: providerID,
377− viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
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:
378− return
672+ return ProviderConfig{
673+ ID: providerID,
674+ ProviderType: provider.TypeOpenAI,
675+ }
379 }
676 }
677+}
380
678
381− // Azure OpenAI configuration
679+func defaultConfigBasedOnEnv() *Config {
382− if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
680+ cfg := &Config{
383− viper.SetDefault("agents.coder.model", models.AzureGPT41)
681+ Options: Options{
384− viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
682+ DataDirectory: defaultDataDirectory,
385− viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
683+ ContextPaths: defaultContextPaths,
386− viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
684+ },
387− return
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
388 }
768 }
389
769
390− // Google Cloud VertexAI configuration
770+ if hasAWSCredentials() {
391− if hasVertexAICredentials() {
771+ providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
392− viper.SetDefault("agents.coder.model", models.VertexAIGemini25)
772+ providerConfig.ExtraParams = map[string]string{
393− viper.SetDefault("agents.summarizer.model", models.VertexAIGemini25)
773+ "region": os.Getenv("AWS_DEFAULT_REGION"),
394− viper.SetDefault("agents.task.model", models.VertexAIGemini25Flash)
774+ }
395− viper.SetDefault("agents.title.model", models.VertexAIGemini25Flash)
775+ if providerConfig.ExtraParams["region"] == "" {
396− return
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
397 }
807 }
808+ return cfg
398}
809}
399
810
400−// hasAWSCredentials checks if AWS credentials are available in the environment.
401func hasAWSCredentials() bool {
811func hasAWSCredentials() bool {
402− // Check for explicit AWS credentials
403 if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
812 if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
404 return true
813 return true
405 }
814 }
406
815
407− // Check for AWS profile
408 if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
816 if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
409 return true
817 return true
410 }
818 }
411
819
412− // Check for AWS region
413 if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
820 if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
414 return true
821 return true
415 }
822 }
416
823
417− // Check if running on EC2 with instance profile
418 if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
824 if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
419 os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
825 os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
420 return true
826 return true
@@ -423,495 +829,673 @@ func hasAWSCredentials() bool {
423 return false
829 return false
424}
830}
425
831
426−// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
832+func WorkingDirectory() string {
427−func hasVertexAICredentials() bool {
833+ return cwd
428− // Check for explicit VertexAI parameters
834+}
429− if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
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)
430− return true
843+ return Model{}
431 }
844 }
432− // Check for Google Cloud project and location
845+
433− if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
846+ var model PreferredModel
434− return true
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
435 }
855 }
856+ providerConfig, ok := cfg.Providers[model.Provider]
857+ if !ok {
858+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
436− return false
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{}
437}
870}
438
871
439−// readConfig handles the result of reading a configuration file.
872+// GetAgentEffectiveMaxTokens returns the effective max tokens for an agent,
440−func readConfig(err error) error {
873+// considering any overrides from the preferred model configuration
441− if err == nil {
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)
442− return nil
879+ return 0
443 }
880 }
444
881
445− // It's okay if the config file doesn't exist
882+ var preferredModel PreferredModel
446− if _, ok := err.(viper.ConfigFileNotFoundError); ok {
883+ switch agent.Model {
447− return nil
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
448 }
891 }
449
892
450− return fmt.Errorf("failed to read config: %w", err)
893+ // Get the base model configuration
894+ baseModel := GetAgentModel(agentID)
895+ if baseModel.ID == "" {
896+ return 0
451−}
897+ }
452
898
453−// mergeLocalConfig loads and merges configuration from the local directory.
899+ // Start with the default max tokens from the base model
454−func mergeLocalConfig(workingDir string) {
900+ maxTokens := baseModel.DefaultMaxTokens
455− local := viper.New()
456− local.SetConfigName(fmt.Sprintf(".%s", appName))
457− local.SetConfigType("json")
458− local.AddConfigPath(workingDir)
459
901
460− // Merge local config if it exists
902+ // Override with preferred model max tokens if set
461− if err := local.ReadInConfig(); err == nil {
903+ if preferredModel.MaxTokens > 0 {
462− viper.MergeConfigMap(local.AllSettings())
904+ maxTokens = preferredModel.MaxTokens
463 }
905 }
906+
907+ return maxTokens
464}
908}
465
909
466−// applyDefaultValues sets default values for configuration fields that need processing.
910+func GetAgentProvider(agentID AgentID) ProviderConfig {
467−func applyDefaultValues() {
911+ cfg := Get()
468− // Set default MCP type if not specified
912+ agent, ok := cfg.Agents[agentID]
469− for k, v := range cfg.MCPServers {
913+ if !ok {
470− if v.Type == "" {
914+ logging.Error("Agent not found", "agent_id", agentID)
471− v.Type = MCPStdio
915+ return ProviderConfig{}
472− cfg.MCPServers[k] = v
473− }
474 }
916 }
475−}
476
917
477−// It validates model IDs and providers, ensuring they are supported.
918+ var model PreferredModel
478−func validateAgent(cfg *Config, name AgentName, agent Agent) error {
919+ switch agent.Model {
479− // Check if model exists
920+ case LargeModel:
480− model, modelExists := models.SupportedModels[agent.Model]
921+ model = cfg.Models.Large
481− if !modelExists {
922+ case SmallModel:
482− logging.Warn("unsupported model configured, reverting to default",
923+ model = cfg.Models.Small
483− "agent", name,
924+ default:
484− "configured_model", agent.Model)
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+ }
485
928
486− // Set default model based on available providers
929+ providerConfig, ok := cfg.Providers[model.Provider]
487− if setDefaultModelForAgent(name) {
930+ if !ok {
488− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
931+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
489− } else {
490− return fmt.Errorf("no valid provider available for agent %s", name)
491− }
492− return nil
932+ return ProviderConfig{}
493 }
933 }
494
934
495− // Check if provider for the model is configured
935+ return providerConfig
496− provider := model.Provider
936+}
497− providerCfg, providerExists := cfg.Providers[provider]
498
937
499− if !providerExists {
938+func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
500− // Provider not configured, check if we have environment variables
939+ cfg := Get()
501− apiKey := getProviderAPIKey(provider)
940+ providerConfig, ok := cfg.Providers[provider]
502− if apiKey == "" {
941+ if !ok {
503− logging.Warn("provider not configured for model, reverting to default",
942+ logging.Error("Provider not found", "provider", provider)
504− "agent", name,
943+ return Model{}
505− "model", agent.Model,
944+ }
506− "provider", provider)
507
945
508− // Set default model based on available providers
946+ for _, model := range providerConfig.Models {
509− if setDefaultModelForAgent(name) {
947+ if model.ID == modelID {
510− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
948+ return 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)
533 }
949 }
534 }
950 }
535
951
536− // Validate max tokens
952+ logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
537− if agent.MaxTokens <= 0 {
953+ return Model{}
538− logging.Warn("invalid max tokens, setting to default",
954+}
539− "agent", name,
540− "model", agent.Model,
541− "max_tokens", agent.MaxTokens)
542
955
543− // Update the agent with default max tokens
956+func GetModel(modelType ModelType) Model {
544− updatedAgent := cfg.Agents[name]
957+ cfg := Get()
545− if model.DefaultMaxTokens > 0 {
958+ var model PreferredModel
546− updatedAgent.MaxTokens = model.DefaultMaxTokens
959+ switch modelType {
547− } else {
960+ case LargeModel:
548− updatedAgent.MaxTokens = MaxTokensFallbackDefault
961+ model = cfg.Models.Large
549− }
962+ case SmallModel:
550− cfg.Agents[name] = updatedAgent
963+ model = cfg.Models.Small
551− } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
964+ default:
552− // Ensure max tokens doesn't exceed half the context window (reasonable limit)
965+ model = cfg.Models.Large // Fallback to large model
553− logging.Warn("max tokens exceeds half the context window, adjusting",
966+ }
554− "agent", name,
967+ providerConfig, ok := cfg.Providers[model.Provider]
555− "model", agent.Model,
968+ if !ok {
556− "max_tokens", agent.MaxTokens,
969+ return Model{}
557− "context_window", model.ContextWindow)
970+ }
558−
559− // Update the agent with adjusted max tokens
560− updatedAgent := cfg.Agents[name]
561− updatedAgent.MaxTokens = model.ContextWindow / 2
562− cfg.Agents[name] = updatedAgent
563− }
564−
565− // Validate reasoning effort for models that support reasoning
566− if model.CanReason && provider == models.ProviderOpenAI || provider == models.ProviderLocal {
567− if agent.ReasoningEffort == "" {
568− // Set default reasoning effort for models that support it
569− logging.Info("setting default reasoning effort for model that supports reasoning",
570− "agent", name,
571− "model", agent.Model)
572−
573− // Update the agent with default reasoning effort
574− updatedAgent := cfg.Agents[name]
575− updatedAgent.ReasoningEffort = "medium"
576− cfg.Agents[name] = updatedAgent
577− } else {
578− // Check if reasoning effort is valid (low, medium, high)
579− effort := strings.ToLower(agent.ReasoningEffort)
580− if effort != "low" && effort != "medium" && effort != "high" {
581− logging.Warn("invalid reasoning effort, setting to medium",
582− "agent", name,
583− "model", agent.Model,
584− "reasoning_effort", agent.ReasoningEffort)
585
971
586− // Update the agent with valid reasoning effort
972+ for _, m := range providerConfig.Models {
587− updatedAgent := cfg.Agents[name]
973+ if m.ID == model.ModelID {
588− updatedAgent.ReasoningEffort = "medium"
974+ return m
589− cfg.Agents[name] = updatedAgent
590− }
591 }
975 }
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
603 }
976 }
977+ return Model{}
978+}
604
979
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+ }
605 return nil
990 return nil
606}
991}
607
992
608−// Validate checks if the configuration is valid and applies defaults where needed.
993+// ValidationError represents a configuration validation error
609−func Validate() error {
994+type ValidationError struct {
610− if cfg == nil {
995+ Field string
611− return fmt.Errorf("config not loaded")
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()
612 }
1012 }
613
1013
614− // Validate agent models
1014+ var messages []string
615− for name, agent := range cfg.Agents {
1015+ for _, err := range e {
616− if err := validateAgent(cfg, name, agent); err != nil {
1016+ messages = append(messages, err.Error())
617− return err
618− }
619 }
1017 }
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
620
1034
621 // Validate providers
1035 // Validate providers
622− for provider, providerCfg := range cfg.Providers {
1036+ c.validateProviders(&errors)
623− if providerCfg.APIKey == "" && !providerCfg.Disabled {
1037+
624− logging.Warn("provider has no API key, marking as disabled", "provider", provider)
1038+ // Validate models
625− providerCfg.Disabled = true
1039+ c.validateModels(&errors)
626− cfg.Providers[provider] = providerCfg
1040+
627− }
1041+ // Validate agents
628− }
1042+ c.validateAgents(&errors)
1043+
1044+ // Validate options
1045+ c.validateOptions(&errors)
1046+
1047+ // Validate MCP configurations
1048+ c.validateMCPs(&errors)
629
1049
630 // Validate LSP configurations
1050 // Validate LSP configurations
631− for language, lspConfig := range cfg.LSP {
1051+ c.validateLSPs(&errors)
632− if lspConfig.Command == "" && !lspConfig.Disabled {
1052+
633− logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
1053+ // Validate cross-references
634− lspConfig.Disabled = true
1054+ c.validateCrossReferences(&errors)
635− cfg.LSP[language] = lspConfig
1055+
636− }
1056+ // Validate completeness
1057+ c.validateCompleteness(&errors)
1058+
1059+ if errors.HasErrors() {
1060+ return errors
637 }
1061 }
638
1062
639 return nil
1063 return nil
640}
1064}
641
1065
642−// getProviderAPIKey gets the API key for a provider from environment variables
1066+// validateProviders validates all provider configurations
643−func getProviderAPIKey(provider models.ModelProvider) string {
1067+func (c *Config) validateProviders(errors *ValidationErrors) {
644− switch provider {
1068+ if c.Providers == nil {
645− case models.ProviderAnthropic:
1069+ c.Providers = make(map[provider.InferenceProvider]ProviderConfig)
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
682 }
1070 }
683
1071
684− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
1072+ knownProviders := provider.KnownProviders()
685− var model models.ModelID
1073+ validTypes := []provider.Type{
686− maxTokens := int64(5000)
1074+ provider.TypeOpenAI,
687− reasoningEffort := ""
1075+ provider.TypeAnthropic,
1076+ provider.TypeGemini,
1077+ provider.TypeAzure,
1078+ provider.TypeBedrock,
1079+ provider.TypeVertexAI,
1080+ provider.TypeXAI,
1081+ }
688
1082
689− switch agent {
1083+ for providerID, providerConfig := range c.Providers {
690− case AgentTitle:
1084+ fieldPrefix := fmt.Sprintf("providers.%s", providerID)
691− model = models.GPT41Mini
692− maxTokens = 80
693− case AgentTask:
694− model = models.GPT41Mini
695− default:
696− model = models.GPT41
697− }
698
1085
699− // Check if model supports reasoning
1086+ // Validate API key for non-disabled providers
700− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
1087+ if !providerConfig.Disabled && providerConfig.APIKey == "" {
701− reasoningEffort = "medium"
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+ }
702 }
1092 }
703
1093
704− cfg.Agents[agent] = Agent{
1094+ // Validate provider type
705− Model: model,
1095+ validType := slices.Contains(validTypes, providerConfig.ProviderType)
706− MaxTokens: maxTokens,
1096+ if !validType {
707− ReasoningEffort: reasoningEffort,
1097+ errors.Add(fieldPrefix+".provider_type", fmt.Sprintf("invalid provider type: %s", providerConfig.ProviderType))
708 }
1098 }
709− return true
710− }
711
1099
712− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
1100+ // Validate custom providers
713− var model models.ModelID
1101+ isKnownProvider := slices.Contains(knownProviders, providerID)
714− maxTokens := int64(5000)
715− reasoningEffort := ""
716
1102
717− switch agent {
1103+ if !isKnownProvider {
718− case AgentTitle:
1104+ // Custom provider validation
719− model = models.OpenRouterClaude35Haiku
1105+ if providerConfig.BaseURL == "" {
720− maxTokens = 80
1106+ errors.Add(fieldPrefix+".base_url", "BaseURL is required for custom providers")
721− case AgentTask:
1107+ }
722− model = models.OpenRouterClaude37Sonnet
1108+ if providerConfig.ProviderType != provider.TypeOpenAI {
723− default:
1109+ errors.Add(fieldPrefix+".provider_type", "custom providers currently only support OpenAI type")
724− model = models.OpenRouterClaude37Sonnet
1110+ }
725 }
1111 }
726
1112
727− // Check if model supports reasoning
1113+ // Validate models
728− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
1114+ modelIDs := make(map[string]bool)
729− reasoningEffort = "medium"
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+ }
730 }
1154 }
731
1155
732− cfg.Agents[agent] = Agent{
1156+ // Validate default model references
733− Model: model,
1157+ if providerConfig.DefaultLargeModel != "" {
734− MaxTokens: maxTokens,
1158+ if !modelIDs[providerConfig.DefaultLargeModel] {
735− ReasoningEffort: reasoningEffort,
1159+ errors.Add(fieldPrefix+".default_large_model", fmt.Sprintf("default large model '%s' not found in provider models", providerConfig.DefaultLargeModel))
1160+ }
736 }
1161 }
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)
738 }
1170 }
1171+}
739
1172
740− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
1173+// validateProviderSpecific validates provider-specific requirements
741− var model models.ModelID
1174+func (c *Config) validateProviderSpecific(providerID provider.InferenceProvider, providerConfig ProviderConfig, errors *ValidationErrors) {
742− maxTokens := int64(5000)
1175+ fieldPrefix := fmt.Sprintf("providers.%s", providerID)
743
1176
744− if agent == AgentTitle {
1177+ switch providerID {
745− model = models.Gemini25Flash
1178+ case provider.InferenceProviderVertexAI:
746− maxTokens = 80
1179+ if !providerConfig.Disabled {
747− } else {
1180+ if providerConfig.ExtraParams == nil {
748− model = models.Gemini25
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+ }
749 }
1190 }
750−
1191+ case provider.InferenceProviderBedrock:
751− cfg.Agents[agent] = Agent{
1192+ if !providerConfig.Disabled {
752− Model: model,
1193+ if providerConfig.ExtraParams == nil || providerConfig.ExtraParams["region"] == "" {
753− MaxTokens: maxTokens,
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+ }
754 }
1200 }
755− return true
756 }
1201 }
1202+}
757
1203
758− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
1204+// validateModels validates preferred model configurations
759− maxTokens := int64(5000)
1205+func (c *Config) validateModels(errors *ValidationErrors) {
760− if agent == AgentTitle {
1206+ // Validate large model
761− maxTokens = 80
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")
762 }
1213 }
763
1214
764− cfg.Agents[agent] = Agent{
1215+ // Check if provider exists and is not disabled
765− Model: models.QWENQwq,
1216+ if providerConfig, exists := c.Providers[c.Models.Large.Provider]; exists {
766− MaxTokens: maxTokens,
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))
767 }
1234 }
768− return true
769 }
1235 }
770
1236
771− if hasAWSCredentials() {
1237+ // Validate small model
772− maxTokens := int64(5000)
1238+ if c.Models.Small.ModelID != "" || c.Models.Small.Provider != "" {
773− if agent == AgentTitle {
1239+ if c.Models.Small.ModelID == "" {
774− maxTokens = 80
1240+ errors.Add("models.small.model_id", "small model ID is required when provider is set")
775 }
1241 }
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+ }
776
1251
777− cfg.Agents[agent] = Agent{
1252+ // Check if model exists in provider
778− Model: models.BedrockClaude37Sonnet,
1253+ modelExists := false
779− MaxTokens: maxTokens,
1254+ for _, model := range providerConfig.Models {
780− ReasoningEffort: "medium", // Claude models support reasoning
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))
781 }
1265 }
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)
783 }
1273 }
784
1274
785− if hasVertexAICredentials() {
1275+ validTools := []string{
786− var model models.ModelID
1276+ "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
787− maxTokens := int64(5000)
1277+ }
788
1278
789− if agent == AgentTitle {
1279+ for agentID, agent := range c.Agents {
790− model = models.VertexAIGemini25Flash
1280+ fieldPrefix := fmt.Sprintf("agents.%s", agentID)
791− maxTokens = 80
1281+
792− } else {
1282+ // Validate agent ID consistency
793− model = models.VertexAIGemini25
1283+ if agent.ID != agentID {
1284+ errors.Add(fieldPrefix+".id", fmt.Sprintf("agent ID mismatch: expected '%s', got '%s'", agentID, agent.ID))
794 }
1285 }
795
1286
796− cfg.Agents[agent] = Agent{
1287+ // Validate required fields
797− Model: model,
1288+ if agent.ID == "" {
798− MaxTokens: maxTokens,
1289+ errors.Add(fieldPrefix+".id", "agent ID is required")
1290+ }
1291+ if agent.Name == "" {
1292+ errors.Add(fieldPrefix+".name", "agent name is required")
799 }
1293 }
800− return true
801− }
802
1294
803− return false
1295+ // Validate model type
804−}
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+ }
805
1299
806−func updateCfgFile(updateCfg func(config *Config)) error {
1300+ // Validate allowed tools
807− if cfg == nil {
1301+ if agent.AllowedTools != nil {
808− return fmt.Errorf("config not loaded")
1302+ for i, tool := range agent.AllowedTools {
809− }
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+ }
810
1309
811− // Get the config file path
1310+ // Validate MCP references
812− configFile := viper.ConfigFileUsed()
1311+ if agent.AllowedMCP != nil {
813− var configData []byte
1312+ for mcpName := range agent.AllowedMCP {
814− if configFile == "" {
1313+ if _, exists := c.MCP[mcpName]; !exists {
815− homeDir, err := os.UserHomeDir()
1314+ errors.Add(fieldPrefix+".allowed_mcp", fmt.Sprintf("referenced MCP '%s' not found", mcpName))
816− if err != nil {
1315+ }
817− return fmt.Errorf("failed to get home directory: %w", err)
1316+ }
818 }
1317 }
819− configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
1318+
820− logging.Info("config file not found, creating new one", "path", configFile)
1319+ // Validate LSP references
821− configData = []byte(`{}`)
1320+ if agent.AllowedLSP != nil {
822− } else {
1321+ for _, lspName := range agent.AllowedLSP {
823− // Read the existing config file
1322+ if _, exists := c.LSP[lspName]; !exists {
824− data, err := os.ReadFile(configFile)
1323+ errors.Add(fieldPrefix+".allowed_lsp", fmt.Sprintf("referenced LSP '%s' not found", lspName))
825− if err != nil {
1324+ }
826− return fmt.Errorf("failed to read config file: %w", err)
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+ }
827 }
1337 }
828− configData = data
829− }
830
1338
831− // Parse the JSON
1339+ // Validate known agents maintain their core properties
832− var userCfg *Config
1340+ if agentID == AgentCoder {
833− if err := json.Unmarshal(configData, &userCfg); err != nil {
1341+ if agent.Name != "Coder" {
834− return fmt.Errorf("failed to parse config file: %w", err)
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+ }
835 }
1359 }
1360+}
836
1361
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+ }
838
1368
839− // Write the updated config back to file
1369+ // Validate context paths
840− updatedData, err := json.MarshalIndent(userCfg, "", " ")
1370+ for i, contextPath := range c.Options.ContextPaths {
841− if err != nil {
1371+ if contextPath == "" {
842− return fmt.Errorf("failed to marshal config: %w", err)
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+ }
843 }
1377 }
1378+}
844
1379
845− if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
1380+// validateMCPs validates MCP configurations
846− return fmt.Errorf("failed to write config file: %w", err)
1381+func (c *Config) validateMCPs(errors *ValidationErrors) {
1382+ if c.MCP == nil {
1383+ c.MCP = make(map[string]MCP)
847 }
1384 }
848
1385
849− return nil
1386+ for mcpName, mcpConfig := range c.MCP {
850−}
1387+ fieldPrefix := fmt.Sprintf("mcp.%s", mcpName)
851
1388
852−// Get returns the current configuration.
1389+ // Validate MCP type
853−// It's safe to call this function multiple times.
1390+ if mcpConfig.Type != MCPStdio && mcpConfig.Type != MCPSse {
854−func Get() *Config {
1391+ errors.Add(fieldPrefix+".type", fmt.Sprintf("invalid MCP type: %s (must be 'stdio' or 'sse')", mcpConfig.Type))
855− return cfg
1392+ }
856−}
857
1393
858−// WorkingDirectory returns the current working directory from the configuration.
1394+ // Validate based on type
859−func WorkingDirectory() string {
1395+ if mcpConfig.Type == MCPStdio {
860− if cfg == nil {
1396+ if mcpConfig.Command == "" {
861− panic("config not loaded")
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+ }
862 }
1404 }
863− return cfg.WorkingDir
864}
1405}
865
1406
866−func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
1407+// validateLSPs validates LSP configurations
867− if cfg == nil {
1408+func (c *Config) validateLSPs(errors *ValidationErrors) {
868− panic("config not loaded")
1409+ if c.LSP == nil {
1410+ c.LSP = make(map[string]LSPConfig)
869 }
1411 }
870
1412
871− existingAgentCfg := cfg.Agents[agentName]
1413+ for lspName, lspConfig := range c.LSP {
1414+ fieldPrefix := fmt.Sprintf("lsp.%s", lspName)
872
1415
873− model, ok := models.SupportedModels[modelID]
1416+ if lspConfig.Command == "" {
874− if !ok {
1417+ errors.Add(fieldPrefix+".command", "command is required for LSP")
875− return fmt.Errorf("model %s not supported", modelID)
1418+ }
876 }
1419 }
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+ }
877
1435
878− maxTokens := existingAgentCfg.MaxTokens
1436+ if preferredModel.Provider != "" {
879− if model.DefaultMaxTokens > 0 {
1437+ if providerConfig, exists := c.Providers[preferredModel.Provider]; exists {
880− maxTokens = model.DefaultMaxTokens
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+ }
881 }
1443 }
1444+}
882
1445
883− newAgentCfg := Agent{
1446+// validateCompleteness validates that the configuration is complete and usable
884− Model: modelID,
1447+func (c *Config) validateCompleteness(errors *ValidationErrors) {
885− MaxTokens: maxTokens,
1448+ // Check for at least one valid, non-disabled provider
886− ReasoningEffort: existingAgentCfg.ReasoningEffort,
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")
887 }
1458 }
888− cfg.Agents[agentName] = newAgentCfg
889
1459
890− if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
1460+ // Check that default agents exist
891− // revert config update on failure
1461+ if _, exists := c.Agents[AgentCoder]; !exists {
892− cfg.Agents[agentName] = existingAgentCfg
1462+ errors.Add("agents", "coder agent is required")
893− return fmt.Errorf("failed to update agent model: %w", err)
1463+ }
1464+ if _, exists := c.Agents[AgentTask]; !exists {
1465+ errors.Add("agents", "task agent is required")
894 }
1466 }
895
1467
896− return updateCfgFile(func(config *Config) {
1468+ // Check that preferred models are set if providers exist
897− if config.Agents == nil {
1469+ if hasValidProvider {
898− config.Agents = make(map[AgentName]Agent)
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")
899 }
1472 }
900− config.Agents[agentName] = newAgentCfg
1473+ if c.Models.Small.ModelID == "" || c.Models.Small.Provider == "" {
901− })
1474+ errors.Add("models.small", "small preferred model must be configured when providers are available")
1475+ }
1476+ }
902}
1477}
903
1478
904−// UpdateTheme updates the theme in the configuration and writes it to the config file.
1479+// JSONSchemaExtend adds custom schema properties for AgentID
905−func UpdateTheme(themeName string) error {
1480+func (AgentID) JSONSchemaExtend(schema *jsonschema.Schema) {
906− if cfg == nil {
1481+ schema.Enum = []any{
907− return fmt.Errorf("config not loaded")
1482+ string(AgentCoder),
1483+ string(AgentTask),
908 }
1484 }
1485+}
909
1486
910− // Update the in-memory config
1487+// JSONSchemaExtend adds custom schema properties for ModelType
911− cfg.TUI.Theme = themeName
1488+func (ModelType) JSONSchemaExtend(schema *jsonschema.Schema) {
1489+ schema.Enum = []any{
1490+ string(LargeModel),
1491+ string(SmallModel),
1492+ }
1493+}
912
1494
913− // Update the file config
1495+// JSONSchemaExtend adds custom schema properties for MCPType
914− return updateCfgFile(func(config *Config) {
1496+func (MCPType) JSONSchemaExtend(schema *jsonschema.Schema) {
915− config.TUI.Theme = themeName
1497+ schema.Enum = []any{
916− })
1498+ string(MCPStdio),
1499+ string(MCPSse),
1500+ }
917}
1501}
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 {
17 Initialized bool `json:"initialized"`
17 Initialized bool `json:"initialized"`
18}
18}
19
19
20−// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory
20+// ProjectNeedsInitialization checks if the current project needs initialization
21−func ShouldShowInitDialog() (bool, error) {
21+func ProjectNeedsInitialization() (bool, error) {
22− if cfg == nil {
22+ if instance == nil {
23 return false, fmt.Errorf("config not loaded")
23 return false, fmt.Errorf("config not loaded")
24 }
24 }
25
25
26− // Create the flag file path
26+ flagFilePath := filepath.Join(instance.Options.DataDirectory, InitFlagFilename)
27− flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
28
27
29 // Check if the flag file exists
28 // Check if the flag file exists
30 _, err := os.Stat(flagFilePath)
29 _, err := os.Stat(flagFilePath)
31 if err == nil {
30 if err == nil {
32− // File exists, don't show the dialog
33 return false, nil
31 return false, nil
34 }
32 }
35
33
36− // If the error is not "file not found", return the error
37 if !os.IsNotExist(err) {
34 if !os.IsNotExist(err) {
38 return false, fmt.Errorf("failed to check init flag file: %w", err)
35 return false, fmt.Errorf("failed to check init flag file: %w", err)
39 }
36 }
@@ -44,11 +41,9 @@ func ShouldShowInitDialog() (bool, error) {
44 return false, fmt.Errorf("failed to check for CRUSH.md files: %w", err)
41 return false, fmt.Errorf("failed to check for CRUSH.md files: %w", err)
45 }
42 }
46 if crushExists {
43 if crushExists {
47− // CRUSH.md already exists, don't show the dialog
48 return false, nil
44 return false, nil
49 }
45 }
50
46
51− // File doesn't exist, show the dialog
52 return true, nil
47 return true, nil
53}
48}
54
49
@@ -75,13 +70,11 @@ func crushMdExists(dir string) (bool, error) {
75
70
76// MarkProjectInitialized marks the current project as initialized
71// MarkProjectInitialized marks the current project as initialized
77func MarkProjectInitialized() error {
72func MarkProjectInitialized() error {
78− if cfg == nil {
73+ if instance == nil {
79 return fmt.Errorf("config not loaded")
74 return fmt.Errorf("config not loaded")
80 }
75 }
81− // Create the flag file path
76+ flagFilePath := filepath.Join(instance.Options.DataDirectory, InitFlagFilename)
82− flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
83
77
84− // Create an empty file to mark the project as initialized
85 file, err := os.Create(flagFilePath)
78 file, err := os.Create(flagFilePath)
86 if err != nil {
79 if err != nil {
87 return fmt.Errorf("failed to create init flag file: %w", err)
80 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 (
17)
17)
18
18
19func Connect(ctx context.Context) (*sql.DB, error) {
19func Connect(ctx context.Context) (*sql.DB, error) {
20− dataDir := config.Get().Data.Directory
20+ dataDir := config.Get().Options.DataDirectory
21 if dataDir == "" {
21 if dataDir == "" {
22 return nil, fmt.Errorf("data.dir is not set")
22 return nil, fmt.Errorf("data.dir is not set")
23 }
23 }
internal/db/messages.sql.go+10−4
@@ -17,12 +17,13 @@ INSERT INTO messages (
17 role,
17 role,
18 parts,
18 parts,
19 model,
19 model,
20+ provider,
20 created_at,
21 created_at,
21 updated_at
22 updated_at
22) VALUES (
23) VALUES (
23− ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24+ ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24)
25)
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
26`
27`
27
28
28type CreateMessageParams struct {
29type CreateMessageParams struct {
@@ -31,6 +32,7 @@ type CreateMessageParams struct {
31 Role string `json:"role"`
32 Role string `json:"role"`
32 Parts string `json:"parts"`
33 Parts string `json:"parts"`
33 Model sql.NullString `json:"model"`
34 Model sql.NullString `json:"model"`
35+ Provider sql.NullString `json:"provider"`
34}
36}
35
37
36func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
38func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -40,6 +42,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
40 arg.Role,
42 arg.Role,
41 arg.Parts,
43 arg.Parts,
42 arg.Model,
44 arg.Model,
45+ arg.Provider,
43 )
46 )
44 var i Message
47 var i Message
45 err := row.Scan(
48 err := row.Scan(
@@ -51,6 +54,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
51 &i.CreatedAt,
54 &i.CreatedAt,
52 &i.UpdatedAt,
55 &i.UpdatedAt,
53 &i.FinishedAt,
56 &i.FinishedAt,
57+ &i.Provider,
54 )
58 )
55 return i, err
59 return i, err
56}
60}
@@ -76,7 +80,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
76}
80}
77
81
78const getMessage = `-- name: GetMessage :one
82const 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
80FROM messages
84FROM messages
81WHERE id = ? LIMIT 1
85WHERE id = ? LIMIT 1
82`
86`
@@ -93,12 +97,13 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
93 &i.CreatedAt,
97 &i.CreatedAt,
94 &i.UpdatedAt,
98 &i.UpdatedAt,
95 &i.FinishedAt,
99 &i.FinishedAt,
100+ &i.Provider,
96 )
101 )
97 return i, err
102 return i, err
98}
103}
99
104
100const listMessagesBySession = `-- name: ListMessagesBySession :many
105const 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
102FROM messages
107FROM messages
103WHERE session_id = ?
108WHERE session_id = ?
104ORDER BY created_at ASC
109ORDER BY created_at ASC
@@ -122,6 +127,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
122 &i.CreatedAt,
127 &i.CreatedAt,
123 &i.UpdatedAt,
128 &i.UpdatedAt,
124 &i.FinishedAt,
129 &i.FinishedAt,
130+ &i.Provider,
125 ); err != nil {
131 ); err != nil {
126 return nil, err
132 return nil, err
127 }
133 }
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 {
27 CreatedAt int64 `json:"created_at"`
27 CreatedAt int64 `json:"created_at"`
28 UpdatedAt int64 `json:"updated_at"`
28 UpdatedAt int64 `json:"updated_at"`
29 FinishedAt sql.NullInt64 `json:"finished_at"`
29 FinishedAt sql.NullInt64 `json:"finished_at"`
30+ Provider sql.NullString `json:"provider"`
30}
31}
31
32
32type Session struct {
33type Session struct {
internal/db/sql/messages.sql+2−1
@@ -16,10 +16,11 @@ INSERT INTO messages (
16 role,
16 role,
17 parts,
17 parts,
18 model,
18 model,
19+ provider,
19 created_at,
20 created_at,
20 updated_at
21 updated_at
21) VALUES (
22) VALUES (
22− ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23+ ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23)
24)
24RETURNING *;
25RETURNING *;
25
26
internal/fsext/fileutil.go+1−1
@@ -55,7 +55,7 @@ func GetRgSearchCmd(pattern, path, include string) *exec.Cmd {
55 return nil
55 return nil
56 }
56 }
57 // Use -n to show line numbers and include the matched line
57 // Use -n to show line numbers and include the matched line
58− args := []string{"-n", pattern}
58+ args := []string{"-H", "-n", pattern}
59 if include != "" {
59 if include != "" {
60 args = append(args, "--glob", include)
60 args = append(args, "--glob", include)
61 }
61 }
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 (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7
7
8− "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/llm/tools"
8 "github.com/charmbracelet/crush/internal/llm/tools"
10− "github.com/charmbracelet/crush/internal/lsp"
11 "github.com/charmbracelet/crush/internal/message"
9 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/session"
13)
11)
14
12
15type agentTool struct {
13type agentTool struct {
16− sessions session.Service
14+ agent Service
17− messages message.Service
15+ sessions session.Service
18− lspClients map[string]*lsp.Client
16+ messages message.Service
19}
17}
20
18
21const (
19const (
@@ -26,6 +24,10 @@ type AgentParams struct {
26 Prompt string `json:"prompt"`
24 Prompt string `json:"prompt"`
27}
25}
28
26
27+func (b *agentTool) Name() string {
28+ return AgentToolName
29+}
30+
29func (b *agentTool) Info() tools.ToolInfo {
31func (b *agentTool) Info() tools.ToolInfo {
30 return tools.ToolInfo{
32 return tools.ToolInfo{
31 Name: AgentToolName,
33 Name: AgentToolName,
@@ -54,17 +56,12 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
54 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
56 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
55 }
57 }
56
58
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−
62 session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
59 session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
63 if err != nil {
60 if err != nil {
64 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
61 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
65 }
62 }
66
63
67− done, err := agent.Run(ctx, session.ID, params.Prompt)
64+ done, err := b.agent.Run(ctx, session.ID, params.Prompt)
68 if err != nil {
65 if err != nil {
69 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
66 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
70 }
67 }
@@ -97,13 +94,13 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
97}
94}
98
95
99func NewAgentTool(
96func NewAgentTool(
100− Sessions session.Service,
97+ agent Service,
101− Messages message.Service,
98+ sessions session.Service,
102− LspClients map[string]*lsp.Client,
99+ messages message.Service,
103) tools.BaseTool {
100) tools.BaseTool {
104 return &agentTool{
101 return &agentTool{
105− sessions: Sessions,
102+ sessions: sessions,
106− messages: Messages,
103+ messages: messages,
107− lspClients: LspClients,
104+ agent: agent,
108 }
105 }
109}
106}
internal/llm/agent/agent.go+267−111
@@ -4,16 +4,18 @@ import (
4 "context"
4 "context"
5 "errors"
5 "errors"
6 "fmt"
6 "fmt"
7+ "slices"
7 "strings"
8 "strings"
8 "sync"
9 "sync"
9 "time"
10 "time"
10
11
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/history"
13 "github.com/charmbracelet/crush/internal/llm/prompt"
14 "github.com/charmbracelet/crush/internal/llm/prompt"
14 "github.com/charmbracelet/crush/internal/llm/provider"
15 "github.com/charmbracelet/crush/internal/llm/provider"
15 "github.com/charmbracelet/crush/internal/llm/tools"
16 "github.com/charmbracelet/crush/internal/llm/tools"
16 "github.com/charmbracelet/crush/internal/logging"
17 "github.com/charmbracelet/crush/internal/logging"
18+ "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/message"
19 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/pubsub"
@@ -47,72 +49,189 @@ type AgentEvent struct {
47
49
48type Service interface {
50type Service interface {
49 pubsub.Suscriber[AgentEvent]
51 pubsub.Suscriber[AgentEvent]
50− Model() models.Model
52+ Model() config.Model
53+ EffectiveMaxTokens() int64
51 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
54 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
52 Cancel(sessionID string)
55 Cancel(sessionID string)
53 CancelAll()
56 CancelAll()
54 IsSessionBusy(sessionID string) bool
57 IsSessionBusy(sessionID string) bool
55 IsBusy() bool
58 IsBusy() bool
56− Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
57 Summarize(ctx context.Context, sessionID string) error
59 Summarize(ctx context.Context, sessionID string) error
60+ UpdateModel() error
58}
61}
59
62
60type agent struct {
63type agent struct {
61 *pubsub.Broker[AgentEvent]
64 *pubsub.Broker[AgentEvent]
65+ agentCfg config.Agent
62 sessions session.Service
66 sessions session.Service
63 messages message.Service
67 messages message.Service
64
68
65− tools []tools.BaseTool
69+ tools []tools.BaseTool
66− provider provider.Provider
70+ provider provider.Provider
71+ providerID string
67
72
68− titleProvider provider.Provider
73+ titleProvider provider.Provider
69− summarizeProvider provider.Provider
74+ summarizeProvider provider.Provider
75+ summarizeProviderID string
70
76
71 activeRequests sync.Map
77 activeRequests sync.Map
72}
78}
73
79
80+var agentPromptMap = map[config.AgentID]prompt.PromptID{
81+ config.AgentCoder: prompt.PromptCoder,
82+ config.AgentTask: prompt.PromptTask,
83+}
84+
74func NewAgent(
85func NewAgent(
75− agentName config.AgentName,
86+ agentCfg config.Agent,
87+ // These services are needed in the tools
88+ permissions permission.Service,
76 sessions session.Service,
89 sessions session.Service,
77 messages message.Service,
90 messages message.Service,
78− agentTools []tools.BaseTool,
91+ history history.Service,
92+ lspClients map[string]*lsp.Client,
79) (Service, error) {
93) (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...)
81 if err != nil {
153 if err != nil {
82 return nil, err
154 return nil, err
83 }
155 }
84− var titleProvider provider.Provider
156+
85− // Only generate titles for the coder agent
157+ smallModelCfg := cfg.Models.Small
86− if agentName == config.AgentCoder {
158+ var smallModel config.Model
87− titleProvider, err = createAgentProvider(config.AgentTitle)
159+
88− if err != nil {
160+ var smallModelProviderCfg config.ProviderConfig
89− return nil, err
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)
90 }
172 }
91 }
173 }
92− var summarizeProvider provider.Provider
174+ for _, m := range smallModelProviderCfg.Models {
93− if agentName == config.AgentCoder {
175+ if m.ID == smallModelCfg.ModelID {
94− summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
176+ smallModel = m
95− if err != nil {
177+ break
96− return nil, err
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+ }
97 }
209 }
98 }
210 }
99
211
100 agent := &agent{
212 agent := &agent{
101− Broker: pubsub.NewBroker[AgentEvent](),
213+ Broker: pubsub.NewBroker[AgentEvent](),
102− provider: agentProvider,
214+ agentCfg: agentCfg,
103− messages: messages,
215+ provider: agentProvider,
104− sessions: sessions,
216+ providerID: string(providerCfg.ID),
105− tools: agentTools,
217+ messages: messages,
106− titleProvider: titleProvider,
218+ sessions: sessions,
107− summarizeProvider: summarizeProvider,
219+ tools: agentTools,
108− activeRequests: sync.Map{},
220+ titleProvider: titleProvider,
221+ summarizeProvider: summarizeProvider,
222+ summarizeProviderID: string(smallModelProviderCfg.ID),
223+ activeRequests: sync.Map{},
109 }
224 }
110
225
111 return agent, nil
226 return agent, nil
112}
227}
113
228
114−func (a *agent) Model() models.Model {
229+func (a *agent) Model() config.Model {
115− return a.provider.Model()
230+ return config.GetAgentModel(a.agentCfg.ID)
231+}
232+
233+func (a *agent) EffectiveMaxTokens() int64 {
234+ return config.GetAgentEffectiveMaxTokens(a.agentCfg.ID)
116}
235}
117
236
118func (a *agent) Cancel(sessionID string) {
237func (a *agent) Cancel(sessionID string) {
@@ -139,10 +258,10 @@ func (a *agent) IsBusy() bool {
139 if cancelFunc, ok := value.(context.CancelFunc); ok {
258 if cancelFunc, ok := value.(context.CancelFunc); ok {
140 if cancelFunc != nil {
259 if cancelFunc != nil {
141 busy = true
260 busy = true
142− return false // Stop iterating
261+ return false
143 }
262 }
144 }
263 }
145− return true // Continue iterating
264+ return true
146 })
265 })
147 return busy
266 return busy
148}
267}
@@ -163,7 +282,9 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
163 if err != nil {
282 if err != nil {
164 return err
283 return err
165 }
284 }
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+ }}
167
288
168 // Use streaming approach like summarization
289 // Use streaming approach like summarization
169 response := a.titleProvider.StreamResponse(
290 response := a.titleProvider.StreamResponse(
@@ -207,7 +328,7 @@ func (a *agent) err(err error) AgentEvent {
207}
328}
208
329
209func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
330func (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 {
211 attachments = nil
332 attachments = nil
212 }
333 }
213 events := make(chan AgentEvent)
334 events := make(chan AgentEvent)
@@ -242,6 +363,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
242}
363}
243
364
244func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
365func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
366+ cfg := config.Get()
245 // List existing messages; if none, start title generation asynchronously.
367 // List existing messages; if none, start title generation asynchronously.
246 msgs, err := a.messages.List(ctx, sessionID)
368 msgs, err := a.messages.List(ctx, sessionID)
247 if err != nil {
369 if err != nil {
@@ -300,7 +422,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
300 }
422 }
301 return a.err(fmt.Errorf("failed to process events: %w", err))
423 return a.err(fmt.Errorf("failed to process events: %w", err))
302 }
424 }
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+ }
304 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
432 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
305 // We are not done, we need to respond with the tool response
433 // We are not done, we need to respond with the tool response
306 msgHistory = append(msgHistory, agentMessage, *toolResults)
434 msgHistory = append(msgHistory, agentMessage, *toolResults)
@@ -324,12 +452,14 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
324}
452}
325
453
326func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
454func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
455+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
327 eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
456 eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
328
457
329 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
458 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
330− Role: message.Assistant,
459+ Role: message.Assistant,
331− Parts: []message.ContentPart{},
460+ Parts: []message.ContentPart{},
332− Model: a.provider.Model().ID,
461+ Model: a.Model().ID,
462+ Provider: a.providerID,
333 })
463 })
334 if err != nil {
464 if err != nil {
335 return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
465 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
337
467
338 // Add the session and message ID into the context if needed by tools.
468 // Add the session and message ID into the context if needed by tools.
339 ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
469 ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
340− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
341
470
342 // Process each event in the stream.
471 // Process each event in the stream.
343 for event := range eventChan {
472 for event := range eventChan {
@@ -369,9 +498,10 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
369 default:
498 default:
370 // Continue processing
499 // Continue processing
371 var tool tools.BaseTool
500 var tool tools.BaseTool
372− for _, availableTools := range a.tools {
501+ for _, availableTool := range a.tools {
373− if availableTools.Info().Name == toolCall.Name {
502+ if availableTool.Info().Name == toolCall.Name {
374− tool = availableTools
503+ tool = availableTool
504+ break
375 }
505 }
376 }
506 }
377
507
@@ -424,8 +554,9 @@ out:
424 parts = append(parts, tr)
554 parts = append(parts, tr)
425 }
555 }
426 msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
556 msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
427− Role: message.Tool,
557+ Role: message.Tool,
428− Parts: parts,
558+ Parts: parts,
559+ Provider: a.providerID,
429 })
560 })
430 if err != nil {
561 if err != nil {
431 return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
562 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
478 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
609 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
479 return fmt.Errorf("failed to update message: %w", err)
610 return fmt.Errorf("failed to update message: %w", err)
480 }
611 }
481− return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
612+ return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
482 }
613 }
483
614
484 return nil
615 return nil
485}
616}
486
617
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 {
488 sess, err := a.sessions.Get(ctx, sessionID)
619 sess, err := a.sessions.Get(ctx, sessionID)
489 if err != nil {
620 if err != nil {
490 return fmt.Errorf("failed to get session: %w", err)
621 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
506 return nil
637 return nil
507}
638}
508
639
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−
528func (a *agent) Summarize(ctx context.Context, sessionID string) error {
640func (a *agent) Summarize(ctx context.Context, sessionID string) error {
529 if a.summarizeProvider == nil {
641 if a.summarizeProvider == nil {
530 return fmt.Errorf("summarize provider not available")
642 return fmt.Errorf("summarize provider not available")
@@ -561,6 +673,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
561 a.Publish(pubsub.CreatedEvent, event)
673 a.Publish(pubsub.CreatedEvent, event)
562 return
674 return
563 }
675 }
676+ summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
564
677
565 if len(msgs) == 0 {
678 if len(msgs) == 0 {
566 event = AgentEvent{
679 event = AgentEvent{
@@ -654,7 +767,8 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
654 Time: time.Now().Unix(),
767 Time: time.Now().Unix(),
655 },
768 },
656 },
769 },
657− Model: a.summarizeProvider.Model().ID,
770+ Model: a.summarizeProvider.Model().ID,
771+ Provider: a.summarizeProviderID,
658 })
772 })
659 if err != nil {
773 if err != nil {
660 event = AgentEvent{
774 event = AgentEvent{
@@ -706,56 +820,98 @@ func (a *agent) CancelAll() {
706 })
820 })
707}
821}
708
822
709−func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
823+func (a *agent) UpdateModel() error {
710 cfg := config.Get()
824 cfg := config.Get()
711− agentConfig, ok := cfg.Agents[agentName]
712− if !ok {
713− return nil, fmt.Errorf("agent %s not found", agentName)
714− }
715− model, ok := models.SupportedModels[agentConfig.Model]
716− if !ok {
717− return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
718− }
719
825
720− providerCfg, ok := cfg.Providers[model.Provider]
826+ // Get current provider configuration
721− if !ok {
827+ currentProviderCfg := config.GetAgentProvider(a.agentCfg.ID)
722− return nil, fmt.Errorf("provider %s not supported", model.Provider)
828+ if currentProviderCfg.ID == "" {
829+ return fmt.Errorf("provider for agent %s not found in config", a.agentCfg.Name)
723 }
830 }
724− if providerCfg.Disabled {
831+
725− return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
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)
726 }
858 }
727− maxTokens := model.DefaultMaxTokens
859+
728− if agentConfig.MaxTokens > 0 {
860+ // Check if small model provider has changed (affects title and summarize providers)
729− maxTokens = agentConfig.MaxTokens
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+ }
730 }
869 }
731− opts := []provider.ProviderClientOption{
870+
732− provider.WithAPIKey(providerCfg.APIKey),
871+ if smallModelProviderCfg.ID == "" {
733− provider.WithModel(model),
872+ return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
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− )
751 }
873 }
752− agentProvider, err := provider.NewProvider(
874+
753− model.Provider,
875+ // Check if summarize provider has changed
754− opts...,
876+ if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
755− )
877+ var smallModel config.Model
756− if err != nil {
878+ for _, m := range smallModelProviderCfg.Models {
757− return nil, fmt.Errorf("could not create provider: %v", err)
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)
758 }
914 }
759
915
760− return agentProvider, nil
916+ return nil
761}
917}
internal/llm/agent/mcp-tools.go+13−5
@@ -18,7 +18,7 @@ import (
18type mcpTool struct {
18type mcpTool struct {
19 mcpName string
19 mcpName string
20 tool mcp.Tool
20 tool mcp.Tool
21− mcpConfig config.MCPServer
21+ mcpConfig config.MCP
22 permissions permission.Service
22 permissions permission.Service
23}
23}
24
24
@@ -32,12 +32,20 @@ type MCPClient interface {
32 Close() error
32 Close() error
33}
33}
34
34
35+func (b *mcpTool) Name() string {
36+ return fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name)
37+}
38+
35func (b *mcpTool) Info() tools.ToolInfo {
39func (b *mcpTool) Info() tools.ToolInfo {
40+ required := b.tool.InputSchema.Required
41+ if required == nil {
42+ required = make([]string, 0)
43+ }
36 return tools.ToolInfo{
44 return tools.ToolInfo{
37 Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name),
45 Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name),
38 Description: b.tool.Description,
46 Description: b.tool.Description,
39 Parameters: b.tool.InputSchema.Properties,
47 Parameters: b.tool.InputSchema.Properties,
40− Required: b.tool.InputSchema.Required,
48+ Required: required,
41 }
49 }
42}
50}
43
51
@@ -124,7 +132,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
124 return tools.NewTextErrorResponse("invalid mcp type"), nil
132 return tools.NewTextErrorResponse("invalid mcp type"), nil
125}
133}
126
134
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 {
128 return &mcpTool{
136 return &mcpTool{
129 mcpName: name,
137 mcpName: name,
130 tool: tool,
138 tool: tool,
@@ -135,7 +143,7 @@ func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpC
135
143
136var mcpTools []tools.BaseTool
144var mcpTools []tools.BaseTool
137
145
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 {
139 var stdioTools []tools.BaseTool
147 var stdioTools []tools.BaseTool
140 initRequest := mcp.InitializeRequest{}
148 initRequest := mcp.InitializeRequest{}
141 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
149 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
@@ -166,7 +174,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
166 if len(mcpTools) > 0 {
174 if len(mcpTools) > 0 {
167 return mcpTools
175 return mcpTools
168 }
176 }
169− for name, m := range config.Get().MCPServers {
177+ for name, m := range config.Get().MCP {
170 switch m.Type {
178 switch m.Type {
171 case config.MCPStdio:
179 case config.MCPStdio:
172 c, err := client.NewStdioMCPClient(
180 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 (
9 "time"
9 "time"
10
10
11 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/llm/tools"
13 "github.com/charmbracelet/crush/internal/llm/tools"
14+ "github.com/charmbracelet/crush/internal/logging"
14)
15)
15
16
16−func CoderPrompt(provider models.ModelProvider) string {
17+func CoderPrompt(p provider.InferenceProvider, contextFiles ...string) string {
17− basePrompt := baseAnthropicCoderPrompt
18+ var basePrompt string
18− switch provider {
19+ switch p {
19− case models.ProviderOpenAI:
20+ case provider.InferenceProviderOpenAI:
20 basePrompt = baseOpenAICoderPrompt
21 basePrompt = baseOpenAICoderPrompt
22+ case provider.InferenceProviderGemini, provider.InferenceProviderVertexAI:
23+ basePrompt = baseGeminiCoderPrompt
24+ default:
25+ basePrompt = baseAnthropicCoderPrompt
21 }
26 }
22 envInfo := getEnvironmentInfo()
27 envInfo := getEnvironmentInfo()
23
28
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
25}
37}
26
38
27const baseOpenAICoderPrompt = `
39const 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.
40+Please resolve the user's task by editing and testing the code files in your current code execution session.
29−
41+You are a deployed coding agent.
30−You can:
42+Your session allows you to easily modify and run code in the user's local environment.
31−- Receive user prompts, project context, and files.
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.
32−- Stream responses and emit function calls (e.g., shell commands, code edits).
33
44
45+IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
34
46
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
36
52
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.
38
54
39You MUST adhere to the following criteria when executing the task:
55You MUST adhere to the following criteria when executing the task:
56+
40- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
57- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
41- Analyzing code for vulnerabilities is allowed.
58- Analyzing code for vulnerabilities is allowed.
42- Showing user code and tool call details is allowed.
59- 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+
44- If completing the user's task requires writing or modifying files:
79- If completing the user's task requires writing or modifying files:
45− - Your code and final answer should follow these *CODING GUIDELINES*:
80+ - 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.
81+ - Fix the problem at the root cause rather than applying surface-level patches, when possible.
47− - Avoid unneeded complexity in your solution.
82+ - Avoid unneeded complexity in your solution.
48− - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
83+ - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
49− - Update documentation as necessary.
84+ - Update documentation as necessary.
50− - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
85+ - 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.
86+ - Use ` + "`git log`" + ` and ` + "`git blame`" + ` to search the history of the codebase if additional context is required.
52− - NEVER add copyright or license headers unless specifically requested.
87+ - 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.
88+ - You do not need to ` + "`git commit`" + ` your changes; this will be done automatically for you.
54− - Once you finish coding, you must
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.
55− - Check "git status" to sanity check your changes; revert any scratch files or changes.
90+ - If pre-commit doesn't work after a few retries, politely inform the user that the pre-commit setup is broken.
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.
91+ - Once you finish coding, you must
57− - Check if you accidentally add copyright or license headers. If so, remove them.
92+ - Check ` + "`git status`" + ` to sanity check your changes; revert any scratch files or changes.
58− - For smaller tasks, describe in brief bullet points
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.
59− - For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
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+
60- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
120- 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.
62- When your task involves writing or modifying files:
122- 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.
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.
64− - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
124+ - 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
68- NEVER use emojis in your responses
125- NEVER use emojis in your responses
69`
126`
70
127
@@ -167,6 +224,161 @@ VERY IMPORTANT NEVER use emojis in your responses.
167
224
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
225You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
169
226
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+
170func getEnvironmentInfo() string {
382func getEnvironmentInfo() string {
171 cwd := config.WorkingDirectory()
383 cwd := config.WorkingDirectory()
172 isGit := isGitRepo(cwd)
384 isGit := isGitRepo(cwd)
internal/llm/prompt/prompt.go+19−35
@@ -1,60 +1,44 @@
1package prompt
1package prompt
2
2
3import (
3import (
4− "fmt"
5 "os"
4 "os"
6 "path/filepath"
5 "path/filepath"
7 "strings"
6 "strings"
8 "sync"
7 "sync"
9
8
10 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/llm/models"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
12− "github.com/charmbracelet/crush/internal/logging"
13)
11)
14
12
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 {
16 basePrompt := ""
24 basePrompt := ""
17− switch agentName {
25+ switch promptID {
18− case config.AgentCoder:
26+ case PromptCoder:
19 basePrompt = CoderPrompt(provider)
27 basePrompt = CoderPrompt(provider)
20− case config.AgentTitle:
28+ case PromptTitle:
21 basePrompt = TitlePrompt(provider)
29 basePrompt = TitlePrompt(provider)
22− case config.AgentTask:
30+ case PromptTask:
23 basePrompt = TaskPrompt(provider)
31 basePrompt = TaskPrompt(provider)
24− case config.AgentSummarizer:
32+ case PromptSummarizer:
25 basePrompt = SummarizerPrompt(provider)
33 basePrompt = SummarizerPrompt(provider)
26 default:
34 default:
27 basePrompt = "You are a helpful assistant"
35 basePrompt = "You are a helpful assistant"
28 }
36 }
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− }
38 return basePrompt
37 return basePrompt
39}
38}
40
39
41−var (
40+func getContextFromPaths(contextPaths []string) string {
42− onceContext sync.Once
41+ return processContextPaths(config.WorkingDirectory(), contextPaths)
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
58}
42}
59
43
60func processContextPaths(workDir string, paths []string) string {
44func processContextPaths(workDir string, paths []string) string {
internal/llm/prompt/prompt_test.go+7−8
@@ -15,16 +15,10 @@ func TestGetContextFromPaths(t *testing.T) {
15 t.Parallel()
15 t.Parallel()
16
16
17 tmpDir := t.TempDir()
17 tmpDir := t.TempDir()
18− _, err := config.Load(tmpDir, false)
18+ _, err := config.Init(tmpDir, false)
19 if err != nil {
19 if err != nil {
20 t.Fatalf("Failed to load config: %v", err)
20 t.Fatalf("Failed to load config: %v", err)
21 }
21 }
22− cfg := config.Get()
23− cfg.WorkingDir = tmpDir
24− cfg.ContextPaths = []string{
25− "file.txt",
26− "directory/",
27− }
28 testFiles := []string{
22 testFiles := []string{
29 "file.txt",
23 "file.txt",
30 "directory/file_a.txt",
24 "directory/file_a.txt",
@@ -34,7 +28,12 @@ func TestGetContextFromPaths(t *testing.T) {
34
28
35 createTestFiles(t, tmpDir, testFiles)
29 createTestFiles(t, tmpDir, testFiles)
36
30
37− context := getContextFromPaths()
31+ context := getContextFromPaths(
32+ []string{
33+ "file.txt",
34+ "directory/",
35+ },
36+ )
38 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)
37 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)
39 assert.Equal(t, expectedContext, context)
38 assert.Equal(t, expectedContext, context)
40}
39}
internal/llm/prompt/summarizer.go+4−2
@@ -1,8 +1,10 @@
1package prompt
1package prompt
2
2
3−import "github.com/charmbracelet/crush/internal/llm/models"
3+import (
4+ "github.com/charmbracelet/crush/internal/fur/provider"
5+)
4
6
5−func SummarizerPrompt(_ models.ModelProvider) string {
7+func SummarizerPrompt(_ provider.InferenceProvider) string {
6 return `You are a helpful AI assistant tasked with summarizing conversations.
8 return `You are a helpful AI assistant tasked with summarizing conversations.
7
9
8When asked to summarize, provide a detailed but concise summary of the conversation.
10When 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
3import (
3import (
4 "fmt"
4 "fmt"
5
5
6− "github.com/charmbracelet/crush/internal/llm/models"
6+ "github.com/charmbracelet/crush/internal/fur/provider"
7)
7)
8
8
9−func TaskPrompt(_ models.ModelProvider) string {
9+func TaskPrompt(_ provider.InferenceProvider) string {
10 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.
10 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.
11Notes:
11Notes:
121. 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...".
121. 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 @@
1package prompt
1package prompt
2
2
3−import "github.com/charmbracelet/crush/internal/llm/models"
3+import (
4+ "github.com/charmbracelet/crush/internal/fur/provider"
5+)
4
6
5−func TitlePrompt(_ models.ModelProvider) string {
7+func TitlePrompt(_ provider.InferenceProvider) string {
6 return `you will generate a short title based on the first message a user begins a conversation with
8 return `you will generate a short title based on the first message a user begins a conversation with
7- ensure it is not more than 50 characters long
9- ensure it is not more than 50 characters long
8- the title should be a summary of the user's message
10- the title should be a summary of the user's message
internal/llm/provider/anthropic.go+53−68
@@ -6,55 +6,42 @@ import (
6 "errors"
6 "errors"
7 "fmt"
7 "fmt"
8 "io"
8 "io"
9− "strings"
10 "time"
9 "time"
11
10
12 "github.com/anthropics/anthropic-sdk-go"
11 "github.com/anthropics/anthropic-sdk-go"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
12 "github.com/anthropics/anthropic-sdk-go/bedrock"
14 "github.com/anthropics/anthropic-sdk-go/option"
13 "github.com/anthropics/anthropic-sdk-go/option"
15 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/llm/models"
15+ "github.com/charmbracelet/crush/internal/fur/provider"
17 "github.com/charmbracelet/crush/internal/llm/tools"
16 "github.com/charmbracelet/crush/internal/llm/tools"
18 "github.com/charmbracelet/crush/internal/logging"
17 "github.com/charmbracelet/crush/internal/logging"
19 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/message"
20)
19)
21
20
22−type anthropicOptions struct {
23− useBedrock bool
24− disableCache bool
25− shouldThink func(userMessage string) bool
26−}
27−
28−type AnthropicOption func(*anthropicOptions)
29−
30type anthropicClient struct {
21type anthropicClient struct {
31 providerOptions providerClientOptions
22 providerOptions providerClientOptions
32− options anthropicOptions
23+ useBedrock bool
33 client anthropic.Client
24 client anthropic.Client
34}
25}
35
26
36type AnthropicClient ProviderClient
27type AnthropicClient ProviderClient
37
28
38−func newAnthropicClient(opts providerClientOptions) AnthropicClient {
29+func newAnthropicClient(opts providerClientOptions, useBedrock bool) AnthropicClient {
39− anthropicOpts := anthropicOptions{}
30+ return &anthropicClient{
40− for _, o := range opts.anthropicOptions {
31+ providerOptions: opts,
41− o(&anthropicOpts)
32+ client: createAnthropicClient(opts, useBedrock),
42 }
33 }
34+}
43
35
36+func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
44 anthropicClientOptions := []option.RequestOption{}
37 anthropicClientOptions := []option.RequestOption{}
45 if opts.apiKey != "" {
38 if opts.apiKey != "" {
46 anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
39 anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
47 }
40 }
48− if anthropicOpts.useBedrock {
41+ if useBedrock {
49 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
42 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
50 }
43 }
51−
44+ return anthropic.NewClient(anthropicClientOptions...)
52− client := anthropic.NewClient(anthropicClientOptions...)
53− return &anthropicClient{
54− providerOptions: opts,
55− options: anthropicOpts,
56− client: client,
57− }
58}
45}
59
46
60func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
47func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
@@ -66,7 +53,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
66 switch msg.Role {
53 switch msg.Role {
67 case message.User:
54 case message.User:
68 content := anthropic.NewTextBlock(msg.Content().String())
55 content := anthropic.NewTextBlock(msg.Content().String())
69− if cache && !a.options.disableCache {
56+ if cache && !a.providerOptions.disableCache {
70 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
57 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
71 Type: "ephemeral",
58 Type: "ephemeral",
72 }
59 }
@@ -74,7 +61,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
74 var contentBlocks []anthropic.ContentBlockParamUnion
61 var contentBlocks []anthropic.ContentBlockParamUnion
75 contentBlocks = append(contentBlocks, content)
62 contentBlocks = append(contentBlocks, content)
76 for _, binaryContent := range msg.BinaryContent() {
63 for _, binaryContent := range msg.BinaryContent() {
77− base64Image := binaryContent.String(models.ProviderAnthropic)
64+ base64Image := binaryContent.String(provider.InferenceProviderAnthropic)
78 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
65 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
79 contentBlocks = append(contentBlocks, imageBlock)
66 contentBlocks = append(contentBlocks, imageBlock)
80 }
67 }
@@ -84,7 +71,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
84 blocks := []anthropic.ContentBlockParamUnion{}
71 blocks := []anthropic.ContentBlockParamUnion{}
85 if msg.Content().String() != "" {
72 if msg.Content().String() != "" {
86 content := anthropic.NewTextBlock(msg.Content().String())
73 content := anthropic.NewTextBlock(msg.Content().String())
87− if cache && !a.options.disableCache {
74+ if cache && !a.providerOptions.disableCache {
88 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
75 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
89 Type: "ephemeral",
76 Type: "ephemeral",
90 }
77 }
@@ -132,7 +119,7 @@ func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolU
132 },
119 },
133 }
120 }
134
121
135− if i == len(tools)-1 && !a.options.disableCache {
122+ if i == len(tools)-1 && !a.providerOptions.disableCache {
136 toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
123 toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
137 Type: "ephemeral",
124 Type: "ephemeral",
138 }
125 }
@@ -160,26 +147,33 @@ func (a *anthropicClient) finishReason(reason string) message.FinishReason {
160}
147}
161
148
162func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
149func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
150+ model := a.providerOptions.model(a.providerOptions.modelType)
163 var thinkingParam anthropic.ThinkingConfigParamUnion
151 var thinkingParam anthropic.ThinkingConfigParamUnion
164− lastMessage := messages[len(messages)-1]
152+ cfg := config.Get()
165− isUser := lastMessage.Role == anthropic.MessageParamRoleUser
153+ modelConfig := cfg.Models.Large
166− messageContent := ""
154+ if a.providerOptions.modelType == config.SmallModel {
155+ modelConfig = cfg.Models.Small
156+ }
167 temperature := anthropic.Float(0)
157 temperature := anthropic.Float(0)
168− if isUser {
158+
169− for _, m := range lastMessage.Content {
159+ if a.Model().CanReason && modelConfig.Think {
170− if m.OfText != nil && m.OfText.Text != "" {
160+ thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
171− messageContent = m.OfText.Text
161+ temperature = anthropic.Float(1)
172− }
162+ }
173− }
163+
174− if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
164+ maxTokens := model.DefaultMaxTokens
175− thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
165+ if modelConfig.MaxTokens > 0 {
176− temperature = anthropic.Float(1)
166+ maxTokens = modelConfig.MaxTokens
177− }
167+ }
168+
169+ // Override max tokens if set in provider options
170+ if a.providerOptions.maxTokens > 0 {
171+ maxTokens = a.providerOptions.maxTokens
178 }
172 }
179
173
180 return anthropic.MessageNewParams{
174 return anthropic.MessageNewParams{
181− Model: anthropic.Model(a.providerOptions.model.APIModel),
175+ Model: anthropic.Model(model.ID),
182− MaxTokens: a.providerOptions.maxTokens,
176+ MaxTokens: maxTokens,
183 Temperature: temperature,
177 Temperature: temperature,
184 Messages: messages,
178 Messages: messages,
185 Tools: tools,
179 Tools: tools,
@@ -198,7 +192,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
198func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
192func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
193 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200 cfg := config.Get()
194 cfg := config.Get()
201− if cfg.Debug {
195+ if cfg.Options.Debug {
202 jsonData, _ := json.Marshal(preparedMessages)
196 jsonData, _ := json.Marshal(preparedMessages)
203 logging.Debug("Prepared messages", "messages", string(jsonData))
197 logging.Debug("Prepared messages", "messages", string(jsonData))
204 }
198 }
@@ -247,7 +241,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
247func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
241func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
248 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
242 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
249 cfg := config.Get()
243 cfg := config.Get()
250− if cfg.Debug {
244+ if cfg.Options.Debug {
251 // jsonData, _ := json.Marshal(preparedMessages)
245 // jsonData, _ := json.Marshal(preparedMessages)
252 // logging.Debug("Prepared messages", "messages", string(jsonData))
246 // logging.Debug("Prepared messages", "messages", string(jsonData))
253 }
247 }
@@ -383,12 +377,21 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
383}
377}
384
378
385func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
379func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
386− var apierr *anthropic.Error
380+ var apiErr *anthropic.Error
387− if !errors.As(err, &apierr) {
381+ if !errors.As(err, &apiErr) {
388 return false, 0, err
382 return false, 0, err
389 }
383 }
390
384
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 {
392 return false, 0, err
395 return false, 0, err
393 }
396 }
394
397
@@ -397,7 +400,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err
397 }
400 }
398
401
399 retryMs := 0
402 retryMs := 0
400− retryAfterValues := apierr.Response.Header.Values("Retry-After")
403+ retryAfterValues := apiErr.Response.Header.Values("Retry-After")
401
404
402 backoffMs := 2000 * (1 << (attempts - 1))
405 backoffMs := 2000 * (1 << (attempts - 1))
403 jitterMs := int(float64(backoffMs) * 0.2)
406 jitterMs := int(float64(backoffMs) * 0.2)
@@ -439,24 +442,6 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
439 }
442 }
440}
443}
441
444
442−func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
445+func (a *anthropicClient) Model() config.Model {
443− return func(options *anthropicOptions) {
446+ return a.providerOptions.model(a.providerOptions.modelType)
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− }
462}
447}
internal/llm/provider/bedrock.go+21−29
@@ -4,65 +4,54 @@ import (
4 "context"
4 "context"
5 "errors"
5 "errors"
6 "fmt"
6 "fmt"
7− "os"
8 "strings"
7 "strings"
9
8
9+ "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/llm/tools"
10 "github.com/charmbracelet/crush/internal/llm/tools"
11 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
12)
12)
13
13
14−type bedrockOptions struct {
15− // Bedrock specific options can be added here
16−}
17−
18−type BedrockOption func(*bedrockOptions)
19−
20type bedrockClient struct {
14type bedrockClient struct {
21 providerOptions providerClientOptions
15 providerOptions providerClientOptions
22− options bedrockOptions
23 childProvider ProviderClient
16 childProvider ProviderClient
24}
17}
25
18
26type BedrockClient ProviderClient
19type BedrockClient ProviderClient
27
20
28func newBedrockClient(opts providerClientOptions) BedrockClient {
21func newBedrockClient(opts providerClientOptions) BedrockClient {
29− bedrockOpts := bedrockOptions{}
30− // Apply bedrock specific options if they are added in the future
31−
32 // Get AWS region from environment
22 // Get AWS region from environment
33− region := os.Getenv("AWS_REGION")
23+ region := opts.extraParams["region"]
34− if region == "" {
35− region = os.Getenv("AWS_DEFAULT_REGION")
36− }
37−
38 if region == "" {
24 if region == "" {
39 region = "us-east-1" // default region
25 region = "us-east-1" // default region
40 }
26 }
41 if len(region) < 2 {
27 if len(region) < 2 {
42 return &bedrockClient{
28 return &bedrockClient{
43 providerOptions: opts,
29 providerOptions: opts,
44− options: bedrockOpts,
45 childProvider: nil, // Will cause an error when used
30 childProvider: nil, // Will cause an error when used
46 }
31 }
47 }
32 }
48
33
49− // Prefix the model name with region
34+ opts.model = func(modelType config.ModelType) config.Model {
50− regionPrefix := region[:2]
35+ model := config.GetModel(modelType)
51− modelName := opts.model.APIModel
36+
52− opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
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)
53
45
54 // Determine which provider to use based on the model
46 // 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") {
56 // Create Anthropic client with Bedrock configuration
48 // Create Anthropic client with Bedrock configuration
57 anthropicOpts := opts
49 anthropicOpts := opts
58− anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
50+ // TODO: later find a way to check if the AWS account has caching enabled
59− WithAnthropicBedrock(true),
51+ opts.disableCache = true // Disable cache for Bedrock
60− WithAnthropicDisableCache(),
61− )
62 return &bedrockClient{
52 return &bedrockClient{
63 providerOptions: opts,
53 providerOptions: opts,
64− options: bedrockOpts,
54+ childProvider: newAnthropicClient(anthropicOpts, true),
65− childProvider: newAnthropicClient(anthropicOpts),
66 }
55 }
67 }
56 }
68
57
@@ -70,7 +59,6 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
70 // This will cause an error when used
59 // This will cause an error when used
71 return &bedrockClient{
60 return &bedrockClient{
72 providerOptions: opts,
61 providerOptions: opts,
73− options: bedrockOpts,
74 childProvider: nil,
62 childProvider: nil,
75 }
63 }
76}
64}
@@ -98,3 +86,7 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
98
86
99 return b.childProvider.stream(ctx, messages, tools)
87 return b.childProvider.stream(ctx, messages, tools)
100}
88}
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 (
17 "google.golang.org/genai"
17 "google.golang.org/genai"
18)
18)
19
19
20−type geminiOptions struct {
21− disableCache bool
22−}
23−
24−type GeminiOption func(*geminiOptions)
25−
26type geminiClient struct {
20type geminiClient struct {
27 providerOptions providerClientOptions
21 providerOptions providerClientOptions
28− options geminiOptions
29 client *genai.Client
22 client *genai.Client
30}
23}
31
24
32type GeminiClient ProviderClient
25type GeminiClient ProviderClient
33
26
34func newGeminiClient(opts providerClientOptions) GeminiClient {
27func newGeminiClient(opts providerClientOptions) GeminiClient {
35− geminiOpts := geminiOptions{}
28+ client, err := createGeminiClient(opts)
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})
41 if err != nil {
29 if err != nil {
42 logging.Error("Failed to create Gemini client", "error", err)
30 logging.Error("Failed to create Gemini client", "error", err)
43 return nil
31 return nil
@@ -45,11 +33,18 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
45
33
46 return &geminiClient{
34 return &geminiClient{
47 providerOptions: opts,
35 providerOptions: opts,
48− options: geminiOpts,
49 client: client,
36 client: client,
50 }
37 }
51}
38}
52
39
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+
53func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
48func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
54 var history []*genai.Content
49 var history []*genai.Content
55 for _, msg := range messages {
50 for _, msg := range messages {
@@ -168,17 +163,26 @@ func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishRea
168func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
163func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
169 // Convert messages
164 // Convert messages
170 geminiMessages := g.convertMessages(messages)
165 geminiMessages := g.convertMessages(messages)
171−
166+ model := g.providerOptions.model(g.providerOptions.modelType)
172 cfg := config.Get()
167 cfg := config.Get()
173− if cfg.Debug {
168+ if cfg.Options.Debug {
174 jsonData, _ := json.Marshal(geminiMessages)
169 jsonData, _ := json.Marshal(geminiMessages)
175 logging.Debug("Prepared messages", "messages", string(jsonData))
170 logging.Debug("Prepared messages", "messages", string(jsonData))
176 }
171 }
177
172
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+ }
178 history := geminiMessages[:len(geminiMessages)-1] // All but last message
182 history := geminiMessages[:len(geminiMessages)-1] // All but last message
179 lastMsg := geminiMessages[len(geminiMessages)-1]
183 lastMsg := geminiMessages[len(geminiMessages)-1]
180 config := &genai.GenerateContentConfig{
184 config := &genai.GenerateContentConfig{
181− MaxOutputTokens: int32(g.providerOptions.maxTokens),
185+ MaxOutputTokens: int32(maxTokens),
182 SystemInstruction: &genai.Content{
186 SystemInstruction: &genai.Content{
183 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
187 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
184 },
188 },
@@ -186,7 +190,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
186 if len(tools) > 0 {
190 if len(tools) > 0 {
187 config.Tools = g.convertTools(tools)
191 config.Tools = g.convertTools(tools)
188 }
192 }
189− chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
193+ chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
190
194
191 attempts := 0
195 attempts := 0
192 for {
196 for {
@@ -257,16 +261,30 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
257 // Convert messages
261 // Convert messages
258 geminiMessages := g.convertMessages(messages)
262 geminiMessages := g.convertMessages(messages)
259
263
264+ model := g.providerOptions.model(g.providerOptions.modelType)
260 cfg := config.Get()
265 cfg := config.Get()
261− if cfg.Debug {
266+ if cfg.Options.Debug {
262 jsonData, _ := json.Marshal(geminiMessages)
267 jsonData, _ := json.Marshal(geminiMessages)
263 logging.Debug("Prepared messages", "messages", string(jsonData))
268 logging.Debug("Prepared messages", "messages", string(jsonData))
264 }
269 }
265
270
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+ }
266 history := geminiMessages[:len(geminiMessages)-1] // All but last message
284 history := geminiMessages[:len(geminiMessages)-1] // All but last message
267 lastMsg := geminiMessages[len(geminiMessages)-1]
285 lastMsg := geminiMessages[len(geminiMessages)-1]
268 config := &genai.GenerateContentConfig{
286 config := &genai.GenerateContentConfig{
269− MaxOutputTokens: int32(g.providerOptions.maxTokens),
287+ MaxOutputTokens: int32(maxTokens),
270 SystemInstruction: &genai.Content{
288 SystemInstruction: &genai.Content{
271 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
289 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
272 },
290 },
@@ -274,7 +292,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
274 if len(tools) > 0 {
292 if len(tools) > 0 {
275 config.Tools = g.convertTools(tools)
293 config.Tools = g.convertTools(tools)
276 }
294 }
277− chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history)
295+ chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
278
296
279 attempts := 0
297 attempts := 0
280 eventChan := make(chan ProviderEvent)
298 eventChan := make(chan ProviderEvent)
@@ -404,6 +422,19 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error)
404 errMsg := err.Error()
422 errMsg := err.Error()
405 isRateLimit := contains(errMsg, "rate limit", "quota exceeded", "too many requests")
423 isRateLimit := contains(errMsg, "rate limit", "quota exceeded", "too many requests")
406
424
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+
407 // Check for common rate limit error messages
438 // Check for common rate limit error messages
408
439
409 if !isRateLimit {
440 if !isRateLimit {
@@ -418,27 +449,6 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error)
418 return true, int64(retryMs), nil
449 return true, int64(retryMs), nil
419}
450}
420
451
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−
442func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
452func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
443 if resp == nil || resp.UsageMetadata == nil {
453 if resp == nil || resp.UsageMetadata == nil {
444 return TokenUsage{}
454 return TokenUsage{}
@@ -452,10 +462,8 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
452 }
462 }
453}
463}
454
464
455−func WithGeminiDisableCache() GeminiOption {
465+func (g *geminiClient) Model() config.Model {
456− return func(options *geminiOptions) {
466+ return g.providerOptions.model(g.providerOptions.modelType)
457− options.disableCache = true
458− }
459}
467}
460
468
461// Helper functions
469// Helper functions
internal/llm/provider/openai.go+58−68
@@ -9,7 +9,7 @@ import (
9 "time"
9 "time"
10
10
11 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/llm/tools"
13 "github.com/charmbracelet/crush/internal/llm/tools"
14 "github.com/charmbracelet/crush/internal/logging"
14 "github.com/charmbracelet/crush/internal/logging"
15 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/message"
@@ -18,51 +18,36 @@ import (
18 "github.com/openai/openai-go/shared"
18 "github.com/openai/openai-go/shared"
19)
19)
20
20
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−
30type openaiClient struct {
21type openaiClient struct {
31 providerOptions providerClientOptions
22 providerOptions providerClientOptions
32− options openaiOptions
33 client openai.Client
23 client openai.Client
34}
24}
35
25
36type OpenAIClient ProviderClient
26type OpenAIClient ProviderClient
37
27
38func newOpenAIClient(opts providerClientOptions) OpenAIClient {
28func newOpenAIClient(opts providerClientOptions) OpenAIClient {
39− openaiOpts := openaiOptions{
29+ return &openaiClient{
40− reasoningEffort: "medium",
30+ providerOptions: opts,
41− }
31+ client: createOpenAIClient(opts),
42− for _, o := range opts.openaiOptions {
43− o(&openaiOpts)
44 }
32 }
33+}
45
34
35+func createOpenAIClient(opts providerClientOptions) openai.Client {
46 openaiClientOptions := []option.RequestOption{}
36 openaiClientOptions := []option.RequestOption{}
47 if opts.apiKey != "" {
37 if opts.apiKey != "" {
48 openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
38 openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
49 }
39 }
50− if openaiOpts.baseURL != "" {
40+ if opts.baseURL != "" {
51− openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
41+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(opts.baseURL))
52 }
42 }
53
43
54− if openaiOpts.extraHeaders != nil {
44+ if opts.extraHeaders != nil {
55− for key, value := range openaiOpts.extraHeaders {
45+ for key, value := range opts.extraHeaders {
56 openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
46 openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
57 }
47 }
58 }
48 }
59
49
60− client := openai.NewClient(openaiClientOptions...)
50+ return openai.NewClient(openaiClientOptions...)
61− return &openaiClient{
62− providerOptions: opts,
63− options: openaiOpts,
64− client: client,
65− }
66}
51}
67
52
68func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
53func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
@@ -76,7 +61,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
76 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
61 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
77 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
62 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
78 for _, binaryContent := range msg.BinaryContent() {
63 for _, binaryContent := range msg.BinaryContent() {
79− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
64+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(provider.InferenceProviderOpenAI)}
80 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
65 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
81
66
82 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
67 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
@@ -160,15 +145,37 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
160}
145}
161
146
162func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
147func (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+
163 params := openai.ChatCompletionNewParams{
161 params := openai.ChatCompletionNewParams{
164− Model: openai.ChatModel(o.providerOptions.model.APIModel),
162+ Model: openai.ChatModel(model.ID),
165 Messages: messages,
163 Messages: messages,
166 Tools: tools,
164 Tools: tools,
167 }
165 }
168
166
169− if o.providerOptions.model.CanReason {
167+ maxTokens := model.DefaultMaxTokens
170− params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
168+ if modelConfig.MaxTokens > 0 {
171− switch o.options.reasoningEffort {
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 {
172 case "low":
179 case "low":
173 params.ReasoningEffort = shared.ReasoningEffortLow
180 params.ReasoningEffort = shared.ReasoningEffortLow
174 case "medium":
181 case "medium":
@@ -176,10 +183,10 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
176 case "high":
183 case "high":
177 params.ReasoningEffort = shared.ReasoningEffortHigh
184 params.ReasoningEffort = shared.ReasoningEffortHigh
178 default:
185 default:
179− params.ReasoningEffort = shared.ReasoningEffortMedium
186+ params.ReasoningEffort = shared.ReasoningEffort(reasoningEffort)
180 }
187 }
181 } else {
188 } else {
182− params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
189+ params.MaxTokens = openai.Int(maxTokens)
183 }
190 }
184
191
185 return params
192 return params
@@ -188,7 +195,7 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
188func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
195func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
189 params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
196 params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
190 cfg := config.Get()
197 cfg := config.Get()
191− if cfg.Debug {
198+ if cfg.Options.Debug {
192 jsonData, _ := json.Marshal(params)
199 jsonData, _ := json.Marshal(params)
193 logging.Debug("Prepared messages", "messages", string(jsonData))
200 logging.Debug("Prepared messages", "messages", string(jsonData))
194 }
201 }
@@ -245,7 +252,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
245 }
252 }
246
253
247 cfg := config.Get()
254 cfg := config.Get()
248− if cfg.Debug {
255+ if cfg.Options.Debug {
249 jsonData, _ := json.Marshal(params)
256 jsonData, _ := json.Marshal(params)
250 logging.Debug("Prepared messages", "messages", string(jsonData))
257 logging.Debug("Prepared messages", "messages", string(jsonData))
251 }
258 }
@@ -335,12 +342,22 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
335}
342}
336
343
337func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
344func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
338− var apierr *openai.Error
345+ var apiErr *openai.Error
339− if !errors.As(err, &apierr) {
346+ if !errors.As(err, &apiErr) {
340 return false, 0, err
347 return false, 0, err
341 }
348 }
342
349
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 {
344 return false, 0, err
361 return false, 0, err
345 }
362 }
346
363
@@ -349,7 +366,7 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
349 }
366 }
350
367
351 retryMs := 0
368 retryMs := 0
352− retryAfterValues := apierr.Response.Header.Values("Retry-After")
369+ retryAfterValues := apiErr.Response.Header.Values("Retry-After")
353
370
354 backoffMs := 2000 * (1 << (attempts - 1))
371 backoffMs := 2000 * (1 << (attempts - 1))
355 jitterMs := int(float64(backoffMs) * 0.2)
372 jitterMs := int(float64(backoffMs) * 0.2)
@@ -393,33 +410,6 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
393 }
410 }
394}
411}
395
412
396−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
413+func (a *openaiClient) Model() config.Model {
397− return func(options *openaiOptions) {
414+ return a.providerOptions.model(a.providerOptions.modelType)
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− }
425}
415}
internal/llm/provider/provider.go+74−115
@@ -3,9 +3,9 @@ package provider
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6− "os"
7
6
8− "github.com/charmbracelet/crush/internal/llm/models"
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/fur/provider"
9 "github.com/charmbracelet/crush/internal/llm/tools"
9 "github.com/charmbracelet/crush/internal/llm/tools"
10 "github.com/charmbracelet/crush/internal/message"
10 "github.com/charmbracelet/crush/internal/message"
11)
11)
@@ -55,19 +55,20 @@ type Provider interface {
55
55
56 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
56 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
57
57
58− Model() models.Model
58+ Model() config.Model
59}
59}
60
60
61type providerClientOptions struct {
61type providerClientOptions struct {
62+ baseURL string
63+ config config.ProviderConfig
62 apiKey string
64 apiKey string
63− model models.Model
65+ modelType config.ModelType
64− maxTokens int64
66+ model func(config.ModelType) config.Model
67+ disableCache bool
65 systemMessage string
68 systemMessage string
66−
69+ maxTokens int64
67− anthropicOptions []AnthropicOption
70+ extraHeaders map[string]string
68− openaiOptions []OpenAIOption
71+ extraParams map[string]string
69− geminiOptions []GeminiOption
70− bedrockOptions []BedrockOption
71}
72}
72
73
73type ProviderClientOption func(*providerClientOptions)
74type ProviderClientOption func(*providerClientOptions)
@@ -75,6 +76,8 @@ type ProviderClientOption func(*providerClientOptions)
75type ProviderClient interface {
76type ProviderClient interface {
76 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
77 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
77 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
78 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
79+
80+ Model() config.Model
78}
81}
79
82
80type baseProvider[C ProviderClient] struct {
83type baseProvider[C ProviderClient] struct {
@@ -82,85 +85,6 @@ type baseProvider[C ProviderClient] struct {
82 client C
85 client C
83}
86}
84
87
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−
164func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
88func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
165 for _, msg := range messages {
89 for _, msg := range messages {
166 // The message has no content
90 // The message has no content
@@ -177,30 +101,24 @@ func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.M
177 return p.client.send(ctx, messages, tools)
101 return p.client.send(ctx, messages, tools)
178}
102}
179
103
180−func (p *baseProvider[C]) Model() models.Model {
181− return p.options.model
182−}
183−
184func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
104func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
185 messages = p.cleanMessages(messages)
105 messages = p.cleanMessages(messages)
186 return p.client.stream(ctx, messages, tools)
106 return p.client.stream(ctx, messages, tools)
187}
107}
188
108
189−func WithAPIKey(apiKey string) ProviderClientOption {
109+func (p *baseProvider[C]) Model() config.Model {
190− return func(options *providerClientOptions) {
110+ return p.client.Model()
191− options.apiKey = apiKey
192− }
193}
111}
194
112
195−func WithModel(model models.Model) ProviderClientOption {
113+func WithModel(model config.ModelType) ProviderClientOption {
196 return func(options *providerClientOptions) {
114 return func(options *providerClientOptions) {
197− options.model = model
115+ options.modelType = model
198 }
116 }
199}
117}
200
118
201−func WithMaxTokens(maxTokens int64) ProviderClientOption {
119+func WithDisableCache(disableCache bool) ProviderClientOption {
202 return func(options *providerClientOptions) {
120 return func(options *providerClientOptions) {
203− options.maxTokens = maxTokens
121+ options.disableCache = disableCache
204 }
122 }
205}
123}
206
124
@@ -210,26 +128,67 @@ func WithSystemMessage(systemMessage string) ProviderClientOption {
210 }
128 }
211}
129}
212
130
213−func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
131+func WithMaxTokens(maxTokens int64) ProviderClientOption {
214 return func(options *providerClientOptions) {
132 return func(options *providerClientOptions) {
215− options.anthropicOptions = anthropicOptions
133+ options.maxTokens = maxTokens
216 }
134 }
217}
135}
218
136
219−func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
137+func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
220− return func(options *providerClientOptions) {
138+ resolvedAPIKey, err := config.ResolveAPIKey(cfg.APIKey)
221− options.openaiOptions = openaiOptions
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
222 }
141 }
223−}
224
142
225−func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
143+ clientOptions := providerClientOptions{
226− return func(options *providerClientOptions) {
144+ baseURL: cfg.BaseURL,
227− options.geminiOptions = geminiOptions
145+ config: cfg,
146+ apiKey: resolvedAPIKey,
147+ extraHeaders: cfg.ExtraHeaders,
148+ model: func(tp config.ModelType) config.Model {
149+ return config.GetModel(tp)
150+ },
228 }
151 }
229−}
152+ for _, o := range opts {
230−
153+ o(&clientOptions)
231−func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
154+ }
232− return func(options *providerClientOptions) {
155+ switch cfg.ProviderType {
233− options.bedrockOptions = bedrockOptions
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
234 }
192 }
193+ return nil, fmt.Errorf("provider not supported: %s", cfg.ProviderType)
235}
194}
internal/llm/provider/vertexai.go+4−9
@@ -2,7 +2,6 @@ package provider
2
2
3import (
3import (
4 "context"
4 "context"
5− "os"
6
5
7 "github.com/charmbracelet/crush/internal/logging"
6 "github.com/charmbracelet/crush/internal/logging"
8 "google.golang.org/genai"
7 "google.golang.org/genai"
@@ -11,14 +10,11 @@ import (
11type VertexAIClient ProviderClient
10type VertexAIClient ProviderClient
12
11
13func newVertexAIClient(opts providerClientOptions) VertexAIClient {
12func newVertexAIClient(opts providerClientOptions) VertexAIClient {
14− geminiOpts := geminiOptions{}
13+ project := opts.extraHeaders["project"]
15− for _, o := range opts.geminiOptions {
14+ location := opts.extraHeaders["location"]
16− o(&geminiOpts)
17− }
18−
19 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
15 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
20− Project: os.Getenv("VERTEXAI_PROJECT"),
16+ Project: project,
21− Location: os.Getenv("VERTEXAI_LOCATION"),
17+ Location: location,
22 Backend: genai.BackendVertexAI,
18 Backend: genai.BackendVertexAI,
23 })
19 })
24 if err != nil {
20 if err != nil {
@@ -28,7 +24,6 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
28
24
29 return &geminiClient{
25 return &geminiClient{
30 providerOptions: opts,
26 providerOptions: opts,
31− options: geminiOpts,
32 client: client,
27 client: client,
33 }
28 }
34}
29}
internal/llm/tools/bash.go+4
@@ -250,6 +250,10 @@ func NewBashTool(permission permission.Service) BaseTool {
250 }
250 }
251}
251}
252
252
253+func (b *bashTool) Name() string {
254+ return BashToolName
255+}
256+
253func (b *bashTool) Info() ToolInfo {
257func (b *bashTool) Info() ToolInfo {
254 return ToolInfo{
258 return ToolInfo{
255 Name: BashToolName,
259 Name: BashToolName,
internal/llm/tools/diagnostics.go+4
@@ -51,6 +51,10 @@ func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
51 }
51 }
52}
52}
53
53
54+func (b *diagnosticsTool) Name() string {
55+ return DiagnosticsToolName
56+}
57+
54func (b *diagnosticsTool) Info() ToolInfo {
58func (b *diagnosticsTool) Info() ToolInfo {
55 return ToolInfo{
59 return ToolInfo{
56 Name: DiagnosticsToolName,
60 Name: DiagnosticsToolName,
internal/llm/tools/edit.go+4
@@ -106,6 +106,10 @@ func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Servi
106 }
106 }
107}
107}
108
108
109+func (e *editTool) Name() string {
110+ return EditToolName
111+}
112+
109func (e *editTool) Info() ToolInfo {
113func (e *editTool) Info() ToolInfo {
110 return ToolInfo{
114 return ToolInfo{
111 Name: EditToolName,
115 Name: EditToolName,
internal/llm/tools/fetch.go+4
@@ -79,6 +79,10 @@ func NewFetchTool(permissions permission.Service) BaseTool {
79 }
79 }
80}
80}
81
81
82+func (t *fetchTool) Name() string {
83+ return FetchToolName
84+}
85+
82func (t *fetchTool) Info() ToolInfo {
86func (t *fetchTool) Info() ToolInfo {
83 return ToolInfo{
87 return ToolInfo{
84 Name: FetchToolName,
88 Name: FetchToolName,
internal/llm/tools/glob.go+4
@@ -74,6 +74,10 @@ func NewGlobTool() BaseTool {
74 return &globTool{}
74 return &globTool{}
75}
75}
76
76
77+func (g *globTool) Name() string {
78+ return GlobToolName
79+}
80+
77func (g *globTool) Info() ToolInfo {
81func (g *globTool) Info() ToolInfo {
78 return ToolInfo{
82 return ToolInfo{
79 Name: GlobToolName,
83 Name: GlobToolName,
internal/llm/tools/grep.go+4
@@ -140,6 +140,10 @@ func NewGrepTool() BaseTool {
140 return &grepTool{}
140 return &grepTool{}
141}
141}
142
142
143+func (g *grepTool) Name() string {
144+ return GrepToolName
145+}
146+
143func (g *grepTool) Info() ToolInfo {
147func (g *grepTool) Info() ToolInfo {
144 return ToolInfo{
148 return ToolInfo{
145 Name: GrepToolName,
149 Name: GrepToolName,
internal/llm/tools/ls.go+4
@@ -74,6 +74,10 @@ func NewLsTool() BaseTool {
74 return &lsTool{}
74 return &lsTool{}
75}
75}
76
76
77+func (l *lsTool) Name() string {
78+ return LSToolName
79+}
80+
77func (l *lsTool) Info() ToolInfo {
81func (l *lsTool) Info() ToolInfo {
78 return ToolInfo{
82 return ToolInfo{
79 Name: LSToolName,
83 Name: LSToolName,
internal/llm/tools/sourcegraph.go+4
@@ -138,6 +138,10 @@ func NewSourcegraphTool() BaseTool {
138 }
138 }
139}
139}
140
140
141+func (t *sourcegraphTool) Name() string {
142+ return SourcegraphToolName
143+}
144+
141func (t *sourcegraphTool) Info() ToolInfo {
145func (t *sourcegraphTool) Info() ToolInfo {
142 return ToolInfo{
146 return ToolInfo{
143 Name: SourcegraphToolName,
147 Name: SourcegraphToolName,
internal/llm/tools/tools.go+1
@@ -68,6 +68,7 @@ type ToolCall struct {
68
68
69type BaseTool interface {
69type BaseTool interface {
70 Info() ToolInfo
70 Info() ToolInfo
71+ Name() string
71 Run(ctx context.Context, params ToolCall) (ToolResponse, error)
72 Run(ctx context.Context, params ToolCall) (ToolResponse, error)
72}
73}
73
74
internal/llm/tools/view.go+4
@@ -77,6 +77,10 @@ func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
77 }
77 }
78}
78}
79
79
80+func (v *viewTool) Name() string {
81+ return ViewToolName
82+}
83+
80func (v *viewTool) Info() ToolInfo {
84func (v *viewTool) Info() ToolInfo {
81 return ToolInfo{
85 return ToolInfo{
82 Name: ViewToolName,
86 Name: ViewToolName,
internal/llm/tools/write.go+4
@@ -84,6 +84,10 @@ func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Serv
84 }
84 }
85}
85}
86
86
87+func (w *writeTool) Name() string {
88+ return WriteToolName
89+}
90+
87func (w *writeTool) Info() ToolInfo {
91func (w *writeTool) Info() ToolInfo {
88 return ToolInfo{
92 return ToolInfo{
89 Name: WriteToolName,
93 Name: WriteToolName,
internal/logging/logger.go+133−2
@@ -4,16 +4,35 @@ import (
4 "fmt"
4 "fmt"
5 "log/slog"
5 "log/slog"
6 "os"
6 "os"
7+
8+ // "path/filepath"
9+ "encoding/json"
10+ "runtime"
7 "runtime/debug"
11 "runtime/debug"
12+ "sync"
8 "time"
13 "time"
9)
14)
10
15
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+
11func Info(msg string, args ...any) {
27func Info(msg string, args ...any) {
12− slog.Info(msg, args...)
28+ source := getCaller()
29+ slog.Info(msg, append([]any{"source", source}, args...)...)
13}
30}
14
31
15func Debug(msg string, args ...any) {
32func 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...)...)
17}
36}
18
37
19func Warn(msg string, args ...any) {
38func Warn(msg string, args ...any) {
@@ -76,3 +95,115 @@ func RecoverPanic(name string, cleanup func()) {
76 }
95 }
77 }
96 }
78}
97}
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{}
45
45
46func (w *writer) Write(p []byte) (int, error) {
46func (w *writer) Write(p []byte) (int, error) {
47 d := logfmt.NewDecoder(bytes.NewReader(p))
47 d := logfmt.NewDecoder(bytes.NewReader(p))
48+
48 for d.ScanRecord() {
49 for d.ScanRecord() {
49 msg := LogMessage{
50 msg := LogMessage{
50 ID: fmt.Sprintf("%d", time.Now().UnixNano()),
51 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
96 go func() {
96 go func() {
97 scanner := bufio.NewScanner(stderr)
97 scanner := bufio.NewScanner(stderr)
98 for scanner.Scan() {
98 for scanner.Scan() {
99− fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
99+ logging.Error("LSP Server", "err", scanner.Text())
100 }
100 }
101 if err := scanner.Err(); err != nil {
101 if err := scanner.Err(); err != nil {
102− fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
102+ logging.Error("Error reading", "err", err)
103 }
103 }
104 }()
104 }()
105
105
@@ -286,7 +286,7 @@ func (c *Client) SetServerState(state ServerState) {
286// WaitForServerReady waits for the server to be ready by polling the server
286// WaitForServerReady waits for the server to be ready by polling the server
287// with a simple request until it responds successfully or times out
287// with a simple request until it responds successfully or times out
288func (c *Client) WaitForServerReady(ctx context.Context) error {
288func (c *Client) WaitForServerReady(ctx context.Context) error {
289− cnf := config.Get()
289+ cfg := config.Get()
290
290
291 // Set initial state
291 // Set initial state
292 c.SetServerState(StateStarting)
292 c.SetServerState(StateStarting)
@@ -299,7 +299,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
299 ticker := time.NewTicker(500 * time.Millisecond)
299 ticker := time.NewTicker(500 * time.Millisecond)
300 defer ticker.Stop()
300 defer ticker.Stop()
301
301
302− if cnf.DebugLSP {
302+ if cfg.Options.DebugLSP {
303 logging.Debug("Waiting for LSP server to be ready...")
303 logging.Debug("Waiting for LSP server to be ready...")
304 }
304 }
305
305
@@ -308,7 +308,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
308
308
309 // For TypeScript-like servers, we need to open some key files first
309 // For TypeScript-like servers, we need to open some key files first
310 if serverType == ServerTypeTypeScript {
310 if serverType == ServerTypeTypeScript {
311− if cnf.DebugLSP {
311+ if cfg.Options.DebugLSP {
312 logging.Debug("TypeScript-like server detected, opening key configuration files")
312 logging.Debug("TypeScript-like server detected, opening key configuration files")
313 }
313 }
314 c.openKeyConfigFiles(ctx)
314 c.openKeyConfigFiles(ctx)
@@ -325,7 +325,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
325 if err == nil {
325 if err == nil {
326 // Server responded successfully
326 // Server responded successfully
327 c.SetServerState(StateReady)
327 c.SetServerState(StateReady)
328− if cnf.DebugLSP {
328+ if cfg.Options.DebugLSP {
329 logging.Debug("LSP server is ready")
329 logging.Debug("LSP server is ready")
330 }
330 }
331 return nil
331 return nil
@@ -333,7 +333,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
333 logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
333 logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
334 }
334 }
335
335
336− if cnf.DebugLSP {
336+ if cfg.Options.DebugLSP {
337 logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
337 logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
338 }
338 }
339 }
339 }
@@ -496,7 +496,7 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
496
496
497// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
497// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
498func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
498func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
499− cnf := config.Get()
499+ cfg := config.Get()
500 filesOpened := 0
500 filesOpened := 0
501 maxFilesToOpen := 5 // Limit to a reasonable number of files
501 maxFilesToOpen := 5 // Limit to a reasonable number of files
502
502
@@ -526,7 +526,7 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
526 // Try to open the file
526 // Try to open the file
527 if err := c.OpenFile(ctx, path); err == nil {
527 if err := c.OpenFile(ctx, path); err == nil {
528 filesOpened++
528 filesOpened++
529− if cnf.DebugLSP {
529+ if cfg.Options.DebugLSP {
530 logging.Debug("Opened TypeScript file for initialization", "file", path)
530 logging.Debug("Opened TypeScript file for initialization", "file", path)
531 }
531 }
532 }
532 }
@@ -535,11 +535,11 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
535 return nil
535 return nil
536 })
536 })
537
537
538− if err != nil && cnf.DebugLSP {
538+ if err != nil && cfg.Options.DebugLSP {
539 logging.Debug("Error walking directory for TypeScript files", "error", err)
539 logging.Debug("Error walking directory for TypeScript files", "error", err)
540 }
540 }
541
541
542− if cnf.DebugLSP {
542+ if cfg.Options.DebugLSP {
543 logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
543 logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
544 }
544 }
545}
545}
@@ -664,7 +664,7 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
664}
664}
665
665
666func (c *Client) CloseFile(ctx context.Context, filepath string) error {
666func (c *Client) CloseFile(ctx context.Context, filepath string) error {
667− cnf := config.Get()
667+ cfg := config.Get()
668 uri := string(protocol.URIFromPath(filepath))
668 uri := string(protocol.URIFromPath(filepath))
669
669
670 c.openFilesMu.Lock()
670 c.openFilesMu.Lock()
@@ -680,7 +680,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
680 },
680 },
681 }
681 }
682
682
683− if cnf.DebugLSP {
683+ if cfg.Options.DebugLSP {
684 logging.Debug("Closing file", "file", filepath)
684 logging.Debug("Closing file", "file", filepath)
685 }
685 }
686 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
686 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
@@ -704,7 +704,7 @@ func (c *Client) IsFileOpen(filepath string) bool {
704
704
705// CloseAllFiles closes all currently open files
705// CloseAllFiles closes all currently open files
706func (c *Client) CloseAllFiles(ctx context.Context) {
706func (c *Client) CloseAllFiles(ctx context.Context) {
707− cnf := config.Get()
707+ cfg := config.Get()
708 c.openFilesMu.Lock()
708 c.openFilesMu.Lock()
709 filesToClose := make([]string, 0, len(c.openFiles))
709 filesToClose := make([]string, 0, len(c.openFiles))
710
710
@@ -719,12 +719,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
719 // Then close them all
719 // Then close them all
720 for _, filePath := range filesToClose {
720 for _, filePath := range filesToClose {
721 err := c.CloseFile(ctx, filePath)
721 err := c.CloseFile(ctx, filePath)
722− if err != nil && cnf.DebugLSP {
722+ if err != nil && cfg.Options.DebugLSP {
723 logging.Warn("Error closing file", "file", filePath, "error", err)
723 logging.Warn("Error closing file", "file", filePath, "error", err)
724 }
724 }
725 }
725 }
726
726
727− if cnf.DebugLSP {
727+ if cfg.Options.DebugLSP {
728 logging.Debug("Closed all files", "files", filesToClose)
728 logging.Debug("Closed all files", "files", filesToClose)
729 }
729 }
730}
730}
internal/lsp/handlers.go+2−2
@@ -82,13 +82,13 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche
82// Notifications
82// Notifications
83
83
84func HandleServerMessage(params json.RawMessage) {
84func HandleServerMessage(params json.RawMessage) {
85− cnf := config.Get()
85+ cfg := config.Get()
86 var msg struct {
86 var msg struct {
87 Type int `json:"type"`
87 Type int `json:"type"`
88 Message string `json:"message"`
88 Message string `json:"message"`
89 }
89 }
90 if err := json.Unmarshal(params, &msg); err == nil {
90 if err := json.Unmarshal(params, &msg); err == nil {
91− if cnf.DebugLSP {
91+ if cfg.Options.DebugLSP {
92 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
92 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
93 }
93 }
94 }
94 }
internal/lsp/transport.go+19−19
@@ -18,9 +18,9 @@ func WriteMessage(w io.Writer, msg *Message) error {
18 if err != nil {
18 if err != nil {
19 return fmt.Errorf("failed to marshal message: %w", err)
19 return fmt.Errorf("failed to marshal message: %w", err)
20 }
20 }
21− cnf := config.Get()
21+ cfg := config.Get()
22
22
23− if cnf.DebugLSP {
23+ if cfg.Options.DebugLSP {
24 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
24 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
25 }
25 }
26
26
@@ -39,7 +39,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
39
39
40// ReadMessage reads a single LSP message from the given reader
40// ReadMessage reads a single LSP message from the given reader
41func ReadMessage(r *bufio.Reader) (*Message, error) {
41func ReadMessage(r *bufio.Reader) (*Message, error) {
42− cnf := config.Get()
42+ cfg := config.Get()
43 // Read headers
43 // Read headers
44 var contentLength int
44 var contentLength int
45 for {
45 for {
@@ -49,7 +49,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
49 }
49 }
50 line = strings.TrimSpace(line)
50 line = strings.TrimSpace(line)
51
51
52− if cnf.DebugLSP {
52+ if cfg.Options.DebugLSP {
53 logging.Debug("Received header", "line", line)
53 logging.Debug("Received header", "line", line)
54 }
54 }
55
55
@@ -65,7 +65,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
65 }
65 }
66 }
66 }
67
67
68− if cnf.DebugLSP {
68+ if cfg.Options.DebugLSP {
69 logging.Debug("Content-Length", "length", contentLength)
69 logging.Debug("Content-Length", "length", contentLength)
70 }
70 }
71
71
@@ -76,7 +76,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
76 return nil, fmt.Errorf("failed to read content: %w", err)
76 return nil, fmt.Errorf("failed to read content: %w", err)
77 }
77 }
78
78
79− if cnf.DebugLSP {
79+ if cfg.Options.DebugLSP {
80 logging.Debug("Received content", "content", string(content))
80 logging.Debug("Received content", "content", string(content))
81 }
81 }
82
82
@@ -91,11 +91,11 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
91
91
92// handleMessages reads and dispatches messages in a loop
92// handleMessages reads and dispatches messages in a loop
93func (c *Client) handleMessages() {
93func (c *Client) handleMessages() {
94− cnf := config.Get()
94+ cfg := config.Get()
95 for {
95 for {
96 msg, err := ReadMessage(c.stdout)
96 msg, err := ReadMessage(c.stdout)
97 if err != nil {
97 if err != nil {
98− if cnf.DebugLSP {
98+ if cfg.Options.DebugLSP {
99 logging.Error("Error reading message", "error", err)
99 logging.Error("Error reading message", "error", err)
100 }
100 }
101 return
101 return
@@ -103,7 +103,7 @@ func (c *Client) handleMessages() {
103
103
104 // Handle server->client request (has both Method and ID)
104 // Handle server->client request (has both Method and ID)
105 if msg.Method != "" && msg.ID != 0 {
105 if msg.Method != "" && msg.ID != 0 {
106− if cnf.DebugLSP {
106+ if cfg.Options.DebugLSP {
107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
108 }
108 }
109
109
@@ -157,11 +157,11 @@ func (c *Client) handleMessages() {
157 c.notificationMu.RUnlock()
157 c.notificationMu.RUnlock()
158
158
159 if ok {
159 if ok {
160− if cnf.DebugLSP {
160+ if cfg.Options.DebugLSP {
161 logging.Debug("Handling notification", "method", msg.Method)
161 logging.Debug("Handling notification", "method", msg.Method)
162 }
162 }
163 go handler(msg.Params)
163 go handler(msg.Params)
164− } else if cnf.DebugLSP {
164+ } else if cfg.Options.DebugLSP {
165 logging.Debug("No handler for notification", "method", msg.Method)
165 logging.Debug("No handler for notification", "method", msg.Method)
166 }
166 }
167 continue
167 continue
@@ -174,12 +174,12 @@ func (c *Client) handleMessages() {
174 c.handlersMu.RUnlock()
174 c.handlersMu.RUnlock()
175
175
176 if ok {
176 if ok {
177− if cnf.DebugLSP {
177+ if cfg.Options.DebugLSP {
178 logging.Debug("Received response for request", "id", msg.ID)
178 logging.Debug("Received response for request", "id", msg.ID)
179 }
179 }
180 ch <- msg
180 ch <- msg
181 close(ch)
181 close(ch)
182− } else if cnf.DebugLSP {
182+ } else if cfg.Options.DebugLSP {
183 logging.Debug("No handler for response", "id", msg.ID)
183 logging.Debug("No handler for response", "id", msg.ID)
184 }
184 }
185 }
185 }
@@ -188,10 +188,10 @@ func (c *Client) handleMessages() {
188
188
189// Call makes a request and waits for the response
189// Call makes a request and waits for the response
190func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
190func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
191− cnf := config.Get()
191+ cfg := config.Get()
192 id := c.nextID.Add(1)
192 id := c.nextID.Add(1)
193
193
194− if cnf.DebugLSP {
194+ if cfg.Options.DebugLSP {
195 logging.Debug("Making call", "method", method, "id", id)
195 logging.Debug("Making call", "method", method, "id", id)
196 }
196 }
197
197
@@ -217,14 +217,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217 return fmt.Errorf("failed to send request: %w", err)
217 return fmt.Errorf("failed to send request: %w", err)
218 }
218 }
219
219
220− if cnf.DebugLSP {
220+ if cfg.Options.DebugLSP {
221 logging.Debug("Request sent", "method", method, "id", id)
221 logging.Debug("Request sent", "method", method, "id", id)
222 }
222 }
223
223
224 // Wait for response
224 // Wait for response
225 resp := <-ch
225 resp := <-ch
226
226
227− if cnf.DebugLSP {
227+ if cfg.Options.DebugLSP {
228 logging.Debug("Received response", "id", id)
228 logging.Debug("Received response", "id", id)
229 }
229 }
230
230
@@ -249,8 +249,8 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
249
249
250// Notify sends a notification (a request without an ID that doesn't expect a response)
250// Notify sends a notification (a request without an ID that doesn't expect a response)
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
252− cnf := config.Get()
252+ cfg := config.Get()
253− if cnf.DebugLSP {
253+ if cfg.Options.DebugLSP {
254 logging.Debug("Sending notification", "method", method)
254 logging.Debug("Sending notification", "method", method)
255 }
255 }
256
256
internal/lsp/watcher/watcher.go+25−25
@@ -43,7 +43,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
43
43
44// AddRegistrations adds file watchers to track
44// AddRegistrations adds file watchers to track
45func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
46− cnf := config.Get()
46+ cfg := config.Get()
47
47
48 logging.Debug("Adding file watcher registrations")
48 logging.Debug("Adding file watcher registrations")
49 w.registrationMu.Lock()
49 w.registrationMu.Lock()
@@ -53,7 +53,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
53 w.registrations = append(w.registrations, watchers...)
53 w.registrations = append(w.registrations, watchers...)
54
54
55 // Print detailed registration information for debugging
55 // Print detailed registration information for debugging
56− if cnf.DebugLSP {
56+ if cfg.Options.DebugLSP {
57 logging.Debug("Adding file watcher registrations",
57 logging.Debug("Adding file watcher registrations",
58 "id", id,
58 "id", id,
59 "watchers", len(watchers),
59 "watchers", len(watchers),
@@ -122,7 +122,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
122 highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
122 highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123 filesOpened += highPriorityFilesOpened
123 filesOpened += highPriorityFilesOpened
124
124
125− if cnf.DebugLSP {
125+ if cfg.Options.DebugLSP {
126 logging.Debug("Opened high-priority files",
126 logging.Debug("Opened high-priority files",
127 "count", highPriorityFilesOpened,
127 "count", highPriorityFilesOpened,
128 "serverName", serverName)
128 "serverName", serverName)
@@ -130,7 +130,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
130
130
131 // If we've already opened enough high-priority files, we might not need more
131 // If we've already opened enough high-priority files, we might not need more
132 if filesOpened >= maxFilesToOpen {
132 if filesOpened >= maxFilesToOpen {
133− if cnf.DebugLSP {
133+ if cfg.Options.DebugLSP {
134 logging.Debug("Reached file limit with high-priority files",
134 logging.Debug("Reached file limit with high-priority files",
135 "filesOpened", filesOpened,
135 "filesOpened", filesOpened,
136 "maxFiles", maxFilesToOpen)
136 "maxFiles", maxFilesToOpen)
@@ -148,7 +148,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
148 // Skip directories that should be excluded
148 // Skip directories that should be excluded
149 if d.IsDir() {
149 if d.IsDir() {
150 if path != w.workspacePath && shouldExcludeDir(path) {
150 if path != w.workspacePath && shouldExcludeDir(path) {
151− if cnf.DebugLSP {
151+ if cfg.Options.DebugLSP {
152 logging.Debug("Skipping excluded directory", "path", path)
152 logging.Debug("Skipping excluded directory", "path", path)
153 }
153 }
154 return filepath.SkipDir
154 return filepath.SkipDir
@@ -176,7 +176,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
176 })
176 })
177
177
178 elapsedTime := time.Since(startTime)
178 elapsedTime := time.Since(startTime)
179− if cnf.DebugLSP {
179+ if cfg.Options.DebugLSP {
180 logging.Debug("Limited workspace scan complete",
180 logging.Debug("Limited workspace scan complete",
181 "filesOpened", filesOpened,
181 "filesOpened", filesOpened,
182 "maxFiles", maxFilesToOpen,
182 "maxFiles", maxFilesToOpen,
@@ -185,11 +185,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
185 )
185 )
186 }
186 }
187
187
188− if err != nil && cnf.DebugLSP {
188+ if err != nil && cfg.Options.DebugLSP {
189 logging.Debug("Error scanning workspace for files to open", "error", err)
189 logging.Debug("Error scanning workspace for files to open", "error", err)
190 }
190 }
191 }()
191 }()
192− } else if cnf.DebugLSP {
192+ } else if cfg.Options.DebugLSP {
193 logging.Debug("Using on-demand file loading for server", "server", serverName)
193 logging.Debug("Using on-demand file loading for server", "server", serverName)
194 }
194 }
195}
195}
@@ -197,7 +197,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
197// openHighPriorityFiles opens important files for the server type
197// openHighPriorityFiles opens important files for the server type
198// Returns the number of files opened
198// Returns the number of files opened
199func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
199func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
200− cnf := config.Get()
200+ cfg := config.Get()
201 filesOpened := 0
201 filesOpened := 0
202
202
203 // Define patterns for high-priority files based on server type
203 // Define patterns for high-priority files based on server type
@@ -265,7 +265,7 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
265 // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
265 // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
266 matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
266 matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
267 if err != nil {
267 if err != nil {
268− if cnf.DebugLSP {
268+ if cfg.Options.DebugLSP {
269 logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
269 logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
270 }
270 }
271 continue
271 continue
@@ -299,12 +299,12 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
299 for j := i; j < end; j++ {
299 for j := i; j < end; j++ {
300 fullPath := filesToOpen[j]
300 fullPath := filesToOpen[j]
301 if err := w.client.OpenFile(ctx, fullPath); err != nil {
301 if err := w.client.OpenFile(ctx, fullPath); err != nil {
302− if cnf.DebugLSP {
302+ if cfg.Options.DebugLSP {
303 logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
303 logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
304 }
304 }
305 } else {
305 } else {
306 filesOpened++
306 filesOpened++
307− if cnf.DebugLSP {
307+ if cfg.Options.DebugLSP {
308 logging.Debug("Opened high-priority file", "path", fullPath)
308 logging.Debug("Opened high-priority file", "path", fullPath)
309 }
309 }
310 }
310 }
@@ -321,7 +321,7 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
321
321
322// WatchWorkspace sets up file watching for a workspace
322// WatchWorkspace sets up file watching for a workspace
323func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
323func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
324− cnf := config.Get()
324+ cfg := config.Get()
325 w.workspacePath = workspacePath
325 w.workspacePath = workspacePath
326
326
327 // Store the watcher in the context for later use
327 // Store the watcher in the context for later use
@@ -356,7 +356,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
356 // Skip excluded directories (except workspace root)
356 // Skip excluded directories (except workspace root)
357 if d.IsDir() && path != workspacePath {
357 if d.IsDir() && path != workspacePath {
358 if shouldExcludeDir(path) {
358 if shouldExcludeDir(path) {
359− if cnf.DebugLSP {
359+ if cfg.Options.DebugLSP {
360 logging.Debug("Skipping excluded directory", "path", path)
360 logging.Debug("Skipping excluded directory", "path", path)
361 }
361 }
362 return filepath.SkipDir
362 return filepath.SkipDir
@@ -409,7 +409,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
409 }
409 }
410
410
411 // Debug logging
411 // Debug logging
412− if cnf.DebugLSP {
412+ if cfg.Options.DebugLSP {
413 matched, kind := w.isPathWatched(event.Name)
413 matched, kind := w.isPathWatched(event.Name)
414 logging.Debug("File event",
414 logging.Debug("File event",
415 "path", event.Name,
415 "path", event.Name,
@@ -676,8 +676,8 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
676
676
677// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
677// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
678func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
678func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
679− cnf := config.Get()
679+ cfg := config.Get()
680− if cnf.DebugLSP {
680+ if cfg.Options.DebugLSP {
681 logging.Debug("Notifying file event",
681 logging.Debug("Notifying file event",
682 "uri", uri,
682 "uri", uri,
683 "changeType", changeType,
683 "changeType", changeType,
@@ -826,7 +826,7 @@ func shouldExcludeDir(dirPath string) bool {
826// shouldExcludeFile returns true if the file should be excluded from opening
826// shouldExcludeFile returns true if the file should be excluded from opening
827func shouldExcludeFile(filePath string) bool {
827func shouldExcludeFile(filePath string) bool {
828 fileName := filepath.Base(filePath)
828 fileName := filepath.Base(filePath)
829− cnf := config.Get()
829+ cfg := config.Get()
830 // Skip dot files
830 // Skip dot files
831 if strings.HasPrefix(fileName, ".") {
831 if strings.HasPrefix(fileName, ".") {
832 return true
832 return true
@@ -852,12 +852,12 @@ func shouldExcludeFile(filePath string) bool {
852
852
853 // Skip large files
853 // Skip large files
854 if info.Size() > maxFileSize {
854 if info.Size() > maxFileSize {
855− if cnf.DebugLSP {
855+ if cfg.Options.DebugLSP {
856 logging.Debug("Skipping large file",
856 logging.Debug("Skipping large file",
857 "path", filePath,
857 "path", filePath,
858 "size", info.Size(),
858 "size", info.Size(),
859 "maxSize", maxFileSize,
859 "maxSize", maxFileSize,
860− "debug", cnf.Debug,
860+ "debug", cfg.Options.Debug,
861 "sizeMB", float64(info.Size())/(1024*1024),
861 "sizeMB", float64(info.Size())/(1024*1024),
862 "maxSizeMB", float64(maxFileSize)/(1024*1024),
862 "maxSizeMB", float64(maxFileSize)/(1024*1024),
863 )
863 )
@@ -870,7 +870,7 @@ func shouldExcludeFile(filePath string) bool {
870
870
871// openMatchingFile opens a file if it matches any of the registered patterns
871// openMatchingFile opens a file if it matches any of the registered patterns
872func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
872func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
873− cnf := config.Get()
873+ cfg := config.Get()
874 // Skip directories
874 // Skip directories
875 info, err := os.Stat(path)
875 info, err := os.Stat(path)
876 if err != nil || info.IsDir() {
876 if err != nil || info.IsDir() {
@@ -890,10 +890,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
890 // Check if the file is a high-priority file that should be opened immediately
890 // Check if the file is a high-priority file that should be opened immediately
891 // This helps with project initialization for certain language servers
891 // This helps with project initialization for certain language servers
892 if isHighPriorityFile(path, serverName) {
892 if isHighPriorityFile(path, serverName) {
893− if cnf.DebugLSP {
893+ if cfg.Options.DebugLSP {
894 logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
894 logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
895 }
895 }
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 {
897 logging.Error("Error opening high-priority file", "path", path, "error", err)
897 logging.Error("Error opening high-priority file", "path", path, "error", err)
898 }
898 }
899 return
899 return
@@ -905,7 +905,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
905
905
906 // Check file size - for preloading we're more conservative
906 // Check file size - for preloading we're more conservative
907 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
907 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
908− if cnf.DebugLSP {
908+ if cfg.Options.DebugLSP {
909 logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
909 logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
910 }
910 }
911 return
911 return
@@ -937,7 +937,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
937
937
938 if shouldOpen {
938 if shouldOpen {
939 // Don't need to check if it's already open - the client.OpenFile handles that
939 // 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 {
941 logging.Error("Error opening file", "path", path, "error", err)
941 logging.Error("Error opening file", "path", path, "error", err)
942 }
942 }
943 }
943 }
internal/message/content.go+5−4
@@ -5,7 +5,7 @@ import (
5 "slices"
5 "slices"
6 "time"
6 "time"
7
7
8− "github.com/charmbracelet/crush/internal/llm/models"
8+ "github.com/charmbracelet/crush/internal/fur/provider"
9)
9)
10
10
11type MessageRole string
11type MessageRole string
@@ -71,9 +71,9 @@ type BinaryContent struct {
71 Data []byte
71 Data []byte
72}
72}
73
73
74−func (bc BinaryContent) String(provider models.ModelProvider) string {
74+func (bc BinaryContent) String(p provider.InferenceProvider) string {
75 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
75 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
76− if provider == models.ProviderOpenAI {
76+ if p == provider.InferenceProviderOpenAI {
77 return "data:" + bc.MIMEType + ";base64," + base64Encoded
77 return "data:" + bc.MIMEType + ";base64," + base64Encoded
78 }
78 }
79 return base64Encoded
79 return base64Encoded
@@ -113,7 +113,8 @@ type Message struct {
113 Role MessageRole
113 Role MessageRole
114 SessionID string
114 SessionID string
115 Parts []ContentPart
115 Parts []ContentPart
116− Model models.ModelID
116+ Model string
117+ Provider string
117 CreatedAt int64
118 CreatedAt int64
118 UpdatedAt int64
119 UpdatedAt int64
119}
120}
internal/message/message.go+7−5
@@ -8,15 +8,15 @@ import (
8 "time"
8 "time"
9
9
10 "github.com/charmbracelet/crush/internal/db"
10 "github.com/charmbracelet/crush/internal/db"
11− "github.com/charmbracelet/crush/internal/llm/models"
12 "github.com/charmbracelet/crush/internal/pubsub"
11 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/google/uuid"
12 "github.com/google/uuid"
14)
13)
15
14
16type CreateMessageParams struct {
15type CreateMessageParams struct {
17− Role MessageRole
16+ Role MessageRole
18− Parts []ContentPart
17+ Parts []ContentPart
19− Model models.ModelID
18+ Model string
19+ Provider string
20}
20}
21
21
22type Service interface {
22type Service interface {
@@ -70,6 +70,7 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
70 Role: string(params.Role),
70 Role: string(params.Role),
71 Parts: string(partsJSON),
71 Parts: string(partsJSON),
72 Model: sql.NullString{String: string(params.Model), Valid: true},
72 Model: sql.NullString{String: string(params.Model), Valid: true},
73+ Provider: sql.NullString{String: params.Provider, Valid: params.Provider != ""},
73 })
74 })
74 if err != nil {
75 if err != nil {
75 return Message{}, err
76 return Message{}, err
@@ -154,7 +155,8 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
154 SessionID: item.SessionID,
155 SessionID: item.SessionID,
155 Role: MessageRole(item.Role),
156 Role: MessageRole(item.Role),
156 Parts: parts,
157 Parts: parts,
157− Model: models.ModelID(item.Model.String),
158+ Model: item.Model.String,
159+ Provider: item.Provider.String,
158 CreatedAt: item.CreatedAt,
160 CreatedAt: item.CreatedAt,
159 UpdatedAt: item.UpdatedAt,
161 UpdatedAt: item.UpdatedAt,
160 }, nil
162 }, nil
internal/tui/components/chat/header/header.go+1−6
@@ -7,7 +7,6 @@ import (
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/fsext"
9 "github.com/charmbracelet/crush/internal/fsext"
10− "github.com/charmbracelet/crush/internal/llm/models"
11 "github.com/charmbracelet/crush/internal/lsp"
10 "github.com/charmbracelet/crush/internal/lsp"
12 "github.com/charmbracelet/crush/internal/lsp/protocol"
11 "github.com/charmbracelet/crush/internal/lsp/protocol"
13 "github.com/charmbracelet/crush/internal/pubsub"
12 "github.com/charmbracelet/crush/internal/pubsub"
@@ -112,11 +111,7 @@ func (h *header) details() string {
112 parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
111 parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
113 }
112 }
114
113
115− cfg := config.Get()
114+ model := config.GetAgentModel(config.AgentCoder)
116− agentCfg := cfg.Agents[config.AgentCoder]
117− selectedModelID := agentCfg.Model
118− model := models.SupportedModels[selectedModelID]
119−
120 percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
115 percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
121 formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
116 formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
122 parts = append(parts, formattedPercentage)
117 parts = append(parts, formattedPercentage)
internal/tui/components/chat/messages/messages.go+5−3
@@ -10,7 +10,8 @@ import (
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12
12
13− "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
16 "github.com/charmbracelet/crush/internal/tui/components/anim"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -290,8 +291,9 @@ func (m *assistantSectionModel) View() tea.View {
290 duration := finishTime.Sub(m.lastUserMessageTime)
291 duration := finishTime.Sub(m.lastUserMessageTime)
291 infoMsg := t.S().Subtle.Render(duration.String())
292 infoMsg := t.S().Subtle.Render(duration.String())
292 icon := t.S().Subtle.Render(styles.ModelIcon)
293 icon := t.S().Subtle.Render(styles.ModelIcon)
293− model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
294+ model := config.GetProviderModel(provider.InferenceProvider(m.message.Provider), m.message.Model)
294− assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
295+ modelFormatted := t.S().Muted.Render(model.Name)
296+ assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
295 return tea.NewView(
297 return tea.NewView(
296 t.S().Base.PaddingLeft(2).Render(
298 t.S().Base.PaddingLeft(2).Render(
297 core.Section(assistant, m.width-2),
299 core.Section(assistant, m.width-2),
internal/tui/components/chat/sidebar/sidebar.go+7−11
@@ -13,7 +13,6 @@ import (
13 "github.com/charmbracelet/crush/internal/diff"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
15 "github.com/charmbracelet/crush/internal/history"
16− "github.com/charmbracelet/crush/internal/llm/models"
17 "github.com/charmbracelet/crush/internal/logging"
16 "github.com/charmbracelet/crush/internal/logging"
18 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/lsp/protocol"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -76,7 +75,7 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
76}
75}
77
76
78func (m *sidebarCmp) Init() tea.Cmd {
77func (m *sidebarCmp) Init() tea.Cmd {
79− m.logo = m.logoBlock(false)
78+ m.logo = m.logoBlock()
80 m.cwd = cwd()
79 m.cwd = cwd()
81 return nil
80 return nil
82}
81}
@@ -231,9 +230,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
231
230
232func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
231func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
232 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
234− m.logo = m.logoBlock(true)
233+ m.logo = m.logoBlock()
235 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
234 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
236− m.logo = m.logoBlock(false)
235+ m.logo = m.logoBlock()
237 }
236 }
238
237
239 m.width = width
238 m.width = width
@@ -245,9 +244,9 @@ func (m *sidebarCmp) GetSize() (int, int) {
245 return m.width, m.height
244 return m.width, m.height
246}
245}
247
246
248−func (m *sidebarCmp) logoBlock(compact bool) string {
247+func (m *sidebarCmp) logoBlock() string {
249 t := styles.CurrentTheme()
248 t := styles.CurrentTheme()
250− return logo.Render(version.Version, compact, logo.Opts{
249+ return logo.Render(version.Version, true, logo.Opts{
251 FieldColor: t.Primary,
250 FieldColor: t.Primary,
252 TitleColorA: t.Secondary,
251 TitleColorA: t.Secondary,
253 TitleColorB: t.Primary,
252 TitleColorB: t.Primary,
@@ -406,7 +405,7 @@ func (m *sidebarCmp) mcpBlock() string {
406
405
407 mcpList := []string{section, ""}
406 mcpList := []string{section, ""}
408
407
409− mcp := config.Get().MCPServers
408+ mcp := config.Get().MCP
410 if len(mcp) == 0 {
409 if len(mcp) == 0 {
411 return lipgloss.JoinVertical(
410 return lipgloss.JoinVertical(
412 lipgloss.Left,
411 lipgloss.Left,
@@ -475,10 +474,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
475}
474}
476
475
477func (s *sidebarCmp) currentModelBlock() string {
476func (s *sidebarCmp) currentModelBlock() string {
478− cfg := config.Get()
477+ model := config.GetAgentModel(config.AgentCoder)
479− agentCfg := cfg.Agents[config.AgentCoder]
480− selectedModelID := agentCfg.Model
481− model := models.SupportedModels[selectedModelID]
482
478
483 t := styles.CurrentTheme()
479 t := styles.CurrentTheme()
484
480
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 {
63
63
64 // Project directory
64 // Project directory
65 sources = append(sources, commandSource{
65 sources = append(sources, commandSource{
66− path: filepath.Join(cfg.Data.Directory, "commands"),
66+ path: filepath.Join(cfg.Options.DataDirectory, "commands"),
67 prefix: ProjectCommandPrefix,
67 prefix: ProjectCommandPrefix,
68 })
68 })
69
69
internal/tui/components/dialogs/models/keys.go+8−2
@@ -8,13 +8,14 @@ type KeyMap struct {
8 Select,
8 Select,
9 Next,
9 Next,
10 Previous,
10 Previous,
11+ Tab,
11 Close key.Binding
12 Close key.Binding
12}
13}
13
14
14func DefaultKeyMap() KeyMap {
15func DefaultKeyMap() KeyMap {
15 return KeyMap{
16 return KeyMap{
16 Select: key.NewBinding(
17 Select: key.NewBinding(
17− key.WithKeys("enter", "tab", "ctrl+y"),
18+ key.WithKeys("enter", "ctrl+y"),
18 key.WithHelp("enter", "confirm"),
19 key.WithHelp("enter", "confirm"),
19 ),
20 ),
20 Next: key.NewBinding(
21 Next: key.NewBinding(
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
25 key.WithKeys("up", "ctrl+p"),
26 key.WithKeys("up", "ctrl+p"),
26 key.WithHelp("↑", "previous item"),
27 key.WithHelp("↑", "previous item"),
27 ),
28 ),
29+ Tab: key.NewBinding(
30+ key.WithKeys("tab"),
31+ key.WithHelp("tab", "toggle type"),
32+ ),
28 Close: key.NewBinding(
33 Close: key.NewBinding(
29 key.WithKeys("esc"),
34 key.WithKeys("esc"),
30 key.WithHelp("esc", "cancel"),
35 key.WithHelp("esc", "cancel"),
@@ -38,6 +43,7 @@ func (k KeyMap) KeyBindings() []key.Binding {
38 k.Select,
43 k.Select,
39 k.Next,
44 k.Next,
40 k.Previous,
45 k.Previous,
46+ k.Tab,
41 k.Close,
47 k.Close,
42 }
48 }
43}
49}
@@ -57,10 +63,10 @@ func (k KeyMap) FullHelp() [][]key.Binding {
57func (k KeyMap) ShortHelp() []key.Binding {
63func (k KeyMap) ShortHelp() []key.Binding {
58 return []key.Binding{
64 return []key.Binding{
59 key.NewBinding(
65 key.NewBinding(
60−
61 key.WithKeys("down", "up"),
66 key.WithKeys("down", "up"),
62 key.WithHelp("↑↓", "choose"),
67 key.WithHelp("↑↓", "choose"),
63 ),
68 ),
69+ k.Tab,
64 k.Select,
70 k.Select,
65 k.Close,
71 k.Close,
66 }
72 }
internal/tui/components/dialogs/models/models.go+157−99
@@ -7,7 +7,7 @@ import (
7 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/llm/models"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11 "github.com/charmbracelet/crush/internal/tui/components/completions"
11 "github.com/charmbracelet/crush/internal/tui/components/completions"
12 "github.com/charmbracelet/crush/internal/tui/components/core"
12 "github.com/charmbracelet/crush/internal/tui/components/core"
13 "github.com/charmbracelet/crush/internal/tui/components/core/list"
13 "github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -24,9 +24,15 @@ const (
24 defaultWidth = 60
24 defaultWidth = 60
25)
25)
26
26
27+const (
28+ LargeModelType int = iota
29+ SmallModelType
30+)
31+
27// ModelSelectedMsg is sent when a model is selected
32// ModelSelectedMsg is sent when a model is selected
28type ModelSelectedMsg struct {
33type ModelSelectedMsg struct {
29− Model models.Model
34+ Model config.PreferredModel
35+ ModelType config.ModelType
30}
36}
31
37
32// CloseModelDialogMsg is sent when a model is selected
38// CloseModelDialogMsg is sent when a model is selected
@@ -37,14 +43,20 @@ type ModelDialog interface {
37 dialogs.DialogModel
43 dialogs.DialogModel
38}
44}
39
45
46+type ModelOption struct {
47+ Provider provider.Provider
48+ Model provider.Model
49+}
50+
40type modelDialogCmp struct {
51type modelDialogCmp struct {
41 width int
52 width int
42− wWidth int // Width of the terminal window
53+ wWidth int
43− wHeight int // Height of the terminal window
54+ wHeight int
44
55
45 modelList list.ListModel
56 modelList list.ListModel
46 keyMap KeyMap
57 keyMap KeyMap
47 help help.Model
58 help help.Model
59+ modelType int
48}
60}
49
61
50func NewModelDialogCmp() ModelDialog {
62func NewModelDialogCmp() ModelDialog {
@@ -77,49 +89,12 @@ func NewModelDialogCmp() ModelDialog {
77 width: defaultWidth,
89 width: defaultWidth,
78 keyMap: DefaultKeyMap(),
90 keyMap: DefaultKeyMap(),
79 help: help,
91 help: help,
92+ modelType: LargeModelType,
80 }
93 }
81}
94}
82
95
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−
107func (m *modelDialogCmp) Init() tea.Cmd {
96func (m *modelDialogCmp) Init() tea.Cmd {
108− cfg := config.Get()
97+ m.SetModelType(m.modelType)
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)
123 return m.modelList.Init()
98 return m.modelList.Init()
124}
99}
125
100
@@ -128,21 +103,41 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128 case tea.WindowSizeMsg:
103 case tea.WindowSizeMsg:
129 m.wWidth = msg.Width
104 m.wWidth = msg.Width
130 m.wHeight = msg.Height
105 m.wHeight = msg.Height
106+ m.SetModelType(m.modelType)
131 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
107 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
132 case tea.KeyPressMsg:
108 case tea.KeyPressMsg:
133 switch {
109 switch {
134 case key.Matches(msg, m.keyMap.Select):
110 case key.Matches(msg, m.keyMap.Select):
135 selectedItemInx := m.modelList.SelectedIndex()
111 selectedItemInx := m.modelList.SelectedIndex()
136 if selectedItemInx == list.NoSelection {
112 if selectedItemInx == list.NoSelection {
137− return m, nil // No item selected, do nothing
113+ return m, nil
138 }
114 }
139 items := m.modelList.Items()
115 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+ }
141
124
142 return m, tea.Sequence(
125 return m, tea.Sequence(
143 util.CmdHandler(dialogs.CloseDialogMsg{}),
126 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+ }),
145 )
134 )
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+ }
146 case key.Matches(msg, m.keyMap.Close):
141 case key.Matches(msg, m.keyMap.Close):
147 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
142 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
148 default:
143 default:
@@ -157,9 +152,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157func (m *modelDialogCmp) View() tea.View {
152func (m *modelDialogCmp) View() tea.View {
158 t := styles.CurrentTheme()
153 t := styles.CurrentTheme()
159 listView := m.modelList.View()
154 listView := m.modelList.View()
155+ radio := m.modelTypeRadio()
160 content := lipgloss.JoinVertical(
156 content := lipgloss.JoinVertical(
161 lipgloss.Left,
157 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),
163 listView.String(),
159 listView.String(),
164 "",
160 "",
165 t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
161 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 {
189 return min(listHeigh, m.wHeight/2)
185 return min(listHeigh, m.wHeight/2)
190}
186}
191
187
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−
244func (m *modelDialogCmp) Position() (int, int) {
188func (m *modelDialogCmp) Position() (int, int) {
245 row := m.wHeight/4 - 2 // just a bit above the center
189 row := m.wHeight/4 - 2 // just a bit above the center
246 col := m.wWidth / 2
190 col := m.wWidth / 2
@@ -259,3 +203,117 @@ func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
259func (m *modelDialogCmp) ID() dialogs.DialogID {
203func (m *modelDialogCmp) ID() dialogs.DialogID {
260 return ModelsDialogID
204 return ModelsDialogID
261}
205}
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 {
26 TitleColorB color.Color // right gradient ramp point
26 TitleColorB color.Color // right gradient ramp point
27 CharmColor color.Color // Charm™ text color
27 CharmColor color.Color // Charm™ text color
28 VersionColor color.Color // Version text color
28 VersionColor color.Color // Version text color
29+ Width int // width of the rendered logo, used for truncation
29}
30}
30
31
31// Render renders the Crush logo. Set the argument to true to render the narrow
32// 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 {
76 }
77 }
77
78
78 // Right field.
79 // Right field.
79− const rightWidth = 15
80+ rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
80 const stepDownAt = 0
81 const stepDownAt = 0
81 rightField := new(strings.Builder)
82 rightField := new(strings.Builder)
82 for i := range fieldHeight {
83 for i := range fieldHeight {
internal/tui/components/logs/details.go−1
@@ -161,7 +161,6 @@ func (i *detailCmp) GetSize() (int, int) {
161}
161}
162
162
163func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
163func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
164− logging.Info("Setting size for detail component", "width", width, "height", height)
165 i.width = width
164 i.width = width
166 i.height = height
165 i.height = height
167 i.viewport.SetWidth(i.width - 4)
166 i.viewport.SetWidth(i.width - 4)
internal/tui/page/chat/chat.go+3−7
@@ -9,7 +9,6 @@ import (
9 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/models"
13 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
15 "github.com/charmbracelet/crush/internal/tui/components/chat"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -171,14 +170,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171 util.CmdHandler(ChatFocusedMsg{Focused: false}),
170 util.CmdHandler(ChatFocusedMsg{Focused: false}),
172 )
171 )
173 case key.Matches(msg, p.keyMap.AddAttachment):
172 case key.Matches(msg, p.keyMap.AddAttachment):
174− cfg := config.Get()
173+ model := config.GetAgentModel(config.AgentCoder)
175− agentCfg := cfg.Agents[config.AgentCoder]
174+ if model.SupportsImages {
176− selectedModelID := agentCfg.Model
177− model := models.SupportedModels[selectedModelID]
178− if model.SupportsAttachments {
179 return p, util.CmdHandler(OpenFilePickerMsg{})
175 return p, util.CmdHandler(OpenFilePickerMsg{})
180 } else {
176 } 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)
182 }
178 }
183 case key.Matches(msg, p.keyMap.Tab):
179 case key.Matches(msg, p.keyMap.Tab):
184 if p.session.ID == "" {
180 if p.session.ID == "" {
internal/tui/tui.go+29−7
@@ -69,7 +69,7 @@ func (a appModel) Init() tea.Cmd {
69
69
70 // Check if we should show the init dialog
70 // Check if we should show the init dialog
71 cmds = append(cmds, func() tea.Msg {
71 cmds = append(cmds, func() tea.Msg {
72− shouldShow, err := config.ShouldShowInitDialog()
72+ shouldShow, err := config.ProjectNeedsInitialization()
73 if err != nil {
73 if err != nil {
74 return util.InfoMsg{
74 return util.InfoMsg{
75 Type: util.InfoTypeError,
75 Type: util.InfoTypeError,
@@ -172,12 +172,19 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172
172
173 // Model Switch
173 // Model Switch
174 case models.ModelSelectedMsg:
174 case models.ModelSelectedMsg:
175− model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
175+ config.UpdatePreferredModel(msg.ModelType, msg.Model)
176− if err != nil {
176+
177− return a, util.ReportError(err)
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))
178 }
181 }
179
182
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))
181
188
182 // File Picker
189 // File Picker
183 case chat.OpenFilePickerMsg:
190 case chat.OpenFilePickerMsg:
@@ -221,8 +228,23 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
221 if err == nil {
228 if err == nil {
222 model := a.app.CoderAgent.Model()
229 model := a.app.CoderAgent.Model()
223 contextWindow := model.ContextWindow
230 contextWindow := model.ContextWindow
224− tokens := session.CompletionTokens + session.PromptTokens
231+ usedTokens := session.CompletionTokens + session.PromptTokens
225− if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
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 {
226 // Show compact confirmation dialog
248 // Show compact confirmation dialog
227 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
249 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
228 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
250 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),