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