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