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 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, providerCfg.OAuthToken != nil)
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}