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/permission"
30 "github.com/charmbracelet/crush/internal/session"
31 "golang.org/x/sync/errgroup"
32
33 "charm.land/fantasy/providers/anthropic"
34 "charm.land/fantasy/providers/azure"
35 "charm.land/fantasy/providers/bedrock"
36 "charm.land/fantasy/providers/google"
37 "charm.land/fantasy/providers/openai"
38 "charm.land/fantasy/providers/openaicompat"
39 "charm.land/fantasy/providers/openrouter"
40 openaisdk "github.com/openai/openai-go/v2/option"
41 "github.com/qjebbs/go-jsons"
42)
43
44type Coordinator interface {
45 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
46 // SetMainAgent(string)
47 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
48 Cancel(sessionID string)
49 CancelAll()
50 IsSessionBusy(sessionID string) bool
51 IsBusy() bool
52 QueuedPrompts(sessionID string) int
53 QueuedPromptsList(sessionID string) []string
54 ClearQueue(sessionID string)
55 Summarize(context.Context, string) error
56 Model() Model
57 UpdateModels(ctx context.Context) error
58}
59
60type coordinator struct {
61 cfg *config.Config
62 sessions session.Service
63 messages message.Service
64 permissions permission.Service
65 history history.Service
66 lspClients *csync.Map[string, *lsp.Client]
67
68 currentAgent SessionAgent
69 agents map[string]SessionAgent
70
71 readyWg errgroup.Group
72}
73
74func NewCoordinator(
75 ctx context.Context,
76 cfg *config.Config,
77 sessions session.Service,
78 messages message.Service,
79 permissions permission.Service,
80 history history.Service,
81 lspClients *csync.Map[string, *lsp.Client],
82) (Coordinator, error) {
83 c := &coordinator{
84 cfg: cfg,
85 sessions: sessions,
86 messages: messages,
87 permissions: permissions,
88 history: history,
89 lspClients: lspClients,
90 agents: make(map[string]SessionAgent),
91 }
92
93 agentCfg, ok := cfg.Agents[config.AgentCoder]
94 if !ok {
95 return nil, errors.New("coder agent not configured")
96 }
97
98 // TODO: make this dynamic when we support multiple agents
99 agent, err := c.buildCoderAgent(ctx, agentCfg, false)
100 if err != nil {
101 return nil, err
102 }
103 c.currentAgent = agent
104 c.agents[config.AgentCoder] = agent
105 return c, nil
106}
107
108// Run implements Coordinator.
109func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
110 if err := c.readyWg.Wait(); err != nil {
111 return nil, err
112 }
113
114 model := c.currentAgent.Model()
115 maxTokens := model.CatwalkCfg.DefaultMaxTokens
116 if model.ModelCfg.MaxTokens != 0 {
117 maxTokens = model.ModelCfg.MaxTokens
118 }
119
120 if !model.CatwalkCfg.SupportsImages && attachments != nil {
121 // filter out image attachments
122 filteredAttachments := make([]message.Attachment, 0, len(attachments))
123 for _, att := range attachments {
124 if att.IsText() {
125 filteredAttachments = append(filteredAttachments, att)
126 }
127 }
128 attachments = filteredAttachments
129 }
130
131 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
132 if !ok {
133 return nil, errors.New("model provider not configured")
134 }
135
136 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
137
138 if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
139 slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
140 if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
141 return nil, err
142 }
143 }
144
145 run := func() (*fantasy.AgentResult, error) {
146 return c.currentAgent.Run(ctx, SessionAgentCall{
147 SessionID: sessionID,
148 Prompt: prompt,
149 Attachments: attachments,
150 MaxOutputTokens: maxTokens,
151 ProviderOptions: mergedOptions,
152 Temperature: temp,
153 TopP: topP,
154 TopK: topK,
155 FrequencyPenalty: freqPenalty,
156 PresencePenalty: presPenalty,
157 })
158 }
159 result, originalErr := run()
160
161 if c.isUnauthorized(originalErr) {
162 switch {
163 case providerCfg.OAuthToken != nil:
164 slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
165 if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
166 return nil, originalErr
167 }
168 slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
169 return run()
170 case strings.Contains(providerCfg.APIKeyTemplate, "$"):
171 slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
172 if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
173 return nil, originalErr
174 }
175 slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
176 return run()
177 }
178 }
179
180 return result, originalErr
181}
182
183func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
184 options := fantasy.ProviderOptions{}
185
186 cfgOpts := []byte("{}")
187 providerCfgOpts := []byte("{}")
188 catwalkOpts := []byte("{}")
189
190 if model.ModelCfg.ProviderOptions != nil {
191 data, err := json.Marshal(model.ModelCfg.ProviderOptions)
192 if err == nil {
193 cfgOpts = data
194 }
195 }
196
197 if providerCfg.ProviderOptions != nil {
198 data, err := json.Marshal(providerCfg.ProviderOptions)
199 if err == nil {
200 providerCfgOpts = data
201 }
202 }
203
204 if model.CatwalkCfg.Options.ProviderOptions != nil {
205 data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
206 if err == nil {
207 catwalkOpts = data
208 }
209 }
210
211 readers := []io.Reader{
212 bytes.NewReader(catwalkOpts),
213 bytes.NewReader(providerCfgOpts),
214 bytes.NewReader(cfgOpts),
215 }
216
217 got, err := jsons.Merge(readers)
218 if err != nil {
219 slog.Error("Could not merge call config", "err", err)
220 return options
221 }
222
223 mergedOptions := make(map[string]any)
224
225 err = json.Unmarshal([]byte(got), &mergedOptions)
226 if err != nil {
227 slog.Error("Could not create config for call", "err", err)
228 return options
229 }
230
231 switch providerCfg.Type {
232 case openai.Name, azure.Name:
233 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
234 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
235 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
236 }
237 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
238 if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
239 mergedOptions["reasoning_summary"] = "auto"
240 mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
241 }
242 parsed, err := openai.ParseResponsesOptions(mergedOptions)
243 if err == nil {
244 options[openai.Name] = parsed
245 }
246 } else {
247 parsed, err := openai.ParseOptions(mergedOptions)
248 if err == nil {
249 options[openai.Name] = parsed
250 }
251 }
252 case anthropic.Name:
253 _, hasThink := mergedOptions["thinking"]
254 if !hasThink && model.ModelCfg.Think {
255 mergedOptions["thinking"] = map[string]any{
256 // TODO: kujtim see if we need to make this dynamic
257 "budget_tokens": 2000,
258 }
259 }
260 parsed, err := anthropic.ParseOptions(mergedOptions)
261 if err == nil {
262 options[anthropic.Name] = parsed
263 }
264
265 case openrouter.Name:
266 _, hasReasoning := mergedOptions["reasoning"]
267 if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
268 mergedOptions["reasoning"] = map[string]any{
269 "enabled": true,
270 "effort": model.ModelCfg.ReasoningEffort,
271 }
272 }
273 parsed, err := openrouter.ParseOptions(mergedOptions)
274 if err == nil {
275 options[openrouter.Name] = parsed
276 }
277 case google.Name:
278 _, hasReasoning := mergedOptions["thinking_config"]
279 if !hasReasoning {
280 mergedOptions["thinking_config"] = map[string]any{
281 "thinking_budget": 2000,
282 "include_thoughts": true,
283 }
284 }
285 parsed, err := google.ParseOptions(mergedOptions)
286 if err == nil {
287 options[google.Name] = parsed
288 }
289 case openaicompat.Name:
290 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
291 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
292 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
293 }
294 parsed, err := openaicompat.ParseOptions(mergedOptions)
295 if err == nil {
296 options[openaicompat.Name] = parsed
297 }
298 }
299
300 return options
301}
302
303func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
304 modelOptions := getProviderOptions(model, cfg)
305 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
306 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
307 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
308 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
309 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
310 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
311}
312
313func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
314 large, small, err := c.buildAgentModels(ctx)
315 if err != nil {
316 return nil, err
317 }
318
319 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
320 if err != nil {
321 return nil, err
322 }
323
324 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
325 result := NewSessionAgent(SessionAgentOptions{
326 large,
327 small,
328 largeProviderCfg.SystemPromptPrefix,
329 systemPrompt,
330 isSubAgent,
331 c.cfg.Options.DisableAutoSummarize,
332 c.permissions.SkipRequests(),
333 c.sessions,
334 c.messages,
335 nil,
336 })
337 c.readyWg.Go(func() error {
338 tools, err := c.buildTools(ctx, agent)
339 if err != nil {
340 return err
341 }
342 result.SetTools(tools)
343 return nil
344 })
345
346 return result, nil
347}
348
349func (c *coordinator) buildCoderAgent(ctx context.Context, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
350 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
351 if !ok {
352 return nil, errors.New("large model not selected")
353 }
354
355 promptInstance, err := coderPrompt(largeModelCfg.Model, prompt.WithWorkingDir(c.cfg.WorkingDir()))
356 if err != nil {
357 return nil, err
358 }
359
360 return c.buildAgent(ctx, promptInstance, agent, isSubAgent)
361}
362
363func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
364 var allTools []fantasy.AgentTool
365 if slices.Contains(agent.AllowedTools, AgentToolName) {
366 agentTool, err := c.agentTool(ctx)
367 if err != nil {
368 return nil, err
369 }
370 allTools = append(allTools, agentTool)
371 }
372
373 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
374 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
375 if err != nil {
376 return nil, err
377 }
378 allTools = append(allTools, agenticFetchTool)
379 }
380
381 // Get the model name for the agent
382 modelName := ""
383 if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
384 if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
385 modelName = model.Name
386 }
387 }
388
389 allTools = append(allTools,
390 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
391 tools.NewJobOutputTool(),
392 tools.NewJobKillTool(),
393 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
394 tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
395 tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
396 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
397 tools.NewGlobTool(c.cfg.WorkingDir()),
398 tools.NewGrepTool(c.cfg.WorkingDir()),
399 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
400 tools.NewSourcegraphTool(nil),
401 tools.NewTodosTool(c.sessions),
402 tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
403 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
404 )
405
406 if len(c.cfg.LSP) > 0 {
407 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
408 }
409
410 var filteredTools []fantasy.AgentTool
411 for _, tool := range allTools {
412 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
413 filteredTools = append(filteredTools, tool)
414 }
415 }
416
417 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
418 // Check MCP-specific disabled tools.
419 if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
420 if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
421 continue
422 }
423 }
424 if agent.AllowedMCP == nil {
425 // No MCP restrictions
426 filteredTools = append(filteredTools, tool)
427 continue
428 }
429 if len(agent.AllowedMCP) == 0 {
430 // No MCPs allowed
431 slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
432 break
433 }
434
435 for mcp, tools := range agent.AllowedMCP {
436 if mcp != tool.MCP() {
437 continue
438 }
439 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
440 filteredTools = append(filteredTools, tool)
441 }
442 }
443 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
444 }
445 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
446 return strings.Compare(a.Info().Name, b.Info().Name)
447 })
448 return filteredTools, nil
449}
450
451// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
452func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
453 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
454 if !ok {
455 return Model{}, Model{}, errors.New("large model not selected")
456 }
457 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
458 if !ok {
459 return Model{}, Model{}, errors.New("small model not selected")
460 }
461
462 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
463 if !ok {
464 return Model{}, Model{}, errors.New("large model provider not configured")
465 }
466
467 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
468 if err != nil {
469 return Model{}, Model{}, err
470 }
471
472 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
473 if !ok {
474 return Model{}, Model{}, errors.New("large model provider not configured")
475 }
476
477 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
478 if err != nil {
479 return Model{}, Model{}, err
480 }
481
482 var largeCatwalkModel *catwalk.Model
483 var smallCatwalkModel *catwalk.Model
484
485 for _, m := range largeProviderCfg.Models {
486 if m.ID == largeModelCfg.Model {
487 largeCatwalkModel = &m
488 }
489 }
490 for _, m := range smallProviderCfg.Models {
491 if m.ID == smallModelCfg.Model {
492 smallCatwalkModel = &m
493 }
494 }
495
496 if largeCatwalkModel == nil {
497 return Model{}, Model{}, errors.New("large model not found in provider config")
498 }
499
500 if smallCatwalkModel == nil {
501 return Model{}, Model{}, errors.New("snall model not found in provider config")
502 }
503
504 largeModelID := largeModelCfg.Model
505 smallModelID := smallModelCfg.Model
506
507 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
508 largeModelID += ":exacto"
509 }
510
511 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
512 smallModelID += ":exacto"
513 }
514
515 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
516 if err != nil {
517 return Model{}, Model{}, err
518 }
519 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
520 if err != nil {
521 return Model{}, Model{}, err
522 }
523
524 return Model{
525 Model: largeModel,
526 CatwalkCfg: *largeCatwalkModel,
527 ModelCfg: largeModelCfg,
528 }, Model{
529 Model: smallModel,
530 CatwalkCfg: *smallCatwalkModel,
531 ModelCfg: smallModelCfg,
532 }, nil
533}
534
535func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
536 var opts []anthropic.Option
537
538 if isOauth {
539 // NOTE: Prevent the SDK from picking up the API key from env.
540 os.Setenv("ANTHROPIC_API_KEY", "")
541 headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
542 } else if apiKey != "" {
543 // X-Api-Key header
544 opts = append(opts, anthropic.WithAPIKey(apiKey))
545 }
546
547 if len(headers) > 0 {
548 opts = append(opts, anthropic.WithHeaders(headers))
549 }
550
551 if baseURL != "" {
552 opts = append(opts, anthropic.WithBaseURL(baseURL))
553 }
554
555 if c.cfg.Options.Debug {
556 httpClient := log.NewHTTPClient()
557 opts = append(opts, anthropic.WithHTTPClient(httpClient))
558 }
559 return anthropic.New(opts...)
560}
561
562func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
563 opts := []openai.Option{
564 openai.WithAPIKey(apiKey),
565 openai.WithUseResponsesAPI(),
566 }
567 if c.cfg.Options.Debug {
568 httpClient := log.NewHTTPClient()
569 opts = append(opts, openai.WithHTTPClient(httpClient))
570 }
571 if len(headers) > 0 {
572 opts = append(opts, openai.WithHeaders(headers))
573 }
574 if baseURL != "" {
575 opts = append(opts, openai.WithBaseURL(baseURL))
576 }
577 return openai.New(opts...)
578}
579
580func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
581 opts := []openrouter.Option{
582 openrouter.WithAPIKey(apiKey),
583 }
584 if c.cfg.Options.Debug {
585 httpClient := log.NewHTTPClient()
586 opts = append(opts, openrouter.WithHTTPClient(httpClient))
587 }
588 if len(headers) > 0 {
589 opts = append(opts, openrouter.WithHeaders(headers))
590 }
591 return openrouter.New(opts...)
592}
593
594func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
595 opts := []openaicompat.Option{
596 openaicompat.WithBaseURL(baseURL),
597 openaicompat.WithAPIKey(apiKey),
598 }
599 if c.cfg.Options.Debug {
600 httpClient := log.NewHTTPClient()
601 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
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) (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)
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)
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}