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) (fantasy.Provider, error) {
520 var opts []anthropic.Option
521
522 if strings.HasPrefix(apiKey, "Bearer ") {
523 // NOTE: Prevent the SDK from picking up the API key from env.
524 os.Setenv("ANTHROPIC_API_KEY", "")
525
526 headers["Authorization"] = apiKey
527 } else if apiKey != "" {
528 // X-Api-Key header
529 opts = append(opts, anthropic.WithAPIKey(apiKey))
530 }
531
532 if len(headers) > 0 {
533 opts = append(opts, anthropic.WithHeaders(headers))
534 }
535
536 if baseURL != "" {
537 opts = append(opts, anthropic.WithBaseURL(baseURL))
538 }
539
540 if c.cfg.Options.Debug {
541 httpClient := log.NewHTTPClient()
542 opts = append(opts, anthropic.WithHTTPClient(httpClient))
543 }
544
545 return anthropic.New(opts...)
546}
547
548func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
549 opts := []openai.Option{
550 openai.WithAPIKey(apiKey),
551 openai.WithUseResponsesAPI(),
552 }
553 if c.cfg.Options.Debug {
554 httpClient := log.NewHTTPClient()
555 opts = append(opts, openai.WithHTTPClient(httpClient))
556 }
557 if len(headers) > 0 {
558 opts = append(opts, openai.WithHeaders(headers))
559 }
560 if baseURL != "" {
561 opts = append(opts, openai.WithBaseURL(baseURL))
562 }
563 return openai.New(opts...)
564}
565
566func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
567 opts := []openrouter.Option{
568 openrouter.WithAPIKey(apiKey),
569 }
570 if c.cfg.Options.Debug {
571 httpClient := log.NewHTTPClient()
572 opts = append(opts, openrouter.WithHTTPClient(httpClient))
573 }
574 if len(headers) > 0 {
575 opts = append(opts, openrouter.WithHeaders(headers))
576 }
577 return openrouter.New(opts...)
578}
579
580func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
581 opts := []openaicompat.Option{
582 openaicompat.WithBaseURL(baseURL),
583 openaicompat.WithAPIKey(apiKey),
584 }
585 if c.cfg.Options.Debug {
586 httpClient := log.NewHTTPClient()
587 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
588 }
589 if len(headers) > 0 {
590 opts = append(opts, openaicompat.WithHeaders(headers))
591 }
592
593 for extraKey, extraValue := range extraBody {
594 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
595 }
596
597 return openaicompat.New(opts...)
598}
599
600func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
601 opts := []azure.Option{
602 azure.WithBaseURL(baseURL),
603 azure.WithAPIKey(apiKey),
604 azure.WithUseResponsesAPI(),
605 }
606 if c.cfg.Options.Debug {
607 httpClient := log.NewHTTPClient()
608 opts = append(opts, azure.WithHTTPClient(httpClient))
609 }
610 if options == nil {
611 options = make(map[string]string)
612 }
613 if apiVersion, ok := options["apiVersion"]; ok {
614 opts = append(opts, azure.WithAPIVersion(apiVersion))
615 }
616 if len(headers) > 0 {
617 opts = append(opts, azure.WithHeaders(headers))
618 }
619
620 return azure.New(opts...)
621}
622
623func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
624 var opts []bedrock.Option
625 if c.cfg.Options.Debug {
626 httpClient := log.NewHTTPClient()
627 opts = append(opts, bedrock.WithHTTPClient(httpClient))
628 }
629 if len(headers) > 0 {
630 opts = append(opts, bedrock.WithHeaders(headers))
631 }
632 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
633 if bearerToken != "" {
634 opts = append(opts, bedrock.WithAPIKey(bearerToken))
635 }
636 return bedrock.New(opts...)
637}
638
639func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
640 opts := []google.Option{
641 google.WithBaseURL(baseURL),
642 google.WithGeminiAPIKey(apiKey),
643 }
644 if c.cfg.Options.Debug {
645 httpClient := log.NewHTTPClient()
646 opts = append(opts, google.WithHTTPClient(httpClient))
647 }
648 if len(headers) > 0 {
649 opts = append(opts, google.WithHeaders(headers))
650 }
651 return google.New(opts...)
652}
653
654func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
655 opts := []google.Option{}
656 if c.cfg.Options.Debug {
657 httpClient := log.NewHTTPClient()
658 opts = append(opts, google.WithHTTPClient(httpClient))
659 }
660 if len(headers) > 0 {
661 opts = append(opts, google.WithHeaders(headers))
662 }
663
664 project := options["project"]
665 location := options["location"]
666
667 opts = append(opts, google.WithVertex(project, location))
668
669 return google.New(opts...)
670}
671
672func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
673 opts := []hyper.Option{
674 hyper.WithBaseURL(baseURL),
675 hyper.WithAPIKey(apiKey),
676 }
677 if c.cfg.Options.Debug {
678 httpClient := log.NewHTTPClient()
679 opts = append(opts, hyper.WithHTTPClient(httpClient))
680 }
681 return hyper.New(opts...)
682}
683
684func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
685 if model.Think {
686 return true
687 }
688
689 if model.ProviderOptions == nil {
690 return false
691 }
692
693 opts, err := anthropic.ParseOptions(model.ProviderOptions)
694 if err != nil {
695 return false
696 }
697 if opts.Thinking != nil {
698 return true
699 }
700 return false
701}
702
703func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
704 headers := maps.Clone(providerCfg.ExtraHeaders)
705 if headers == nil {
706 headers = make(map[string]string)
707 }
708
709 // handle special headers for anthropic
710 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
711 if v, ok := headers["anthropic-beta"]; ok {
712 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
713 } else {
714 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
715 }
716 }
717
718 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
719 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
720
721 switch providerCfg.Type {
722 case openai.Name:
723 return c.buildOpenaiProvider(baseURL, apiKey, headers)
724 case anthropic.Name:
725 return c.buildAnthropicProvider(baseURL, apiKey, headers)
726 case openrouter.Name:
727 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
728 case azure.Name:
729 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
730 case bedrock.Name:
731 return c.buildBedrockProvider(headers)
732 case google.Name:
733 return c.buildGoogleProvider(baseURL, apiKey, headers)
734 case "google-vertex":
735 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
736 case openaicompat.Name:
737 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
738 if providerCfg.ExtraBody == nil {
739 providerCfg.ExtraBody = map[string]any{}
740 }
741 providerCfg.ExtraBody["tool_stream"] = true
742 }
743 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
744 case hyper.Name:
745 return c.buildHyperProvider(baseURL, apiKey)
746 default:
747 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
748 }
749}
750
751func isExactoSupported(modelID string) bool {
752 supportedModels := []string{
753 "moonshotai/kimi-k2-0905",
754 "deepseek/deepseek-v3.1-terminus",
755 "z-ai/glm-4.6",
756 "openai/gpt-oss-120b",
757 "qwen/qwen3-coder",
758 }
759 return slices.Contains(supportedModels, modelID)
760}
761
762func (c *coordinator) Cancel(sessionID string) {
763 c.currentAgent.Cancel(sessionID)
764}
765
766func (c *coordinator) CancelAll() {
767 c.currentAgent.CancelAll()
768}
769
770func (c *coordinator) ClearQueue(sessionID string) {
771 c.currentAgent.ClearQueue(sessionID)
772}
773
774func (c *coordinator) IsBusy() bool {
775 return c.currentAgent.IsBusy()
776}
777
778func (c *coordinator) IsSessionBusy(sessionID string) bool {
779 return c.currentAgent.IsSessionBusy(sessionID)
780}
781
782func (c *coordinator) Model() Model {
783 return c.currentAgent.Model()
784}
785
786func (c *coordinator) UpdateModels(ctx context.Context) error {
787 // build the models again so we make sure we get the latest config
788 large, small, err := c.buildAgentModels(ctx)
789 if err != nil {
790 return err
791 }
792 c.currentAgent.SetModels(large, small)
793
794 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
795 if !ok {
796 return errors.New("coder agent not configured")
797 }
798
799 tools, err := c.buildTools(ctx, agentCfg)
800 if err != nil {
801 return err
802 }
803 c.currentAgent.SetTools(tools)
804 return nil
805}
806
807func (c *coordinator) QueuedPrompts(sessionID string) int {
808 return c.currentAgent.QueuedPrompts(sessionID)
809}
810
811func (c *coordinator) QueuedPromptsList(sessionID string) []string {
812 return c.currentAgent.QueuedPromptsList(sessionID)
813}
814
815func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
816 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
817 if !ok {
818 return errors.New("model provider not configured")
819 }
820 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
821}
822
823func (c *coordinator) isUnauthorized(err error) bool {
824 var providerErr *fantasy.ProviderError
825 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
826}
827
828func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
829 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
830 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
831 return err
832 }
833 if err := c.UpdateModels(ctx); err != nil {
834 return err
835 }
836 return nil
837}
838
839func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
840 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
841 if err != nil {
842 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
843 return err
844 }
845
846 providerCfg.APIKey = newAPIKey
847 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
848
849 if err := c.UpdateModels(ctx); err != nil {
850 return err
851 }
852 return nil
853}