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