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 mergedOptions["thinking_config"] = map[string]any{
321 "thinking_budget": 2000,
322 "include_thoughts": true,
323 }
324 }
325 parsed, err := google.ParseOptions(mergedOptions)
326 if err == nil {
327 options[google.Name] = parsed
328 }
329 case openaicompat.Name:
330 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
331 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
332 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
333 }
334 parsed, err := openaicompat.ParseOptions(mergedOptions)
335 if err == nil {
336 options[openaicompat.Name] = parsed
337 }
338 }
339
340 return options
341}
342
343func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
344 modelOptions := getProviderOptions(model, cfg)
345 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
346 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
347 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
348 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
349 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
350 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
351}
352
353func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
354 large, small, err := c.buildAgentModels(ctx, isSubAgent)
355 if err != nil {
356 return nil, err
357 }
358
359 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
360 result := NewSessionAgent(SessionAgentOptions{
361 large,
362 small,
363 largeProviderCfg.SystemPromptPrefix,
364 "",
365 isSubAgent,
366 c.cfg.Options.DisableAutoSummarize,
367 c.permissions.SkipRequests(),
368 c.sessions,
369 c.messages,
370 nil,
371 })
372
373 c.readyWg.Go(func() error {
374 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
375 if err != nil {
376 return err
377 }
378 result.SetSystemPrompt(systemPrompt)
379 return nil
380 })
381
382 c.readyWg.Go(func() error {
383 tools, err := c.buildTools(ctx, agent)
384 if err != nil {
385 return err
386 }
387 result.SetTools(tools)
388 return nil
389 })
390
391 return result, nil
392}
393
394func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
395 var allTools []fantasy.AgentTool
396 if slices.Contains(agent.AllowedTools, AgentToolName) {
397 agentTool, err := c.agentTool(ctx)
398 if err != nil {
399 return nil, err
400 }
401 allTools = append(allTools, agentTool)
402 }
403
404 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
405 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
406 if err != nil {
407 return nil, err
408 }
409 allTools = append(allTools, agenticFetchTool)
410 }
411
412 // Get the model name for the agent
413 modelName := ""
414 if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
415 if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
416 modelName = model.Name
417 }
418 }
419
420 allTools = append(allTools,
421 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
422 tools.NewJobOutputTool(),
423 tools.NewJobKillTool(),
424 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
425 tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
426 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
427 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
428 tools.NewGlobTool(c.cfg.WorkingDir()),
429 tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
430 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
431 tools.NewSourcegraphTool(nil),
432 tools.NewTodosTool(c.sessions),
433 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
434 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
435 )
436
437 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
438 if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
439 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
440 }
441
442 if len(c.cfg.MCP) > 0 {
443 allTools = append(
444 allTools,
445 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
446 tools.NewReadMCPResourceTool(c.cfg, c.permissions),
447 )
448 }
449
450 var filteredTools []fantasy.AgentTool
451 for _, tool := range allTools {
452 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
453 filteredTools = append(filteredTools, tool)
454 }
455 }
456
457 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
458 if agent.AllowedMCP == nil {
459 // No MCP restrictions
460 filteredTools = append(filteredTools, tool)
461 continue
462 }
463 if len(agent.AllowedMCP) == 0 {
464 // No MCPs allowed
465 slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
466 break
467 }
468
469 for mcp, tools := range agent.AllowedMCP {
470 if mcp != tool.MCP() {
471 continue
472 }
473 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
474 filteredTools = append(filteredTools, tool)
475 break
476 }
477 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
478 }
479 }
480 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
481 return strings.Compare(a.Info().Name, b.Info().Name)
482 })
483 return filteredTools, nil
484}
485
486// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
487func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
488 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
489 if !ok {
490 return Model{}, Model{}, errors.New("large model not selected")
491 }
492 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
493 if !ok {
494 return Model{}, Model{}, errors.New("small model not selected")
495 }
496
497 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
498 if !ok {
499 return Model{}, Model{}, errors.New("large model provider not configured")
500 }
501
502 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
503 if err != nil {
504 return Model{}, Model{}, err
505 }
506
507 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
508 if !ok {
509 return Model{}, Model{}, errors.New("small model provider not configured")
510 }
511
512 smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
513 if err != nil {
514 return Model{}, Model{}, err
515 }
516
517 var largeCatwalkModel *catwalk.Model
518 var smallCatwalkModel *catwalk.Model
519
520 for _, m := range largeProviderCfg.Models {
521 if m.ID == largeModelCfg.Model {
522 largeCatwalkModel = &m
523 }
524 }
525 for _, m := range smallProviderCfg.Models {
526 if m.ID == smallModelCfg.Model {
527 smallCatwalkModel = &m
528 }
529 }
530
531 if largeCatwalkModel == nil {
532 return Model{}, Model{}, errors.New("large model not found in provider config")
533 }
534
535 if smallCatwalkModel == nil {
536 return Model{}, Model{}, errors.New("small model not found in provider config")
537 }
538
539 largeModelID := largeModelCfg.Model
540 smallModelID := smallModelCfg.Model
541
542 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
543 largeModelID += ":exacto"
544 }
545
546 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
547 smallModelID += ":exacto"
548 }
549
550 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
551 if err != nil {
552 return Model{}, Model{}, err
553 }
554 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
555 if err != nil {
556 return Model{}, Model{}, err
557 }
558
559 return Model{
560 Model: largeModel,
561 CatwalkCfg: *largeCatwalkModel,
562 ModelCfg: largeModelCfg,
563 }, Model{
564 Model: smallModel,
565 CatwalkCfg: *smallCatwalkModel,
566 ModelCfg: smallModelCfg,
567 }, nil
568}
569
570func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
571 var opts []anthropic.Option
572
573 switch {
574 case strings.HasPrefix(apiKey, "Bearer "):
575 // NOTE: Prevent the SDK from picking up the API key from env.
576 os.Setenv("ANTHROPIC_API_KEY", "")
577 headers["Authorization"] = apiKey
578 case providerID == string(catwalk.InferenceProviderMiniMax):
579 // NOTE: Prevent the SDK from picking up the API key from env.
580 os.Setenv("ANTHROPIC_API_KEY", "")
581 headers["Authorization"] = "Bearer " + apiKey
582 case apiKey != "":
583 // X-Api-Key header
584 opts = append(opts, anthropic.WithAPIKey(apiKey))
585 }
586
587 if len(headers) > 0 {
588 opts = append(opts, anthropic.WithHeaders(headers))
589 }
590
591 if baseURL != "" {
592 opts = append(opts, anthropic.WithBaseURL(baseURL))
593 }
594
595 if c.cfg.Options.Debug {
596 httpClient := log.NewHTTPClient()
597 opts = append(opts, anthropic.WithHTTPClient(httpClient))
598 }
599 return anthropic.New(opts...)
600}
601
602func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
603 opts := []openai.Option{
604 openai.WithAPIKey(apiKey),
605 openai.WithUseResponsesAPI(),
606 }
607 if c.cfg.Options.Debug {
608 httpClient := log.NewHTTPClient()
609 opts = append(opts, openai.WithHTTPClient(httpClient))
610 }
611 if len(headers) > 0 {
612 opts = append(opts, openai.WithHeaders(headers))
613 }
614 if baseURL != "" {
615 opts = append(opts, openai.WithBaseURL(baseURL))
616 }
617 return openai.New(opts...)
618}
619
620func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
621 opts := []openrouter.Option{
622 openrouter.WithAPIKey(apiKey),
623 }
624 if c.cfg.Options.Debug {
625 httpClient := log.NewHTTPClient()
626 opts = append(opts, openrouter.WithHTTPClient(httpClient))
627 }
628 if len(headers) > 0 {
629 opts = append(opts, openrouter.WithHeaders(headers))
630 }
631 return openrouter.New(opts...)
632}
633
634func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
635 opts := []vercel.Option{
636 vercel.WithAPIKey(apiKey),
637 }
638 if c.cfg.Options.Debug {
639 httpClient := log.NewHTTPClient()
640 opts = append(opts, vercel.WithHTTPClient(httpClient))
641 }
642 if len(headers) > 0 {
643 opts = append(opts, vercel.WithHeaders(headers))
644 }
645 return vercel.New(opts...)
646}
647
648func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
649 opts := []openaicompat.Option{
650 openaicompat.WithBaseURL(baseURL),
651 openaicompat.WithAPIKey(apiKey),
652 }
653
654 // Set HTTP client based on provider and debug mode.
655 var httpClient *http.Client
656 if providerID == string(catwalk.InferenceProviderCopilot) {
657 opts = append(opts, openaicompat.WithUseResponsesAPI())
658 httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
659 } else if c.cfg.Options.Debug {
660 httpClient = log.NewHTTPClient()
661 }
662 if httpClient != nil {
663 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
664 }
665
666 if len(headers) > 0 {
667 opts = append(opts, openaicompat.WithHeaders(headers))
668 }
669
670 for extraKey, extraValue := range extraBody {
671 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
672 }
673
674 return openaicompat.New(opts...)
675}
676
677func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
678 opts := []azure.Option{
679 azure.WithBaseURL(baseURL),
680 azure.WithAPIKey(apiKey),
681 azure.WithUseResponsesAPI(),
682 }
683 if c.cfg.Options.Debug {
684 httpClient := log.NewHTTPClient()
685 opts = append(opts, azure.WithHTTPClient(httpClient))
686 }
687 if options == nil {
688 options = make(map[string]string)
689 }
690 if apiVersion, ok := options["apiVersion"]; ok {
691 opts = append(opts, azure.WithAPIVersion(apiVersion))
692 }
693 if len(headers) > 0 {
694 opts = append(opts, azure.WithHeaders(headers))
695 }
696
697 return azure.New(opts...)
698}
699
700func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
701 var opts []bedrock.Option
702 if c.cfg.Options.Debug {
703 httpClient := log.NewHTTPClient()
704 opts = append(opts, bedrock.WithHTTPClient(httpClient))
705 }
706 if len(headers) > 0 {
707 opts = append(opts, bedrock.WithHeaders(headers))
708 }
709 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
710 if bearerToken != "" {
711 opts = append(opts, bedrock.WithAPIKey(bearerToken))
712 }
713 return bedrock.New(opts...)
714}
715
716func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
717 opts := []google.Option{
718 google.WithBaseURL(baseURL),
719 google.WithGeminiAPIKey(apiKey),
720 }
721 if c.cfg.Options.Debug {
722 httpClient := log.NewHTTPClient()
723 opts = append(opts, google.WithHTTPClient(httpClient))
724 }
725 if len(headers) > 0 {
726 opts = append(opts, google.WithHeaders(headers))
727 }
728 return google.New(opts...)
729}
730
731func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
732 opts := []google.Option{}
733 if c.cfg.Options.Debug {
734 httpClient := log.NewHTTPClient()
735 opts = append(opts, google.WithHTTPClient(httpClient))
736 }
737 if len(headers) > 0 {
738 opts = append(opts, google.WithHeaders(headers))
739 }
740
741 project := options["project"]
742 location := options["location"]
743
744 opts = append(opts, google.WithVertex(project, location))
745
746 return google.New(opts...)
747}
748
749func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
750 opts := []hyper.Option{
751 hyper.WithBaseURL(baseURL),
752 hyper.WithAPIKey(apiKey),
753 }
754 if c.cfg.Options.Debug {
755 httpClient := log.NewHTTPClient()
756 opts = append(opts, hyper.WithHTTPClient(httpClient))
757 }
758 return hyper.New(opts...)
759}
760
761func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
762 if model.Think {
763 return true
764 }
765
766 if model.ProviderOptions == nil {
767 return false
768 }
769
770 opts, err := anthropic.ParseOptions(model.ProviderOptions)
771 if err != nil {
772 return false
773 }
774 if opts.Thinking != nil {
775 return true
776 }
777 return false
778}
779
780func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
781 headers := maps.Clone(providerCfg.ExtraHeaders)
782 if headers == nil {
783 headers = make(map[string]string)
784 }
785
786 // handle special headers for anthropic
787 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
788 if v, ok := headers["anthropic-beta"]; ok {
789 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
790 } else {
791 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
792 }
793 }
794
795 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
796 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
797
798 switch providerCfg.Type {
799 case openai.Name:
800 return c.buildOpenaiProvider(baseURL, apiKey, headers)
801 case anthropic.Name:
802 return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
803 case openrouter.Name:
804 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
805 case vercel.Name:
806 return c.buildVercelProvider(baseURL, apiKey, headers)
807 case azure.Name:
808 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
809 case bedrock.Name:
810 return c.buildBedrockProvider(headers)
811 case google.Name:
812 return c.buildGoogleProvider(baseURL, apiKey, headers)
813 case "google-vertex":
814 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
815 case openaicompat.Name:
816 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
817 if providerCfg.ExtraBody == nil {
818 providerCfg.ExtraBody = map[string]any{}
819 }
820 providerCfg.ExtraBody["tool_stream"] = true
821 }
822 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
823 case hyper.Name:
824 return c.buildHyperProvider(baseURL, apiKey)
825 default:
826 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
827 }
828}
829
830func isExactoSupported(modelID string) bool {
831 supportedModels := []string{
832 "moonshotai/kimi-k2-0905",
833 "deepseek/deepseek-v3.1-terminus",
834 "z-ai/glm-4.6",
835 "openai/gpt-oss-120b",
836 "qwen/qwen3-coder",
837 }
838 return slices.Contains(supportedModels, modelID)
839}
840
841func (c *coordinator) Cancel(sessionID string) {
842 c.currentAgent.Cancel(sessionID)
843}
844
845func (c *coordinator) CancelAll() {
846 c.currentAgent.CancelAll()
847}
848
849func (c *coordinator) ClearQueue(sessionID string) {
850 c.currentAgent.ClearQueue(sessionID)
851}
852
853func (c *coordinator) IsBusy() bool {
854 return c.currentAgent.IsBusy()
855}
856
857func (c *coordinator) IsSessionBusy(sessionID string) bool {
858 return c.currentAgent.IsSessionBusy(sessionID)
859}
860
861func (c *coordinator) Model() Model {
862 return c.currentAgent.Model()
863}
864
865func (c *coordinator) UpdateModels(ctx context.Context) error {
866 // build the models again so we make sure we get the latest config
867 large, small, err := c.buildAgentModels(ctx, false)
868 if err != nil {
869 return err
870 }
871 c.currentAgent.SetModels(large, small)
872
873 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
874 if !ok {
875 return errors.New("coder agent not configured")
876 }
877
878 tools, err := c.buildTools(ctx, agentCfg)
879 if err != nil {
880 return err
881 }
882 c.currentAgent.SetTools(tools)
883 return nil
884}
885
886func (c *coordinator) QueuedPrompts(sessionID string) int {
887 return c.currentAgent.QueuedPrompts(sessionID)
888}
889
890func (c *coordinator) QueuedPromptsList(sessionID string) []string {
891 return c.currentAgent.QueuedPromptsList(sessionID)
892}
893
894func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
895 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
896 if !ok {
897 return errors.New("model provider not configured")
898 }
899 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
900}
901
902func (c *coordinator) isUnauthorized(err error) bool {
903 var providerErr *fantasy.ProviderError
904 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
905}
906
907func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
908 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
909 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
910 return err
911 }
912 if err := c.UpdateModels(ctx); err != nil {
913 return err
914 }
915 return nil
916}
917
918func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
919 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
920 if err != nil {
921 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
922 return err
923 }
924
925 providerCfg.APIKey = newAPIKey
926 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
927
928 if err := c.UpdateModels(ctx); err != nil {
929 return err
930 }
931 return nil
932}