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