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