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