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