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