1package agent
2
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "maps"
13 "net/http"
14 "os"
15 "slices"
16 "strings"
17
18 "charm.land/fantasy"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
20 "github.com/charmbracelet/crush/internal/agent/prompt"
21 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/csync"
24 "github.com/charmbracelet/crush/internal/history"
25 "github.com/charmbracelet/crush/internal/log"
26 "github.com/charmbracelet/crush/internal/lsp"
27 "github.com/charmbracelet/crush/internal/message"
28 "github.com/charmbracelet/crush/internal/permission"
29 "github.com/charmbracelet/crush/internal/session"
30 "golang.org/x/sync/errgroup"
31
32 "charm.land/fantasy/providers/anthropic"
33 "charm.land/fantasy/providers/azure"
34 "charm.land/fantasy/providers/bedrock"
35 "charm.land/fantasy/providers/google"
36 "charm.land/fantasy/providers/openai"
37 "charm.land/fantasy/providers/openaicompat"
38 "charm.land/fantasy/providers/openrouter"
39 openaisdk "github.com/openai/openai-go/v2/option"
40 "github.com/qjebbs/go-jsons"
41)
42
43type Coordinator interface {
44 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
45 // SetMainAgent(string)
46 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
47 Cancel(sessionID string)
48 CancelAll()
49 IsSessionBusy(sessionID string) bool
50 IsBusy() bool
51 QueuedPrompts(sessionID string) int
52 QueuedPromptsList(sessionID string) []string
53 ClearQueue(sessionID string)
54 Summarize(context.Context, string) error
55 Model() Model
56 UpdateModels(ctx context.Context) error
57}
58
59type coordinator struct {
60 cfg *config.Config
61 sessions session.Service
62 messages message.Service
63 permissions permission.Service
64 history history.Service
65 lspClients *csync.Map[string, *lsp.Client]
66
67 currentAgent SessionAgent
68 agents map[string]SessionAgent
69
70 readyWg errgroup.Group
71}
72
73func NewCoordinator(
74 ctx context.Context,
75 cfg *config.Config,
76 sessions session.Service,
77 messages message.Service,
78 permissions permission.Service,
79 history history.Service,
80 lspClients *csync.Map[string, *lsp.Client],
81) (Coordinator, error) {
82 c := &coordinator{
83 cfg: cfg,
84 sessions: sessions,
85 messages: messages,
86 permissions: permissions,
87 history: history,
88 lspClients: lspClients,
89 agents: make(map[string]SessionAgent),
90 }
91
92 agentCfg, ok := cfg.Agents[config.AgentCoder]
93 if !ok {
94 return nil, errors.New("coder agent not configured")
95 }
96
97 // TODO: make this dynamic when we support multiple agents
98 prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
99 if err != nil {
100 return nil, err
101 }
102
103 agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
104 if err != nil {
105 return nil, err
106 }
107 c.currentAgent = agent
108 c.agents[config.AgentCoder] = agent
109 return c, nil
110}
111
112// Run implements Coordinator.
113func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
114 if err := c.readyWg.Wait(); err != nil {
115 return nil, err
116 }
117
118 model := c.currentAgent.Model()
119 maxTokens := model.CatwalkCfg.DefaultMaxTokens
120 if model.ModelCfg.MaxTokens != 0 {
121 maxTokens = model.ModelCfg.MaxTokens
122 }
123
124 if !model.CatwalkCfg.SupportsImages && attachments != nil {
125 attachments = nil
126 }
127
128 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
129 if !ok {
130 return nil, errors.New("model provider not configured")
131 }
132
133 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
134
135 run := func() (*fantasy.AgentResult, error) {
136 return c.currentAgent.Run(ctx, SessionAgentCall{
137 SessionID: sessionID,
138 Prompt: prompt,
139 Attachments: attachments,
140 MaxOutputTokens: maxTokens,
141 ProviderOptions: mergedOptions,
142 Temperature: temp,
143 TopP: topP,
144 TopK: topK,
145 FrequencyPenalty: freqPenalty,
146 PresencePenalty: presPenalty,
147 })
148 }
149 result, originalErr := run()
150
151 if c.isUnauthorized(originalErr) {
152 switch {
153 case providerCfg.OAuthToken != nil:
154 slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
155 if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
156 return nil, originalErr
157 }
158 slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
159 return run()
160 case strings.Contains(providerCfg.APIKeyTemplate, "$"):
161 slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
162 if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
163 return nil, originalErr
164 }
165 slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
166 return run()
167 }
168 }
169
170 return result, originalErr
171}
172
173func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
174 options := fantasy.ProviderOptions{}
175
176 cfgOpts := []byte("{}")
177 providerCfgOpts := []byte("{}")
178 catwalkOpts := []byte("{}")
179
180 if model.ModelCfg.ProviderOptions != nil {
181 data, err := json.Marshal(model.ModelCfg.ProviderOptions)
182 if err == nil {
183 cfgOpts = data
184 }
185 }
186
187 if providerCfg.ProviderOptions != nil {
188 data, err := json.Marshal(providerCfg.ProviderOptions)
189 if err == nil {
190 providerCfgOpts = data
191 }
192 }
193
194 if model.CatwalkCfg.Options.ProviderOptions != nil {
195 data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
196 if err == nil {
197 catwalkOpts = data
198 }
199 }
200
201 readers := []io.Reader{
202 bytes.NewReader(catwalkOpts),
203 bytes.NewReader(providerCfgOpts),
204 bytes.NewReader(cfgOpts),
205 }
206
207 got, err := jsons.Merge(readers)
208 if err != nil {
209 slog.Error("Could not merge call config", "err", err)
210 return options
211 }
212
213 mergedOptions := make(map[string]any)
214
215 err = json.Unmarshal([]byte(got), &mergedOptions)
216 if err != nil {
217 slog.Error("Could not create config for call", "err", err)
218 return options
219 }
220
221 switch providerCfg.Type {
222 case openai.Name, azure.Name:
223 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
224 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
225 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
226 }
227 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
228 if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
229 mergedOptions["reasoning_summary"] = "auto"
230 mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
231 }
232 parsed, err := openai.ParseResponsesOptions(mergedOptions)
233 if err == nil {
234 options[openai.Name] = parsed
235 }
236 } else {
237 parsed, err := openai.ParseOptions(mergedOptions)
238 if err == nil {
239 options[openai.Name] = parsed
240 }
241 }
242 case anthropic.Name:
243 _, hasThink := mergedOptions["thinking"]
244 if !hasThink && model.ModelCfg.Think {
245 mergedOptions["thinking"] = map[string]any{
246 // TODO: kujtim see if we need to make this dynamic
247 "budget_tokens": 2000,
248 }
249 }
250 parsed, err := anthropic.ParseOptions(mergedOptions)
251 if err == nil {
252 options[anthropic.Name] = parsed
253 }
254
255 case openrouter.Name:
256 _, hasReasoning := mergedOptions["reasoning"]
257 if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
258 mergedOptions["reasoning"] = map[string]any{
259 "enabled": true,
260 "effort": model.ModelCfg.ReasoningEffort,
261 }
262 }
263 parsed, err := openrouter.ParseOptions(mergedOptions)
264 if err == nil {
265 options[openrouter.Name] = parsed
266 }
267 case google.Name:
268 _, hasReasoning := mergedOptions["thinking_config"]
269 if !hasReasoning {
270 mergedOptions["thinking_config"] = map[string]any{
271 "thinking_budget": 2000,
272 "include_thoughts": true,
273 }
274 }
275 parsed, err := google.ParseOptions(mergedOptions)
276 if err == nil {
277 options[google.Name] = parsed
278 }
279 case openaicompat.Name:
280 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
281 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
282 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
283 }
284 parsed, err := openaicompat.ParseOptions(mergedOptions)
285 if err == nil {
286 options[openaicompat.Name] = parsed
287 }
288 }
289
290 return options
291}
292
293func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
294 modelOptions := getProviderOptions(model, cfg)
295 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
296 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
297 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
298 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
299 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
300 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
301}
302
303func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
304 large, small, err := c.buildAgentModels(ctx)
305 if err != nil {
306 return nil, err
307 }
308
309 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
310 if err != nil {
311 return nil, err
312 }
313
314 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
315 result := NewSessionAgent(SessionAgentOptions{
316 large,
317 small,
318 largeProviderCfg.SystemPromptPrefix,
319 systemPrompt,
320 isSubAgent,
321 c.cfg.Options.DisableAutoSummarize,
322 c.permissions.SkipRequests(),
323 c.sessions,
324 c.messages,
325 nil,
326 })
327 c.readyWg.Go(func() error {
328 tools, err := c.buildTools(ctx, agent)
329 if err != nil {
330 return err
331 }
332 result.SetTools(tools)
333 return nil
334 })
335
336 return result, nil
337}
338
339func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
340 var allTools []fantasy.AgentTool
341 if slices.Contains(agent.AllowedTools, AgentToolName) {
342 agentTool, err := c.agentTool(ctx)
343 if err != nil {
344 return nil, err
345 }
346 allTools = append(allTools, agentTool)
347 }
348
349 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
350 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
351 if err != nil {
352 return nil, err
353 }
354 allTools = append(allTools, agenticFetchTool)
355 }
356
357 // Get the model name for the agent
358 modelName := ""
359 if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
360 if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
361 modelName = model.Name
362 }
363 }
364
365 allTools = append(allTools,
366 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
367 tools.NewJobOutputTool(),
368 tools.NewJobKillTool(),
369 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
370 tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
371 tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
372 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
373 tools.NewGlobTool(c.cfg.WorkingDir()),
374 tools.NewGrepTool(c.cfg.WorkingDir()),
375 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
376 tools.NewSourcegraphTool(nil),
377 tools.NewTodosTool(c.sessions),
378 tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
379 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
380 )
381
382 if len(c.cfg.LSP) > 0 {
383 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
384 }
385
386 var filteredTools []fantasy.AgentTool
387 for _, tool := range allTools {
388 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
389 filteredTools = append(filteredTools, tool)
390 }
391 }
392
393 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
394 // Check MCP-specific disabled tools.
395 if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
396 if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
397 continue
398 }
399 }
400 if agent.AllowedMCP == nil {
401 // No MCP restrictions
402 filteredTools = append(filteredTools, tool)
403 continue
404 }
405 if len(agent.AllowedMCP) == 0 {
406 // No MCPs allowed
407 slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
408 break
409 }
410
411 for mcp, tools := range agent.AllowedMCP {
412 if mcp != tool.MCP() {
413 continue
414 }
415 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
416 filteredTools = append(filteredTools, tool)
417 }
418 }
419 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
420 }
421 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
422 return strings.Compare(a.Info().Name, b.Info().Name)
423 })
424 return filteredTools, nil
425}
426
427// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
428func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
429 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
430 if !ok {
431 return Model{}, Model{}, errors.New("large model not selected")
432 }
433 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
434 if !ok {
435 return Model{}, Model{}, errors.New("small model not selected")
436 }
437
438 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
439 if !ok {
440 return Model{}, Model{}, errors.New("large model provider not configured")
441 }
442
443 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
444 if err != nil {
445 return Model{}, Model{}, err
446 }
447
448 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
449 if !ok {
450 return Model{}, Model{}, errors.New("large model provider not configured")
451 }
452
453 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
454 if err != nil {
455 return Model{}, Model{}, err
456 }
457
458 var largeCatwalkModel *catwalk.Model
459 var smallCatwalkModel *catwalk.Model
460
461 for _, m := range largeProviderCfg.Models {
462 if m.ID == largeModelCfg.Model {
463 largeCatwalkModel = &m
464 }
465 }
466 for _, m := range smallProviderCfg.Models {
467 if m.ID == smallModelCfg.Model {
468 smallCatwalkModel = &m
469 }
470 }
471
472 if largeCatwalkModel == nil {
473 return Model{}, Model{}, errors.New("large model not found in provider config")
474 }
475
476 if smallCatwalkModel == nil {
477 return Model{}, Model{}, errors.New("snall model not found in provider config")
478 }
479
480 largeModelID := largeModelCfg.Model
481 smallModelID := smallModelCfg.Model
482
483 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
484 largeModelID += ":exacto"
485 }
486
487 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
488 smallModelID += ":exacto"
489 }
490
491 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
492 if err != nil {
493 return Model{}, Model{}, err
494 }
495 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
496 if err != nil {
497 return Model{}, Model{}, err
498 }
499
500 return Model{
501 Model: largeModel,
502 CatwalkCfg: *largeCatwalkModel,
503 ModelCfg: largeModelCfg,
504 }, Model{
505 Model: smallModel,
506 CatwalkCfg: *smallCatwalkModel,
507 ModelCfg: smallModelCfg,
508 }, nil
509}
510
511func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
512 var opts []anthropic.Option
513
514 if strings.HasPrefix(apiKey, "Bearer ") {
515 // NOTE: Prevent the SDK from picking up the API key from env.
516 os.Setenv("ANTHROPIC_API_KEY", "")
517
518 headers["Authorization"] = apiKey
519 } else if apiKey != "" {
520 // X-Api-Key header
521 opts = append(opts, anthropic.WithAPIKey(apiKey))
522 }
523
524 if len(headers) > 0 {
525 opts = append(opts, anthropic.WithHeaders(headers))
526 }
527
528 if baseURL != "" {
529 opts = append(opts, anthropic.WithBaseURL(baseURL))
530 }
531
532 if c.cfg.Options.Debug {
533 httpClient := log.NewHTTPClient()
534 opts = append(opts, anthropic.WithHTTPClient(httpClient))
535 }
536
537 return anthropic.New(opts...)
538}
539
540func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
541 opts := []openai.Option{
542 openai.WithAPIKey(apiKey),
543 openai.WithUseResponsesAPI(),
544 }
545 if c.cfg.Options.Debug {
546 httpClient := log.NewHTTPClient()
547 opts = append(opts, openai.WithHTTPClient(httpClient))
548 }
549 if len(headers) > 0 {
550 opts = append(opts, openai.WithHeaders(headers))
551 }
552 if baseURL != "" {
553 opts = append(opts, openai.WithBaseURL(baseURL))
554 }
555 return openai.New(opts...)
556}
557
558func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
559 opts := []openrouter.Option{
560 openrouter.WithAPIKey(apiKey),
561 }
562 if c.cfg.Options.Debug {
563 httpClient := log.NewHTTPClient()
564 opts = append(opts, openrouter.WithHTTPClient(httpClient))
565 }
566 if len(headers) > 0 {
567 opts = append(opts, openrouter.WithHeaders(headers))
568 }
569 return openrouter.New(opts...)
570}
571
572func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
573 opts := []openaicompat.Option{
574 openaicompat.WithBaseURL(baseURL),
575 openaicompat.WithAPIKey(apiKey),
576 }
577 if c.cfg.Options.Debug {
578 httpClient := log.NewHTTPClient()
579 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
580 }
581 if len(headers) > 0 {
582 opts = append(opts, openaicompat.WithHeaders(headers))
583 }
584
585 for extraKey, extraValue := range extraBody {
586 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
587 }
588
589 return openaicompat.New(opts...)
590}
591
592func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
593 opts := []azure.Option{
594 azure.WithBaseURL(baseURL),
595 azure.WithAPIKey(apiKey),
596 azure.WithUseResponsesAPI(),
597 }
598 if c.cfg.Options.Debug {
599 httpClient := log.NewHTTPClient()
600 opts = append(opts, azure.WithHTTPClient(httpClient))
601 }
602 if options == nil {
603 options = make(map[string]string)
604 }
605 if apiVersion, ok := options["apiVersion"]; ok {
606 opts = append(opts, azure.WithAPIVersion(apiVersion))
607 }
608 if len(headers) > 0 {
609 opts = append(opts, azure.WithHeaders(headers))
610 }
611
612 return azure.New(opts...)
613}
614
615func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
616 var opts []bedrock.Option
617 if c.cfg.Options.Debug {
618 httpClient := log.NewHTTPClient()
619 opts = append(opts, bedrock.WithHTTPClient(httpClient))
620 }
621 if len(headers) > 0 {
622 opts = append(opts, bedrock.WithHeaders(headers))
623 }
624 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
625 if bearerToken != "" {
626 opts = append(opts, bedrock.WithAPIKey(bearerToken))
627 }
628 return bedrock.New(opts...)
629}
630
631func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
632 opts := []google.Option{
633 google.WithBaseURL(baseURL),
634 google.WithGeminiAPIKey(apiKey),
635 }
636 if c.cfg.Options.Debug {
637 httpClient := log.NewHTTPClient()
638 opts = append(opts, google.WithHTTPClient(httpClient))
639 }
640 if len(headers) > 0 {
641 opts = append(opts, google.WithHeaders(headers))
642 }
643 return google.New(opts...)
644}
645
646func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
647 opts := []google.Option{}
648 if c.cfg.Options.Debug {
649 httpClient := log.NewHTTPClient()
650 opts = append(opts, google.WithHTTPClient(httpClient))
651 }
652 if len(headers) > 0 {
653 opts = append(opts, google.WithHeaders(headers))
654 }
655
656 project := options["project"]
657 location := options["location"]
658
659 opts = append(opts, google.WithVertex(project, location))
660
661 return google.New(opts...)
662}
663
664func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
665 if model.Think {
666 return true
667 }
668
669 if model.ProviderOptions == nil {
670 return false
671 }
672
673 opts, err := anthropic.ParseOptions(model.ProviderOptions)
674 if err != nil {
675 return false
676 }
677 if opts.Thinking != nil {
678 return true
679 }
680 return false
681}
682
683func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
684 headers := maps.Clone(providerCfg.ExtraHeaders)
685 if headers == nil {
686 headers = make(map[string]string)
687 }
688
689 // handle special headers for anthropic
690 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
691 if v, ok := headers["anthropic-beta"]; ok {
692 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
693 } else {
694 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
695 }
696 }
697
698 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
699 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
700
701 switch providerCfg.Type {
702 case openai.Name:
703 return c.buildOpenaiProvider(baseURL, apiKey, headers)
704 case anthropic.Name:
705 return c.buildAnthropicProvider(baseURL, apiKey, headers)
706 case openrouter.Name:
707 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
708 case azure.Name:
709 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
710 case bedrock.Name:
711 return c.buildBedrockProvider(headers)
712 case google.Name:
713 return c.buildGoogleProvider(baseURL, apiKey, headers)
714 case "google-vertex":
715 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
716 case openaicompat.Name:
717 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
718 if providerCfg.ExtraBody == nil {
719 providerCfg.ExtraBody = map[string]any{}
720 }
721 providerCfg.ExtraBody["tool_stream"] = true
722 }
723 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
724 default:
725 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
726 }
727}
728
729func isExactoSupported(modelID string) bool {
730 supportedModels := []string{
731 "moonshotai/kimi-k2-0905",
732 "deepseek/deepseek-v3.1-terminus",
733 "z-ai/glm-4.6",
734 "openai/gpt-oss-120b",
735 "qwen/qwen3-coder",
736 }
737 return slices.Contains(supportedModels, modelID)
738}
739
740func (c *coordinator) Cancel(sessionID string) {
741 c.currentAgent.Cancel(sessionID)
742}
743
744func (c *coordinator) CancelAll() {
745 c.currentAgent.CancelAll()
746}
747
748func (c *coordinator) ClearQueue(sessionID string) {
749 c.currentAgent.ClearQueue(sessionID)
750}
751
752func (c *coordinator) IsBusy() bool {
753 return c.currentAgent.IsBusy()
754}
755
756func (c *coordinator) IsSessionBusy(sessionID string) bool {
757 return c.currentAgent.IsSessionBusy(sessionID)
758}
759
760func (c *coordinator) Model() Model {
761 return c.currentAgent.Model()
762}
763
764func (c *coordinator) UpdateModels(ctx context.Context) error {
765 // build the models again so we make sure we get the latest config
766 large, small, err := c.buildAgentModels(ctx)
767 if err != nil {
768 return err
769 }
770 c.currentAgent.SetModels(large, small)
771
772 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
773 if !ok {
774 return errors.New("coder agent not configured")
775 }
776
777 tools, err := c.buildTools(ctx, agentCfg)
778 if err != nil {
779 return err
780 }
781 c.currentAgent.SetTools(tools)
782 return nil
783}
784
785func (c *coordinator) QueuedPrompts(sessionID string) int {
786 return c.currentAgent.QueuedPrompts(sessionID)
787}
788
789func (c *coordinator) QueuedPromptsList(sessionID string) []string {
790 return c.currentAgent.QueuedPromptsList(sessionID)
791}
792
793func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
794 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
795 if !ok {
796 return errors.New("model provider not configured")
797 }
798 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
799}
800
801func (c *coordinator) isUnauthorized(err error) bool {
802 var providerErr *fantasy.ProviderError
803 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
804}
805
806func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
807 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
808 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
809 return err
810 }
811 if err := c.UpdateModels(ctx); err != nil {
812 return err
813 }
814 return nil
815}
816
817func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
818 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
819 if err != nil {
820 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
821 return err
822 }
823
824 providerCfg.APIKey = newAPIKey
825 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
826
827 if err := c.UpdateModels(ctx); err != nil {
828 return err
829 }
830 return nil
831}