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 // Check MCP-specific disabled tools.
392 if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
393 if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
394 continue
395 }
396 }
397 if agent.AllowedMCP == nil {
398 // No MCP restrictions
399 filteredTools = append(filteredTools, tool)
400 continue
401 }
402 if len(agent.AllowedMCP) == 0 {
403 // No MCPs allowed
404 slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
405 break
406 }
407
408 for mcp, tools := range agent.AllowedMCP {
409 if mcp != tool.MCP() {
410 continue
411 }
412 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
413 filteredTools = append(filteredTools, tool)
414 }
415 }
416 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
417 }
418 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
419 return strings.Compare(a.Info().Name, b.Info().Name)
420 })
421 return filteredTools, nil
422}
423
424// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
425func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
426 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
427 if !ok {
428 return Model{}, Model{}, errors.New("large model not selected")
429 }
430 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
431 if !ok {
432 return Model{}, Model{}, errors.New("small model not selected")
433 }
434
435 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
436 if !ok {
437 return Model{}, Model{}, errors.New("large model provider not configured")
438 }
439
440 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
441 if err != nil {
442 return Model{}, Model{}, err
443 }
444
445 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
446 if !ok {
447 return Model{}, Model{}, errors.New("large model provider not configured")
448 }
449
450 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
451 if err != nil {
452 return Model{}, Model{}, err
453 }
454
455 var largeCatwalkModel *catwalk.Model
456 var smallCatwalkModel *catwalk.Model
457
458 for _, m := range largeProviderCfg.Models {
459 if m.ID == largeModelCfg.Model {
460 largeCatwalkModel = &m
461 }
462 }
463 for _, m := range smallProviderCfg.Models {
464 if m.ID == smallModelCfg.Model {
465 smallCatwalkModel = &m
466 }
467 }
468
469 if largeCatwalkModel == nil {
470 return Model{}, Model{}, errors.New("large model not found in provider config")
471 }
472
473 if smallCatwalkModel == nil {
474 return Model{}, Model{}, errors.New("snall model not found in provider config")
475 }
476
477 largeModelID := largeModelCfg.Model
478 smallModelID := smallModelCfg.Model
479
480 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
481 largeModelID += ":exacto"
482 }
483
484 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
485 smallModelID += ":exacto"
486 }
487
488 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
489 if err != nil {
490 return Model{}, Model{}, err
491 }
492 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
493 if err != nil {
494 return Model{}, Model{}, err
495 }
496
497 return Model{
498 Model: largeModel,
499 CatwalkCfg: *largeCatwalkModel,
500 ModelCfg: largeModelCfg,
501 }, Model{
502 Model: smallModel,
503 CatwalkCfg: *smallCatwalkModel,
504 ModelCfg: smallModelCfg,
505 }, nil
506}
507
508func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
509 var opts []anthropic.Option
510
511 if strings.HasPrefix(apiKey, "Bearer ") {
512 // NOTE: Prevent the SDK from picking up the API key from env.
513 os.Setenv("ANTHROPIC_API_KEY", "")
514
515 headers["Authorization"] = apiKey
516 } else if apiKey != "" {
517 // X-Api-Key header
518 opts = append(opts, anthropic.WithAPIKey(apiKey))
519 }
520
521 if len(headers) > 0 {
522 opts = append(opts, anthropic.WithHeaders(headers))
523 }
524
525 if baseURL != "" {
526 opts = append(opts, anthropic.WithBaseURL(baseURL))
527 }
528
529 if c.cfg.Options.Debug {
530 httpClient := log.NewHTTPClient()
531 opts = append(opts, anthropic.WithHTTPClient(httpClient))
532 }
533
534 return anthropic.New(opts...)
535}
536
537func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
538 opts := []openai.Option{
539 openai.WithAPIKey(apiKey),
540 openai.WithUseResponsesAPI(),
541 }
542 if c.cfg.Options.Debug {
543 httpClient := log.NewHTTPClient()
544 opts = append(opts, openai.WithHTTPClient(httpClient))
545 }
546 if len(headers) > 0 {
547 opts = append(opts, openai.WithHeaders(headers))
548 }
549 if baseURL != "" {
550 opts = append(opts, openai.WithBaseURL(baseURL))
551 }
552 return openai.New(opts...)
553}
554
555func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
556 opts := []openrouter.Option{
557 openrouter.WithAPIKey(apiKey),
558 }
559 if c.cfg.Options.Debug {
560 httpClient := log.NewHTTPClient()
561 opts = append(opts, openrouter.WithHTTPClient(httpClient))
562 }
563 if len(headers) > 0 {
564 opts = append(opts, openrouter.WithHeaders(headers))
565 }
566 return openrouter.New(opts...)
567}
568
569func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
570 opts := []openaicompat.Option{
571 openaicompat.WithBaseURL(baseURL),
572 openaicompat.WithAPIKey(apiKey),
573 }
574 if c.cfg.Options.Debug {
575 httpClient := log.NewHTTPClient()
576 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
577 }
578 if len(headers) > 0 {
579 opts = append(opts, openaicompat.WithHeaders(headers))
580 }
581
582 for extraKey, extraValue := range extraBody {
583 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
584 }
585
586 return openaicompat.New(opts...)
587}
588
589func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
590 opts := []azure.Option{
591 azure.WithBaseURL(baseURL),
592 azure.WithAPIKey(apiKey),
593 azure.WithUseResponsesAPI(),
594 }
595 if c.cfg.Options.Debug {
596 httpClient := log.NewHTTPClient()
597 opts = append(opts, azure.WithHTTPClient(httpClient))
598 }
599 if options == nil {
600 options = make(map[string]string)
601 }
602 if apiVersion, ok := options["apiVersion"]; ok {
603 opts = append(opts, azure.WithAPIVersion(apiVersion))
604 }
605 if len(headers) > 0 {
606 opts = append(opts, azure.WithHeaders(headers))
607 }
608
609 return azure.New(opts...)
610}
611
612func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
613 var opts []bedrock.Option
614 if c.cfg.Options.Debug {
615 httpClient := log.NewHTTPClient()
616 opts = append(opts, bedrock.WithHTTPClient(httpClient))
617 }
618 if len(headers) > 0 {
619 opts = append(opts, bedrock.WithHeaders(headers))
620 }
621 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
622 if bearerToken != "" {
623 opts = append(opts, bedrock.WithAPIKey(bearerToken))
624 }
625 return bedrock.New(opts...)
626}
627
628func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
629 opts := []google.Option{
630 google.WithBaseURL(baseURL),
631 google.WithGeminiAPIKey(apiKey),
632 }
633 if c.cfg.Options.Debug {
634 httpClient := log.NewHTTPClient()
635 opts = append(opts, google.WithHTTPClient(httpClient))
636 }
637 if len(headers) > 0 {
638 opts = append(opts, google.WithHeaders(headers))
639 }
640 return google.New(opts...)
641}
642
643func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
644 opts := []google.Option{}
645 if c.cfg.Options.Debug {
646 httpClient := log.NewHTTPClient()
647 opts = append(opts, google.WithHTTPClient(httpClient))
648 }
649 if len(headers) > 0 {
650 opts = append(opts, google.WithHeaders(headers))
651 }
652
653 project := options["project"]
654 location := options["location"]
655
656 opts = append(opts, google.WithVertex(project, location))
657
658 return google.New(opts...)
659}
660
661func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
662 if model.Think {
663 return true
664 }
665
666 if model.ProviderOptions == nil {
667 return false
668 }
669
670 opts, err := anthropic.ParseOptions(model.ProviderOptions)
671 if err != nil {
672 return false
673 }
674 if opts.Thinking != nil {
675 return true
676 }
677 return false
678}
679
680func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
681 headers := maps.Clone(providerCfg.ExtraHeaders)
682 if headers == nil {
683 headers = make(map[string]string)
684 }
685
686 // handle special headers for anthropic
687 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
688 if v, ok := headers["anthropic-beta"]; ok {
689 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
690 } else {
691 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
692 }
693 }
694
695 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
696 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
697
698 switch providerCfg.Type {
699 case openai.Name:
700 return c.buildOpenaiProvider(baseURL, apiKey, headers)
701 case anthropic.Name:
702 return c.buildAnthropicProvider(baseURL, apiKey, headers)
703 case openrouter.Name:
704 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
705 case azure.Name:
706 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
707 case bedrock.Name:
708 return c.buildBedrockProvider(headers)
709 case google.Name:
710 return c.buildGoogleProvider(baseURL, apiKey, headers)
711 case "google-vertex":
712 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
713 case openaicompat.Name:
714 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
715 if providerCfg.ExtraBody == nil {
716 providerCfg.ExtraBody = map[string]any{}
717 }
718 providerCfg.ExtraBody["tool_stream"] = true
719 }
720 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
721 default:
722 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
723 }
724}
725
726func isExactoSupported(modelID string) bool {
727 supportedModels := []string{
728 "moonshotai/kimi-k2-0905",
729 "deepseek/deepseek-v3.1-terminus",
730 "z-ai/glm-4.6",
731 "openai/gpt-oss-120b",
732 "qwen/qwen3-coder",
733 }
734 return slices.Contains(supportedModels, modelID)
735}
736
737func (c *coordinator) Cancel(sessionID string) {
738 c.currentAgent.Cancel(sessionID)
739}
740
741func (c *coordinator) CancelAll() {
742 c.currentAgent.CancelAll()
743}
744
745func (c *coordinator) ClearQueue(sessionID string) {
746 c.currentAgent.ClearQueue(sessionID)
747}
748
749func (c *coordinator) IsBusy() bool {
750 return c.currentAgent.IsBusy()
751}
752
753func (c *coordinator) IsSessionBusy(sessionID string) bool {
754 return c.currentAgent.IsSessionBusy(sessionID)
755}
756
757func (c *coordinator) Model() Model {
758 return c.currentAgent.Model()
759}
760
761func (c *coordinator) UpdateModels(ctx context.Context) error {
762 // build the models again so we make sure we get the latest config
763 large, small, err := c.buildAgentModels(ctx)
764 if err != nil {
765 return err
766 }
767 c.currentAgent.SetModels(large, small)
768
769 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
770 if !ok {
771 return errors.New("coder agent not configured")
772 }
773
774 tools, err := c.buildTools(ctx, agentCfg)
775 if err != nil {
776 return err
777 }
778 c.currentAgent.SetTools(tools)
779 return nil
780}
781
782func (c *coordinator) QueuedPrompts(sessionID string) int {
783 return c.currentAgent.QueuedPrompts(sessionID)
784}
785
786func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
787 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
788 if !ok {
789 return errors.New("model provider not configured")
790 }
791 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
792}
793
794func (c *coordinator) isUnauthorized(err error) bool {
795 var providerErr *fantasy.ProviderError
796 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
797}
798
799func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
800 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
801 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
802 return err
803 }
804 if err := c.UpdateModels(ctx); err != nil {
805 return err
806 }
807 return nil
808}
809
810func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
811 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
812 if err != nil {
813 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
814 return err
815 }
816
817 providerCfg.APIKey = newAPIKey
818 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
819
820 if err := c.UpdateModels(ctx); err != nil {
821 return err
822 }
823 return nil
824}