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