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 }
476 }
477 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
478 }
479 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
480 return strings.Compare(a.Info().Name, b.Info().Name)
481 })
482 return filteredTools, nil
483}
484
485// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
486func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
487 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
488 if !ok {
489 return Model{}, Model{}, errors.New("large model not selected")
490 }
491 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
492 if !ok {
493 return Model{}, Model{}, errors.New("small model not selected")
494 }
495
496 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
497 if !ok {
498 return Model{}, Model{}, errors.New("large model provider not configured")
499 }
500
501 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
502 if err != nil {
503 return Model{}, Model{}, err
504 }
505
506 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
507 if !ok {
508 return Model{}, Model{}, errors.New("large model provider not configured")
509 }
510
511 smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
512 if err != nil {
513 return Model{}, Model{}, err
514 }
515
516 var largeCatwalkModel *catwalk.Model
517 var smallCatwalkModel *catwalk.Model
518
519 for _, m := range largeProviderCfg.Models {
520 if m.ID == largeModelCfg.Model {
521 largeCatwalkModel = &m
522 }
523 }
524 for _, m := range smallProviderCfg.Models {
525 if m.ID == smallModelCfg.Model {
526 smallCatwalkModel = &m
527 }
528 }
529
530 if largeCatwalkModel == nil {
531 return Model{}, Model{}, errors.New("large model not found in provider config")
532 }
533
534 if smallCatwalkModel == nil {
535 return Model{}, Model{}, errors.New("small model not found in provider config")
536 }
537
538 largeModelID := largeModelCfg.Model
539 smallModelID := smallModelCfg.Model
540
541 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
542 largeModelID += ":exacto"
543 }
544
545 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
546 smallModelID += ":exacto"
547 }
548
549 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
550 if err != nil {
551 return Model{}, Model{}, err
552 }
553 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
554 if err != nil {
555 return Model{}, Model{}, err
556 }
557
558 return Model{
559 Model: largeModel,
560 CatwalkCfg: *largeCatwalkModel,
561 ModelCfg: largeModelCfg,
562 }, Model{
563 Model: smallModel,
564 CatwalkCfg: *smallCatwalkModel,
565 ModelCfg: smallModelCfg,
566 }, nil
567}
568
569func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
570 var opts []anthropic.Option
571
572 switch {
573 case strings.HasPrefix(apiKey, "Bearer "):
574 // NOTE: Prevent the SDK from picking up the API key from env.
575 os.Setenv("ANTHROPIC_API_KEY", "")
576 headers["Authorization"] = apiKey
577 case providerID == string(catwalk.InferenceProviderMiniMax):
578 // NOTE: Prevent the SDK from picking up the API key from env.
579 os.Setenv("ANTHROPIC_API_KEY", "")
580 headers["Authorization"] = "Bearer " + apiKey
581 case apiKey != "":
582 // X-Api-Key header
583 opts = append(opts, anthropic.WithAPIKey(apiKey))
584 }
585
586 if len(headers) > 0 {
587 opts = append(opts, anthropic.WithHeaders(headers))
588 }
589
590 if baseURL != "" {
591 opts = append(opts, anthropic.WithBaseURL(baseURL))
592 }
593
594 if c.cfg.Options.Debug {
595 httpClient := log.NewHTTPClient()
596 opts = append(opts, anthropic.WithHTTPClient(httpClient))
597 }
598 return anthropic.New(opts...)
599}
600
601func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
602 opts := []openai.Option{
603 openai.WithAPIKey(apiKey),
604 openai.WithUseResponsesAPI(),
605 }
606 if c.cfg.Options.Debug {
607 httpClient := log.NewHTTPClient()
608 opts = append(opts, openai.WithHTTPClient(httpClient))
609 }
610 if len(headers) > 0 {
611 opts = append(opts, openai.WithHeaders(headers))
612 }
613 if baseURL != "" {
614 opts = append(opts, openai.WithBaseURL(baseURL))
615 }
616 return openai.New(opts...)
617}
618
619func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
620 opts := []openrouter.Option{
621 openrouter.WithAPIKey(apiKey),
622 }
623 if c.cfg.Options.Debug {
624 httpClient := log.NewHTTPClient()
625 opts = append(opts, openrouter.WithHTTPClient(httpClient))
626 }
627 if len(headers) > 0 {
628 opts = append(opts, openrouter.WithHeaders(headers))
629 }
630 return openrouter.New(opts...)
631}
632
633func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
634 opts := []vercel.Option{
635 vercel.WithAPIKey(apiKey),
636 }
637 if c.cfg.Options.Debug {
638 httpClient := log.NewHTTPClient()
639 opts = append(opts, vercel.WithHTTPClient(httpClient))
640 }
641 if len(headers) > 0 {
642 opts = append(opts, vercel.WithHeaders(headers))
643 }
644 return vercel.New(opts...)
645}
646
647func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
648 opts := []openaicompat.Option{
649 openaicompat.WithBaseURL(baseURL),
650 openaicompat.WithAPIKey(apiKey),
651 }
652
653 // Set HTTP client based on provider and debug mode.
654 var httpClient *http.Client
655 if providerID == string(catwalk.InferenceProviderCopilot) {
656 opts = append(opts, openaicompat.WithUseResponsesAPI())
657 httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
658 } else if c.cfg.Options.Debug {
659 httpClient = log.NewHTTPClient()
660 }
661 if httpClient != nil {
662 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
663 }
664
665 if len(headers) > 0 {
666 opts = append(opts, openaicompat.WithHeaders(headers))
667 }
668
669 for extraKey, extraValue := range extraBody {
670 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
671 }
672
673 return openaicompat.New(opts...)
674}
675
676func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
677 opts := []azure.Option{
678 azure.WithBaseURL(baseURL),
679 azure.WithAPIKey(apiKey),
680 azure.WithUseResponsesAPI(),
681 }
682 if c.cfg.Options.Debug {
683 httpClient := log.NewHTTPClient()
684 opts = append(opts, azure.WithHTTPClient(httpClient))
685 }
686 if options == nil {
687 options = make(map[string]string)
688 }
689 if apiVersion, ok := options["apiVersion"]; ok {
690 opts = append(opts, azure.WithAPIVersion(apiVersion))
691 }
692 if len(headers) > 0 {
693 opts = append(opts, azure.WithHeaders(headers))
694 }
695
696 return azure.New(opts...)
697}
698
699func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
700 var opts []bedrock.Option
701 if c.cfg.Options.Debug {
702 httpClient := log.NewHTTPClient()
703 opts = append(opts, bedrock.WithHTTPClient(httpClient))
704 }
705 if len(headers) > 0 {
706 opts = append(opts, bedrock.WithHeaders(headers))
707 }
708 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
709 if bearerToken != "" {
710 opts = append(opts, bedrock.WithAPIKey(bearerToken))
711 }
712 return bedrock.New(opts...)
713}
714
715func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
716 opts := []google.Option{
717 google.WithBaseURL(baseURL),
718 google.WithGeminiAPIKey(apiKey),
719 }
720 if c.cfg.Options.Debug {
721 httpClient := log.NewHTTPClient()
722 opts = append(opts, google.WithHTTPClient(httpClient))
723 }
724 if len(headers) > 0 {
725 opts = append(opts, google.WithHeaders(headers))
726 }
727 return google.New(opts...)
728}
729
730func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
731 opts := []google.Option{}
732 if c.cfg.Options.Debug {
733 httpClient := log.NewHTTPClient()
734 opts = append(opts, google.WithHTTPClient(httpClient))
735 }
736 if len(headers) > 0 {
737 opts = append(opts, google.WithHeaders(headers))
738 }
739
740 project := options["project"]
741 location := options["location"]
742
743 opts = append(opts, google.WithVertex(project, location))
744
745 return google.New(opts...)
746}
747
748func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
749 opts := []hyper.Option{
750 hyper.WithBaseURL(baseURL),
751 hyper.WithAPIKey(apiKey),
752 }
753 if c.cfg.Options.Debug {
754 httpClient := log.NewHTTPClient()
755 opts = append(opts, hyper.WithHTTPClient(httpClient))
756 }
757 return hyper.New(opts...)
758}
759
760func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
761 if model.Think {
762 return true
763 }
764
765 if model.ProviderOptions == nil {
766 return false
767 }
768
769 opts, err := anthropic.ParseOptions(model.ProviderOptions)
770 if err != nil {
771 return false
772 }
773 if opts.Thinking != nil {
774 return true
775 }
776 return false
777}
778
779func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
780 headers := maps.Clone(providerCfg.ExtraHeaders)
781 if headers == nil {
782 headers = make(map[string]string)
783 }
784
785 // handle special headers for anthropic
786 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
787 if v, ok := headers["anthropic-beta"]; ok {
788 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
789 } else {
790 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
791 }
792 }
793
794 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
795 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
796
797 switch providerCfg.Type {
798 case openai.Name:
799 return c.buildOpenaiProvider(baseURL, apiKey, headers)
800 case anthropic.Name:
801 return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
802 case openrouter.Name:
803 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
804 case vercel.Name:
805 return c.buildVercelProvider(baseURL, apiKey, headers)
806 case azure.Name:
807 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
808 case bedrock.Name:
809 return c.buildBedrockProvider(headers)
810 case google.Name:
811 return c.buildGoogleProvider(baseURL, apiKey, headers)
812 case "google-vertex":
813 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
814 case openaicompat.Name:
815 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
816 if providerCfg.ExtraBody == nil {
817 providerCfg.ExtraBody = map[string]any{}
818 }
819 providerCfg.ExtraBody["tool_stream"] = true
820 }
821 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
822 case hyper.Name:
823 return c.buildHyperProvider(baseURL, apiKey)
824 default:
825 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
826 }
827}
828
829func isExactoSupported(modelID string) bool {
830 supportedModels := []string{
831 "moonshotai/kimi-k2-0905",
832 "deepseek/deepseek-v3.1-terminus",
833 "z-ai/glm-4.6",
834 "openai/gpt-oss-120b",
835 "qwen/qwen3-coder",
836 }
837 return slices.Contains(supportedModels, modelID)
838}
839
840func (c *coordinator) Cancel(sessionID string) {
841 c.currentAgent.Cancel(sessionID)
842}
843
844func (c *coordinator) CancelAll() {
845 c.currentAgent.CancelAll()
846}
847
848func (c *coordinator) ClearQueue(sessionID string) {
849 c.currentAgent.ClearQueue(sessionID)
850}
851
852func (c *coordinator) IsBusy() bool {
853 return c.currentAgent.IsBusy()
854}
855
856func (c *coordinator) IsSessionBusy(sessionID string) bool {
857 return c.currentAgent.IsSessionBusy(sessionID)
858}
859
860func (c *coordinator) Model() Model {
861 return c.currentAgent.Model()
862}
863
864func (c *coordinator) UpdateModels(ctx context.Context) error {
865 // build the models again so we make sure we get the latest config
866 large, small, err := c.buildAgentModels(ctx, false)
867 if err != nil {
868 return err
869 }
870 c.currentAgent.SetModels(large, small)
871
872 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
873 if !ok {
874 return errors.New("coder agent not configured")
875 }
876
877 tools, err := c.buildTools(ctx, agentCfg)
878 if err != nil {
879 return err
880 }
881 c.currentAgent.SetTools(tools)
882 return nil
883}
884
885func (c *coordinator) QueuedPrompts(sessionID string) int {
886 return c.currentAgent.QueuedPrompts(sessionID)
887}
888
889func (c *coordinator) QueuedPromptsList(sessionID string) []string {
890 return c.currentAgent.QueuedPromptsList(sessionID)
891}
892
893func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
894 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
895 if !ok {
896 return errors.New("model provider not configured")
897 }
898 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
899}
900
901func (c *coordinator) isUnauthorized(err error) bool {
902 var providerErr *fantasy.ProviderError
903 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
904}
905
906func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
907 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
908 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
909 return err
910 }
911 if err := c.UpdateModels(ctx); err != nil {
912 return err
913 }
914 return nil
915}
916
917func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
918 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
919 if err != nil {
920 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
921 return err
922 }
923
924 providerCfg.APIKey = newAPIKey
925 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
926
927 if err := c.UpdateModels(ctx); err != nil {
928 return err
929 }
930 return nil
931}