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