on main help sign in
chore: move fur structs, small provider changes
commit399e7f7084e5828879717695ae68d5c7317a93a9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 26 Jun 2025, 14:11 (1 year ago) parent85fe73a
13 files changed+477−255
go.mod−3 internal/config_v2/config.go+270−56 internal/config_v2/config_test.go+3−1 internal/config_v2/provider.go+2−2 internal/fur/client/client.go+63 internal/fur/provider/provider.go+72 internal/llm/agent/agent.go+9−15 internal/llm/provider/anthropic.go+21−52 internal/llm/provider/bedrock.go+3−16 internal/llm/provider/gemini.go−19 internal/llm/provider/openai.go+4−43 internal/llm/provider/provider.go+30−42 internal/llm/provider/vertexai.go−6
go.mod−3
@@ -2,8 +2,6 @@ module github.com/charmbracelet/crush
22
33go 1.24.3
44
5−replace github.com/charmbracelet/fur => ../fur
6−
75require (
86 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
97 github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -17,7 +15,6 @@ require (
1715 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
1816 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94
1917 github.com/charmbracelet/fang v0.1.0
20− github.com/charmbracelet/fur v0.0.0-00010101000000-000000000000
2118 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
2219 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
2320 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
internal/config_v2/config.go+270−56
@@ -10,8 +10,8 @@ import (
1010 "strings"
1111 "sync"
1212
13+ "github.com/charmbracelet/crush/internal/fur/provider"
1314 "github.com/charmbracelet/crush/internal/logging"
14− "github.com/charmbracelet/fur/pkg/provider"
1515)
1616
1717const (
@@ -22,6 +22,29 @@ const (
2222 MaxTokensFallbackDefault = 4096
2323)
2424
25+var defaultContextPaths = []string{
26+ ".github/copilot-instructions.md",
27+ ".cursorrules",
28+ ".cursor/rules/",
29+ "CLAUDE.md",
30+ "CLAUDE.local.md",
31+ "crush.md",
32+ "crush.local.md",
33+ "Crush.md",
34+ "Crush.local.md",
35+ "CRUSH.md",
36+ "CRUSH.local.md",
37+}
38+
39+type AgentID string
40+
41+const (
42+ AgentCoder AgentID = "coder"
43+ AgentTask AgentID = "task"
44+ AgentTitle AgentID = "title"
45+ AgentSummarize AgentID = "summarize"
46+)
47+
2548type Model struct {
2649 ID string `json:"id"`
2750 Name string `json:"model"`
@@ -43,40 +66,43 @@ type VertexAIOptions struct {
4366}
4467
4568type ProviderConfig struct {
46− BaseURL string `json:"base_url,omitempty"`
47− ProviderType provider.Type `json:"provider_type"`
48− APIKey string `json:"api_key,omitempty"`
49− Disabled bool `json:"disabled"`
50− ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
69+ ID provider.InferenceProvider `json:"id"`
70+ BaseURL string `json:"base_url,omitempty"`
71+ ProviderType provider.Type `json:"provider_type"`
72+ APIKey string `json:"api_key,omitempty"`
73+ Disabled bool `json:"disabled"`
74+ ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
5175 // used for e.x for vertex to set the project
5276 ExtraParams map[string]string `json:"extra_params,omitempty"`
5377
54− DefaultModel string `json:"default_model"`
78+ DefaultLargeModel string `json:"default_large_model,omitempty"`
79+ DefaultSmallModel string `json:"default_small_model,omitempty"`
80+
81+ Models []Model `json:"models,omitempty"`
5582}
5683
5784type Agent struct {
58− Name string `json:"name"`
85+ Name string `json:"name"`
86+ Description string `json:"description,omitempty"`
5987 // This is the id of the system prompt used by the agent
60− // TODO: still needs to be implemented
61− PromptID string `json:"prompt_id"`
62− Disabled bool `json:"disabled"`
88+ Disabled bool `json:"disabled"`
6389
6490 Provider provider.InferenceProvider `json:"provider"`
65− Model Model `json:"model"`
91+ Model string `json:"model"`
6692
6793 // The available tools for the agent
68− // if this is empty, all tools are available
94+ // if this is nil, all tools are available
6995 AllowedTools []string `json:"allowed_tools"`
7096
7197 // this tells us which MCPs are available for this agent
7298 // if this is empty all mcps are available
73− // the string array is the list of tools from the MCP the agent has available
74− // if the string array is empty, all tools from the MCP are available
75− MCP map[string][]string `json:"mcp"`
99+ // the string array is the list of tools from the AllowedMCP the agent has available
100+ // if the string array is nil, all tools from the AllowedMCP are available
101+ AllowedMCP map[string][]string `json:"allowed_mcp"`
76102
77103 // The list of LSPs that this agent can use
78− // if this is empty, all LSPs are available
79− LSP []string `json:"lsp"`
104+ // if this is nil, all LSPs are available
105+ AllowedLSP []string `json:"allowed_lsp"`
80106
81107 // Overrides the context paths for this agent
82108 ContextPaths []string `json:"context_paths"`
@@ -125,7 +151,7 @@ type Config struct {
125151 Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
126152
127153 // List of configured agents
128− Agents map[string]Agent `json:"agents,omitempty"`
154+ Agents map[AgentID]Agent `json:"agents,omitempty"`
129155
130156 // List of configured MCPs
131157 MCP map[string]MCP `json:"mcp,omitempty"`
@@ -135,15 +161,13 @@ type Config struct {
135161
136162 // Miscellaneous options
137163 Options Options `json:"options"`
138−
139− // Used to add models that are not already in the repository
140− Models map[provider.InferenceProvider][]provider.Model `json:"models,omitempty"`
141164}
142165
143166var (
144167 instance *Config // The single instance of the Singleton
145168 cwd string
146169 once sync.Once // Ensures the initialization happens only once
170+
147171)
148172
149173func loadConfig(cwd string) (*Config, error) {
@@ -190,10 +214,73 @@ func loadConfig(cwd string) (*Config, error) {
190214 }
191215
192216 // merge options
193− cfg.Options = mergeOptions(cfg.Options, globalCfg.Options)
194− cfg.Options = mergeOptions(cfg.Options, localConfig.Options)
217+ mergeOptions(cfg, globalCfg, localConfig)
195218
196219 mergeProviderConfigs(cfg, globalCfg, localConfig)
220+ // no providers found the app is not initialized yet
221+ if len(cfg.Providers) == 0 {
222+ return cfg, nil
223+ }
224+ preferredProvider := getPreferredProvider(cfg.Providers)
225+
226+ if preferredProvider == nil {
227+ return nil, errors.New("no valid providers configured")
228+ }
229+
230+ agents := map[AgentID]Agent{
231+ AgentCoder: {
232+ Name: "Coder",
233+ Description: "An agent that helps with executing coding tasks.",
234+ Provider: preferredProvider.ID,
235+ Model: preferredProvider.DefaultLargeModel,
236+ ContextPaths: cfg.Options.ContextPaths,
237+ // All tools allowed
238+ },
239+ AgentTask: {
240+ Name: "Task",
241+ Description: "An agent that helps with searching for context and finding implementation details.",
242+ Provider: preferredProvider.ID,
243+ Model: preferredProvider.DefaultLargeModel,
244+ ContextPaths: cfg.Options.ContextPaths,
245+ AllowedTools: []string{
246+ "glob",
247+ "grep",
248+ "ls",
249+ "sourcegraph",
250+ "view",
251+ },
252+ // NO MCPs or LSPs by default
253+ AllowedMCP: map[string][]string{},
254+ AllowedLSP: []string{},
255+ },
256+ AgentTitle: {
257+ Name: "Title",
258+ Description: "An agent that helps with generating titles for sessions.",
259+ Provider: preferredProvider.ID,
260+ Model: preferredProvider.DefaultSmallModel,
261+ ContextPaths: cfg.Options.ContextPaths,
262+ AllowedTools: []string{},
263+ // NO MCPs or LSPs by default
264+ AllowedMCP: map[string][]string{},
265+ AllowedLSP: []string{},
266+ },
267+ AgentSummarize: {
268+ Name: "Summarize",
269+ Description: "An agent that helps with summarizing sessions.",
270+ Provider: preferredProvider.ID,
271+ Model: preferredProvider.DefaultSmallModel,
272+ ContextPaths: cfg.Options.ContextPaths,
273+ AllowedTools: []string{},
274+ // NO MCPs or LSPs by default
275+ AllowedMCP: map[string][]string{},
276+ AllowedLSP: []string{},
277+ },
278+ }
279+ cfg.Agents = agents
280+ mergeAgents(cfg, globalCfg, localConfig)
281+ mergeMCPs(cfg, globalCfg, localConfig)
282+ mergeLSPs(cfg, globalCfg, localConfig)
283+
197284 return cfg, nil
198285}
199286
@@ -219,6 +306,22 @@ func GetConfig() *Config {
219306 return instance
220307}
221308
309+func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
310+ providers := Providers()
311+ for _, p := range providers {
312+ if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
313+ return &providerConfig
314+ }
315+ }
316+ // if none found return the first configured provider
317+ for _, providerConfig := range configuredProviders {
318+ if !providerConfig.Disabled {
319+ return &providerConfig
320+ }
321+ }
322+ return nil
323+}
324+
222325func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
223326 if other.APIKey != "" {
224327 base.APIKey = other.APIKey
@@ -249,6 +352,26 @@ func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfi
249352 base.Disabled = other.Disabled
250353 }
251354
355+ if other.DefaultLargeModel != "" {
356+ base.DefaultLargeModel = other.DefaultLargeModel
357+ }
358+ // Add new models if they don't exist
359+ if other.Models != nil {
360+ for _, model := range other.Models {
361+ // check if the model already exists
362+ exists := false
363+ for _, existingModel := range base.Models {
364+ if existingModel.ID == model.ID {
365+ exists = true
366+ break
367+ }
368+ }
369+ if !exists {
370+ base.Models = append(base.Models, model)
371+ }
372+ }
373+ }
374+
252375 return base
253376}
254377
@@ -267,39 +390,114 @@ func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfi
267390 return nil
268391}
269392
270−func mergeOptions(base, other Options) Options {
271− result := base
393+func mergeOptions(base, global, local *Config) {
394+ for _, cfg := range []*Config{global, local} {
395+ if cfg == nil {
396+ continue
397+ }
398+ baseOptions := base.Options
399+ other := cfg.Options
400+ if len(other.ContextPaths) > 0 {
401+ baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
402+ }
272403
273− if len(other.ContextPaths) > 0 {
274− base.ContextPaths = append(base.ContextPaths, other.ContextPaths...)
275− }
404+ if other.TUI.CompactMode {
405+ baseOptions.TUI.CompactMode = other.TUI.CompactMode
406+ }
276407
277− if other.TUI.CompactMode {
278− result.TUI.CompactMode = other.TUI.CompactMode
279− }
408+ if other.Debug {
409+ baseOptions.Debug = other.Debug
410+ }
280411
281− if other.Debug {
282− result.Debug = other.Debug
283− }
412+ if other.DebugLSP {
413+ baseOptions.DebugLSP = other.DebugLSP
414+ }
284415
285− if other.DebugLSP {
286− result.DebugLSP = other.DebugLSP
416+ if other.DisableAutoSummarize {
417+ baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
418+ }
419+
420+ if other.DataDirectory != "" {
421+ baseOptions.DataDirectory = other.DataDirectory
422+ }
423+ base.Options = baseOptions
287424 }
425+}
288426
289− if other.DisableAutoSummarize {
290− result.DisableAutoSummarize = other.DisableAutoSummarize
427+func mergeAgents(base, global, local *Config) {
428+ for _, cfg := range []*Config{global, local} {
429+ if cfg == nil {
430+ continue
431+ }
432+ for agentID, globalAgent := range cfg.Agents {
433+ if _, ok := base.Agents[agentID]; !ok {
434+ base.Agents[agentID] = globalAgent
435+ } else {
436+ switch agentID {
437+ case AgentCoder:
438+ baseAgent := base.Agents[agentID]
439+ baseAgent.Model = globalAgent.Model
440+ baseAgent.Provider = globalAgent.Provider
441+ baseAgent.AllowedMCP = globalAgent.AllowedMCP
442+ baseAgent.AllowedLSP = globalAgent.AllowedLSP
443+ base.Agents[agentID] = baseAgent
444+ case AgentTask:
445+ baseAgent := base.Agents[agentID]
446+ baseAgent.Model = globalAgent.Model
447+ baseAgent.Provider = globalAgent.Provider
448+ base.Agents[agentID] = baseAgent
449+ case AgentTitle:
450+ baseAgent := base.Agents[agentID]
451+ baseAgent.Model = globalAgent.Model
452+ baseAgent.Provider = globalAgent.Provider
453+ base.Agents[agentID] = baseAgent
454+ case AgentSummarize:
455+ baseAgent := base.Agents[agentID]
456+ baseAgent.Model = globalAgent.Model
457+ baseAgent.Provider = globalAgent.Provider
458+ base.Agents[agentID] = baseAgent
459+ default:
460+ baseAgent := base.Agents[agentID]
461+ baseAgent.Name = globalAgent.Name
462+ baseAgent.Description = globalAgent.Description
463+ baseAgent.Disabled = globalAgent.Disabled
464+ baseAgent.Provider = globalAgent.Provider
465+ baseAgent.Model = globalAgent.Model
466+ baseAgent.AllowedTools = globalAgent.AllowedTools
467+ baseAgent.AllowedMCP = globalAgent.AllowedMCP
468+ baseAgent.AllowedLSP = globalAgent.AllowedLSP
469+ base.Agents[agentID] = baseAgent
470+
471+ }
472+ }
473+ }
291474 }
475+}
292476
293− if other.DataDirectory != "" {
294− result.DataDirectory = other.DataDirectory
477+func mergeMCPs(base, global, local *Config) {
478+ for _, cfg := range []*Config{global, local} {
479+ if cfg == nil {
480+ continue
481+ }
482+ maps.Copy(base.MCP, cfg.MCP)
295483 }
484+}
296485
297− return result
486+func mergeLSPs(base, global, local *Config) {
487+ for _, cfg := range []*Config{global, local} {
488+ if cfg == nil {
489+ continue
490+ }
491+ maps.Copy(base.LSP, cfg.LSP)
492+ }
298493}
299494
300495func mergeProviderConfigs(base, global, local *Config) {
301− if global != nil {
302− for providerName, globalProvider := range global.Providers {
496+ for _, cfg := range []*Config{global, local} {
497+ if cfg == nil {
498+ continue
499+ }
500+ for providerName, globalProvider := range cfg.Providers {
303501 if _, ok := base.Providers[providerName]; !ok {
304502 base.Providers[providerName] = globalProvider
305503 } else {
@@ -307,15 +505,6 @@ func mergeProviderConfigs(base, global, local *Config) {
307505 }
308506 }
309507 }
310− if local != nil {
311− for providerName, localProvider := range local.Providers {
312− if _, ok := base.Providers[providerName]; !ok {
313− base.Providers[providerName] = localProvider
314− } else {
315− base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], localProvider)
316− }
317− }
318− }
319508
320509 finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
321510 for providerName, providerConfig := range base.Providers {
@@ -328,30 +517,36 @@ func mergeProviderConfigs(base, global, local *Config) {
328517 base.Providers = finalProviders
329518}
330519
331−func providerDefaultConfig(providerName provider.InferenceProvider) ProviderConfig {
332− switch providerName {
520+func providerDefaultConfig(providerId provider.InferenceProvider) ProviderConfig {
521+ switch providerId {
333522 case provider.InferenceProviderAnthropic:
334523 return ProviderConfig{
524+ ID: providerId,
335525 ProviderType: provider.TypeAnthropic,
336526 }
337527 case provider.InferenceProviderOpenAI:
338528 return ProviderConfig{
529+ ID: providerId,
339530 ProviderType: provider.TypeOpenAI,
340531 }
341532 case provider.InferenceProviderGemini:
342533 return ProviderConfig{
534+ ID: providerId,
343535 ProviderType: provider.TypeGemini,
344536 }
345537 case provider.InferenceProviderBedrock:
346538 return ProviderConfig{
539+ ID: providerId,
347540 ProviderType: provider.TypeBedrock,
348541 }
349542 case provider.InferenceProviderAzure:
350543 return ProviderConfig{
544+ ID: providerId,
351545 ProviderType: provider.TypeAzure,
352546 }
353547 case provider.InferenceProviderOpenRouter:
354548 return ProviderConfig{
549+ ID: providerId,
355550 ProviderType: provider.TypeOpenAI,
356551 BaseURL: "https://openrouter.ai/api/v1",
357552 ExtraHeaders: map[string]string{
@@ -361,15 +556,18 @@ func providerDefaultConfig(providerName provider.InferenceProvider) ProviderConf
361556 }
362557 case provider.InferenceProviderXAI:
363558 return ProviderConfig{
559+ ID: providerId,
364560 ProviderType: provider.TypeXAI,
365561 BaseURL: "https://api.x.ai/v1",
366562 }
367563 case provider.InferenceProviderVertexAI:
368564 return ProviderConfig{
565+ ID: providerId,
369566 ProviderType: provider.TypeVertexAI,
370567 }
371568 default:
372569 return ProviderConfig{
570+ ID: providerId,
373571 ProviderType: provider.TypeOpenAI,
374572 }
375573 }
@@ -379,6 +577,7 @@ func defaultConfigBasedOnEnv() *Config {
379577 cfg := &Config{
380578 Options: Options{
381579 DataDirectory: defaultDataDirectory,
580+ ContextPaths: defaultContextPaths,
382581 },
383582 Providers: make(map[provider.InferenceProvider]ProviderConfig),
384583 }
@@ -391,7 +590,22 @@ func defaultConfigBasedOnEnv() *Config {
391590 if apiKey := os.Getenv(envVar); apiKey != "" {
392591 providerConfig := providerDefaultConfig(p.ID)
393592 providerConfig.APIKey = apiKey
394− providerConfig.DefaultModel = p.DefaultModelID
593+ providerConfig.DefaultLargeModel = p.DefaultLargeModelID
594+ providerConfig.DefaultSmallModel = p.DefaultSmallModelID
595+ for _, model := range p.Models {
596+ providerConfig.Models = append(providerConfig.Models, Model{
597+ ID: model.ID,
598+ Name: model.Name,
599+ CostPer1MIn: model.CostPer1MIn,
600+ CostPer1MOut: model.CostPer1MOut,
601+ CostPer1MInCached: model.CostPer1MInCached,
602+ CostPer1MOutCached: model.CostPer1MOutCached,
603+ ContextWindow: model.ContextWindow,
604+ DefaultMaxTokens: model.DefaultMaxTokens,
605+ CanReason: model.CanReason,
606+ SupportsImages: model.SupportsImages,
607+ })
608+ }
395609 cfg.Providers[p.ID] = providerConfig
396610 }
397611 }
internal/config_v2/config_test.go+3−1
@@ -1,6 +1,7 @@
11package configv2
22
33import (
4+ "encoding/json"
45 "fmt"
56 "os"
67 "testing"
@@ -28,6 +29,7 @@ func TestConfigWithEnv(t *testing.T) {
2829 os.Setenv("XAI_API_KEY", "test-xai-key")
2930 os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
3031 cfg := InitConfig(cwdDir)
31− fmt.Println(cfg)
32+ data, _ := json.MarshalIndent(cfg, "", " ")
33+ fmt.Println(string(data))
3234 assert.Len(t, cfg.Providers, 5)
3335}
internal/config_v2/provider.go+2−2
@@ -6,8 +6,8 @@ import (
66 "path/filepath"
77 "sync"
88
9− "github.com/charmbracelet/fur/pkg/client"
10− "github.com/charmbracelet/fur/pkg/provider"
9+ "github.com/charmbracelet/crush/internal/fur/client"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
1111)
1212
1313var fur = client.New()
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 = "http://localhost:8080"
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+72
@@ -0,0 +1,72 @@
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+ TypeOpenRouter Type = "openrouter"
17+)
18+
19+// InferenceProvider represents the inference provider identifier.
20+type InferenceProvider string
21+
22+// All the inference providers supported by the system.
23+const (
24+ InferenceProviderOpenAI InferenceProvider = "openai"
25+ InferenceProviderAnthropic InferenceProvider = "anthropic"
26+ InferenceProviderGemini InferenceProvider = "gemini"
27+ InferenceProviderAzure InferenceProvider = "azure"
28+ InferenceProviderBedrock InferenceProvider = "bedrock"
29+ InferenceProviderVertexAI InferenceProvider = "vertexai"
30+ InferenceProviderXAI InferenceProvider = "xai"
31+ InferenceProviderOpenRouter InferenceProvider = "openrouter"
32+)
33+
34+// Provider represents an AI provider configuration.
35+type Provider struct {
36+ Name string `json:"name"`
37+ ID InferenceProvider `json:"id"`
38+ APIKey string `json:"api_key,omitempty"`
39+ APIEndpoint string `json:"api_endpoint,omitempty"`
40+ Type Type `json:"type,omitempty"`
41+ DefaultLargeModelID string `json:"default_large_model_id,omitempty"`
42+ DefaultSmallModelID string `json:"default_small_model_id,omitempty"`
43+ Models []Model `json:"models,omitempty"`
44+}
45+
46+// Model represents an AI model configuration.
47+type Model struct {
48+ ID string `json:"id"`
49+ Name string `json:"model"`
50+ CostPer1MIn float64 `json:"cost_per_1m_in"`
51+ CostPer1MOut float64 `json:"cost_per_1m_out"`
52+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
53+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
54+ ContextWindow int64 `json:"context_window"`
55+ DefaultMaxTokens int64 `json:"default_max_tokens"`
56+ CanReason bool `json:"can_reason"`
57+ SupportsImages bool `json:"supports_attachments"`
58+}
59+
60+// KnownProviders returns all the known inference providers.
61+func KnownProviders() []InferenceProvider {
62+ return []InferenceProvider{
63+ InferenceProviderOpenAI,
64+ InferenceProviderAnthropic,
65+ InferenceProviderGemini,
66+ InferenceProviderAzure,
67+ InferenceProviderBedrock,
68+ InferenceProviderVertexAI,
69+ InferenceProviderXAI,
70+ InferenceProviderOpenRouter,
71+ }
72+}
internal/llm/agent/agent.go+9−15
@@ -734,21 +734,15 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
734734 provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
735735 provider.WithMaxTokens(maxTokens),
736736 }
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− }
737+ // TODO: reimplement
738+ // if model.Provider == models.ProviderOpenAI || model.Provider == models.ProviderLocal && model.CanReason {
739+ // opts = append(
740+ // opts,
741+ // provider.WithOpenAIOptions(
742+ // provider.WithReasoningEffort(agentConfig.ReasoningEffort),
743+ // ),
744+ // )
745+ // }
752746 agentProvider, err := provider.NewProvider(
753747 model.Provider,
754748 opts...,
internal/llm/provider/anthropic.go+21−52
@@ -19,40 +19,25 @@ import (
1919 "github.com/charmbracelet/crush/internal/message"
2020)
2121
22−type anthropicOptions struct {
23− useBedrock bool
24− disableCache bool
25− shouldThink func(userMessage string) bool
26−}
27−
28−type AnthropicOption func(*anthropicOptions)
29−
3022type anthropicClient struct {
3123 providerOptions providerClientOptions
32− options anthropicOptions
3324 client anthropic.Client
3425}
3526
3627type AnthropicClient ProviderClient
3728
38−func newAnthropicClient(opts providerClientOptions) AnthropicClient {
39− anthropicOpts := anthropicOptions{}
40− for _, o := range opts.anthropicOptions {
41− o(&anthropicOpts)
42− }
43−
29+func newAnthropicClient(opts providerClientOptions, useBedrock bool) AnthropicClient {
4430 anthropicClientOptions := []option.RequestOption{}
4531 if opts.apiKey != "" {
4632 anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
4733 }
48− if anthropicOpts.useBedrock {
34+ if useBedrock {
4935 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
5036 }
5137
5238 client := anthropic.NewClient(anthropicClientOptions...)
5339 return &anthropicClient{
5440 providerOptions: opts,
55− options: anthropicOpts,
5641 client: client,
5742 }
5843}
@@ -66,7 +51,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
6651 switch msg.Role {
6752 case message.User:
6853 content := anthropic.NewTextBlock(msg.Content().String())
69− if cache && !a.options.disableCache {
54+ if cache && !a.providerOptions.disableCache {
7055 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
7156 Type: "ephemeral",
7257 }
@@ -84,7 +69,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
8469 blocks := []anthropic.ContentBlockParamUnion{}
8570 if msg.Content().String() != "" {
8671 content := anthropic.NewTextBlock(msg.Content().String())
87− if cache && !a.options.disableCache {
72+ if cache && !a.providerOptions.disableCache {
8873 content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
8974 Type: "ephemeral",
9075 }
@@ -132,7 +117,7 @@ func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolU
132117 },
133118 }
134119
135− if i == len(tools)-1 && !a.options.disableCache {
120+ if i == len(tools)-1 && !a.providerOptions.disableCache {
136121 toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
137122 Type: "ephemeral",
138123 }
@@ -161,21 +146,22 @@ func (a *anthropicClient) finishReason(reason string) message.FinishReason {
161146
162147func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
163148 var thinkingParam anthropic.ThinkingConfigParamUnion
164− lastMessage := messages[len(messages)-1]
165− isUser := lastMessage.Role == anthropic.MessageParamRoleUser
166− messageContent := ""
149+ // TODO: Implement a proper thinking function
150+ // lastMessage := messages[len(messages)-1]
151+ // isUser := lastMessage.Role == anthropic.MessageParamRoleUser
152+ // messageContent := ""
167153 temperature := anthropic.Float(0)
168− if isUser {
169− for _, m := range lastMessage.Content {
170− if m.OfText != nil && m.OfText.Text != "" {
171− messageContent = m.OfText.Text
172− }
173− }
174− if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
175− thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
176− temperature = anthropic.Float(1)
177− }
178− }
154+ // if isUser {
155+ // for _, m := range lastMessage.Content {
156+ // if m.OfText != nil && m.OfText.Text != "" {
157+ // messageContent = m.OfText.Text
158+ // }
159+ // }
160+ // if messageContent != "" && a.shouldThink != nil && a.options.shouldThink(messageContent) {
161+ // thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
162+ // temperature = anthropic.Float(1)
163+ // }
164+ // }
179165
180166 return anthropic.MessageNewParams{
181167 Model: anthropic.Model(a.providerOptions.model.APIModel),
@@ -439,24 +425,7 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
439425 }
440426}
441427
442−func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
443− return func(options *anthropicOptions) {
444− options.useBedrock = useBedrock
445− }
446−}
447−
448−func WithAnthropicDisableCache() AnthropicOption {
449− return func(options *anthropicOptions) {
450− options.disableCache = true
451− }
452−}
453−
428+// TODO: check if we need
454429func DefaultShouldThinkFn(s string) bool {
455430 return strings.Contains(strings.ToLower(s), "think")
456431}
457−
458−func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
459− return func(options *anthropicOptions) {
460− options.shouldThink = fn
461− }
462−}
internal/llm/provider/bedrock.go+3−16
@@ -11,22 +11,14 @@ import (
1111 "github.com/charmbracelet/crush/internal/message"
1212)
1313
14−type bedrockOptions struct {
15− // Bedrock specific options can be added here
16−}
17−
18−type BedrockOption func(*bedrockOptions)
19−
2014type bedrockClient struct {
2115 providerOptions providerClientOptions
22− options bedrockOptions
2316 childProvider ProviderClient
2417}
2518
2619type BedrockClient ProviderClient
2720
2821func newBedrockClient(opts providerClientOptions) BedrockClient {
29− bedrockOpts := bedrockOptions{}
3022 // Apply bedrock specific options if they are added in the future
3123
3224 // Get AWS region from environment
@@ -41,7 +33,6 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
4133 if len(region) < 2 {
4234 return &bedrockClient{
4335 providerOptions: opts,
44− options: bedrockOpts,
4536 childProvider: nil, // Will cause an error when used
4637 }
4738 }
@@ -55,14 +46,11 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
5546 if strings.Contains(string(opts.model.APIModel), "anthropic") {
5647 // Create Anthropic client with Bedrock configuration
5748 anthropicOpts := opts
58− anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
59− WithAnthropicBedrock(true),
60− WithAnthropicDisableCache(),
61− )
49+ // TODO: later find a way to check if the AWS account has caching enabled
50+ opts.disableCache = true // Disable cache for Bedrock
6251 return &bedrockClient{
6352 providerOptions: opts,
64− options: bedrockOpts,
65− childProvider: newAnthropicClient(anthropicOpts),
53+ childProvider: newAnthropicClient(anthropicOpts, true),
6654 }
6755 }
6856
@@ -70,7 +58,6 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
7058 // This will cause an error when used
7159 return &bedrockClient{
7260 providerOptions: opts,
73− options: bedrockOpts,
7461 childProvider: nil,
7562 }
7663}
internal/llm/provider/gemini.go−19
@@ -17,26 +17,14 @@ import (
1717 "google.golang.org/genai"
1818)
1919
20−type geminiOptions struct {
21− disableCache bool
22−}
23−
24−type GeminiOption func(*geminiOptions)
25−
2620type geminiClient struct {
2721 providerOptions providerClientOptions
28− options geminiOptions
2922 client *genai.Client
3023}
3124
3225type GeminiClient ProviderClient
3326
3427func newGeminiClient(opts providerClientOptions) GeminiClient {
35− geminiOpts := geminiOptions{}
36− for _, o := range opts.geminiOptions {
37− o(&geminiOpts)
38− }
39−
4028 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
4129 if err != nil {
4230 logging.Error("Failed to create Gemini client", "error", err)
@@ -45,7 +33,6 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
4533
4634 return &geminiClient{
4735 providerOptions: opts,
48− options: geminiOpts,
4936 client: client,
5037 }
5138}
@@ -452,12 +439,6 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
452439 }
453440}
454441
455−func WithGeminiDisableCache() GeminiOption {
456− return func(options *geminiOptions) {
457− options.disableCache = true
458− }
459−}
460−
461442// Helper functions
462443func parseJsonToMap(jsonStr string) (map[string]any, error) {
463444 var result map[string]any
internal/llm/provider/openai.go+4−43
@@ -19,14 +19,9 @@ import (
1919)
2020
2121type openaiOptions struct {
22− baseURL string
23− disableCache bool
2422 reasoningEffort string
25− extraHeaders map[string]string
2623}
2724
28−type OpenAIOption func(*openaiOptions)
29−
3025type openaiClient struct {
3126 providerOptions providerClientOptions
3227 options openaiOptions
@@ -39,20 +34,17 @@ func newOpenAIClient(opts providerClientOptions) OpenAIClient {
3934 openaiOpts := openaiOptions{
4035 reasoningEffort: "medium",
4136 }
42− for _, o := range opts.openaiOptions {
43− o(&openaiOpts)
44− }
4537
4638 openaiClientOptions := []option.RequestOption{}
4739 if opts.apiKey != "" {
4840 openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
4941 }
50− if openaiOpts.baseURL != "" {
51− openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
42+ if opts.baseURL != "" {
43+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(opts.baseURL))
5244 }
5345
54− if openaiOpts.extraHeaders != nil {
55− for key, value := range openaiOpts.extraHeaders {
46+ if opts.extraHeaders != nil {
47+ for key, value := range opts.extraHeaders {
5648 openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
5749 }
5850 }
@@ -392,34 +384,3 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
392384 CacheReadTokens: cachedTokens,
393385 }
394386}
395−
396−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
397− return func(options *openaiOptions) {
398− options.baseURL = baseURL
399− }
400−}
401−
402−func WithOpenAIExtraHeaders(headers map[string]string) OpenAIOption {
403− return func(options *openaiOptions) {
404− options.extraHeaders = headers
405− }
406−}
407−
408−func WithOpenAIDisableCache() OpenAIOption {
409− return func(options *openaiOptions) {
410− options.disableCache = true
411− }
412−}
413−
414−func WithReasoningEffort(effort string) OpenAIOption {
415− return func(options *openaiOptions) {
416− defaultReasoningEffort := "medium"
417− switch effort {
418− case "low", "medium", "high":
419− defaultReasoningEffort = effort
420− default:
421− logging.Warn("Invalid reasoning effort, using default: medium")
422− }
423− options.reasoningEffort = defaultReasoningEffort
424− }
425−}
internal/llm/provider/provider.go+30−42
@@ -3,6 +3,7 @@ package provider
33import (
44 "context"
55 "fmt"
6+ "maps"
67 "os"
78
89 "github.com/charmbracelet/crush/internal/llm/models"
@@ -59,15 +60,13 @@ type Provider interface {
5960}
6061
6162type providerClientOptions struct {
63+ baseURL string
6264 apiKey string
6365 model models.Model
66+ disableCache bool
6467 maxTokens int64
6568 systemMessage string
66−
67− anthropicOptions []AnthropicOption
68− openaiOptions []OpenAIOption
69− geminiOptions []GeminiOption
70− bedrockOptions []BedrockOption
69+ extraHeaders map[string]string
7170}
7271
7372type ProviderClientOption func(*providerClientOptions)
@@ -91,7 +90,7 @@ func NewProvider(providerName models.InferenceProvider, opts ...ProviderClientOp
9190 case models.ProviderAnthropic:
9291 return &baseProvider[AnthropicClient]{
9392 options: clientOptions,
94− client: newAnthropicClient(clientOptions),
93+ client: newAnthropicClient(clientOptions, false),
9594 }, nil
9695 case models.ProviderOpenAI:
9796 return &baseProvider[OpenAIClient]{
@@ -109,9 +108,7 @@ func NewProvider(providerName models.InferenceProvider, opts ...ProviderClientOp
109108 client: newBedrockClient(clientOptions),
110109 }, nil
111110 case models.ProviderGROQ:
112− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
113− WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
114− )
111+ clientOptions.baseURL = "https://api.groq.com/openai/v1"
115112 return &baseProvider[OpenAIClient]{
116113 options: clientOptions,
117114 client: newOpenAIClient(clientOptions),
@@ -127,29 +124,23 @@ func NewProvider(providerName models.InferenceProvider, opts ...ProviderClientOp
127124 client: newVertexAIClient(clientOptions),
128125 }, nil
129126 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− )
127+ clientOptions.baseURL = "https://openrouter.ai/api/v1"
128+ clientOptions.extraHeaders = map[string]string{
129+ "HTTP-Referer": "crush.charm.land",
130+ "X-Title": "Crush",
131+ }
137132 return &baseProvider[OpenAIClient]{
138133 options: clientOptions,
139134 client: newOpenAIClient(clientOptions),
140135 }, nil
141136 case models.ProviderXAI:
142− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
143− WithOpenAIBaseURL("https://api.x.ai/v1"),
144− )
137+ clientOptions.baseURL = "https://api.x.ai/v1"
145138 return &baseProvider[OpenAIClient]{
146139 options: clientOptions,
147140 client: newOpenAIClient(clientOptions),
148141 }, nil
149142 case models.ProviderLocal:
150− clientOptions.openaiOptions = append(clientOptions.openaiOptions,
151− WithOpenAIBaseURL(os.Getenv("LOCAL_ENDPOINT")),
152− )
143+ clientOptions.baseURL = os.Getenv("LOCAL_ENDPOINT")
153144 return &baseProvider[OpenAIClient]{
154145 options: clientOptions,
155146 client: newOpenAIClient(clientOptions),
@@ -186,50 +177,47 @@ func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message
186177 return p.client.stream(ctx, messages, tools)
187178}
188179
189−func WithAPIKey(apiKey string) ProviderClientOption {
190− return func(options *providerClientOptions) {
191− options.apiKey = apiKey
192− }
193−}
194−
195−func WithModel(model models.Model) ProviderClientOption {
180+func WithBaseURL(baseURL string) ProviderClientOption {
196181 return func(options *providerClientOptions) {
197− options.model = model
182+ options.baseURL = baseURL
198183 }
199184}
200185
201−func WithMaxTokens(maxTokens int64) ProviderClientOption {
186+func WithAPIKey(apiKey string) ProviderClientOption {
202187 return func(options *providerClientOptions) {
203− options.maxTokens = maxTokens
188+ options.apiKey = apiKey
204189 }
205190}
206191
207−func WithSystemMessage(systemMessage string) ProviderClientOption {
192+func WithModel(model models.Model) ProviderClientOption {
208193 return func(options *providerClientOptions) {
209− options.systemMessage = systemMessage
194+ options.model = model
210195 }
211196}
212197
213−func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
198+func WithDisableCache(disableCache bool) ProviderClientOption {
214199 return func(options *providerClientOptions) {
215− options.anthropicOptions = anthropicOptions
200+ options.disableCache = disableCache
216201 }
217202}
218203
219−func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
204+func WithExtraHeaders(extraHeaders map[string]string) ProviderClientOption {
220205 return func(options *providerClientOptions) {
221− options.openaiOptions = openaiOptions
206+ if options.extraHeaders == nil {
207+ options.extraHeaders = make(map[string]string)
208+ }
209+ maps.Copy(options.extraHeaders, extraHeaders)
222210 }
223211}
224212
225−func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
213+func WithMaxTokens(maxTokens int64) ProviderClientOption {
226214 return func(options *providerClientOptions) {
227− options.geminiOptions = geminiOptions
215+ options.maxTokens = maxTokens
228216 }
229217}
230218
231−func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
219+func WithSystemMessage(systemMessage string) ProviderClientOption {
232220 return func(options *providerClientOptions) {
233− options.bedrockOptions = bedrockOptions
221+ options.systemMessage = systemMessage
234222 }
235223}
internal/llm/provider/vertexai.go−6
@@ -11,11 +11,6 @@ import (
1111type VertexAIClient ProviderClient
1212
1313func newVertexAIClient(opts providerClientOptions) VertexAIClient {
14− geminiOpts := geminiOptions{}
15− for _, o := range opts.geminiOptions {
16− o(&geminiOpts)
17− }
18−
1914 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
2015 Project: os.Getenv("GOOGLE_CLOUD_PROJECT"),
2116 Location: os.Getenv("GOOGLE_CLOUD_LOCATION"),
@@ -28,7 +23,6 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
2823
2924 return &geminiClient{
3025 providerOptions: opts,
31− options: geminiOpts,
3226 client: client,
3327 }
3428}