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