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