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