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 RefreshTools(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 lspManager *lsp.Manager
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 lspManager *lsp.Manager,
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 lspManager: lspManager,
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.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
427 tools.NewMultiEditTool(c.lspManager, 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(), c.cfg.Tools.Grep),
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.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
435 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
436 )
437
438 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
439 if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
440 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
441 }
442
443 if len(c.cfg.MCP) > 0 {
444 allTools = append(
445 allTools,
446 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
447 tools.NewReadMCPResourceTool(c.cfg, c.permissions),
448 )
449 }
450
451 var filteredTools []fantasy.AgentTool
452 for _, tool := range allTools {
453 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
454 filteredTools = append(filteredTools, tool)
455 }
456 }
457
458 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
459 if agent.AllowedMCP == nil {
460 // No MCP restrictions
461 filteredTools = append(filteredTools, tool)
462 continue
463 }
464 if len(agent.AllowedMCP) == 0 {
465 // No MCPs allowed
466 slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
467 break
468 }
469
470 for mcp, tools := range agent.AllowedMCP {
471 if mcp != tool.MCP() {
472 continue
473 }
474 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
475 filteredTools = append(filteredTools, tool)
476 }
477 }
478 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
479 }
480 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
481 return strings.Compare(a.Info().Name, b.Info().Name)
482 })
483 return filteredTools, nil
484}
485
486// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
487func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
488 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
489 if !ok {
490 return Model{}, Model{}, errors.New("large model not selected")
491 }
492 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
493 if !ok {
494 return Model{}, Model{}, errors.New("small model not selected")
495 }
496
497 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
498 if !ok {
499 return Model{}, Model{}, errors.New("large model provider not configured")
500 }
501
502 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
503 if err != nil {
504 return Model{}, Model{}, err
505 }
506
507 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
508 if !ok {
509 return Model{}, Model{}, errors.New("large model provider not configured")
510 }
511
512 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg, true)
513 if err != nil {
514 return Model{}, Model{}, err
515 }
516
517 var largeCatwalkModel *catwalk.Model
518 var smallCatwalkModel *catwalk.Model
519
520 for _, m := range largeProviderCfg.Models {
521 if m.ID == largeModelCfg.Model {
522 largeCatwalkModel = &m
523 }
524 }
525 for _, m := range smallProviderCfg.Models {
526 if m.ID == smallModelCfg.Model {
527 smallCatwalkModel = &m
528 }
529 }
530
531 if largeCatwalkModel == nil {
532 return Model{}, Model{}, errors.New("large model not found in provider config")
533 }
534
535 if smallCatwalkModel == nil {
536 return Model{}, Model{}, errors.New("small model not found in provider config")
537 }
538
539 largeModelID := largeModelCfg.Model
540 smallModelID := smallModelCfg.Model
541
542 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
543 largeModelID += ":exacto"
544 }
545
546 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
547 smallModelID += ":exacto"
548 }
549
550 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
551 if err != nil {
552 return Model{}, Model{}, err
553 }
554 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
555 if err != nil {
556 return Model{}, Model{}, err
557 }
558
559 return Model{
560 Model: largeModel,
561 CatwalkCfg: *largeCatwalkModel,
562 ModelCfg: largeModelCfg,
563 }, Model{
564 Model: smallModel,
565 CatwalkCfg: *smallCatwalkModel,
566 ModelCfg: smallModelCfg,
567 }, nil
568}
569
570func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
571 var opts []anthropic.Option
572
573 if strings.HasPrefix(apiKey, "Bearer ") {
574 // NOTE: Prevent the SDK from picking up the API key from env.
575 os.Setenv("ANTHROPIC_API_KEY", "")
576 headers["Authorization"] = apiKey
577 } else if apiKey != "" {
578 // X-Api-Key header
579 opts = append(opts, anthropic.WithAPIKey(apiKey))
580 }
581
582 if len(headers) > 0 {
583 opts = append(opts, anthropic.WithHeaders(headers))
584 }
585
586 if baseURL != "" {
587 opts = append(opts, anthropic.WithBaseURL(baseURL))
588 }
589
590 if c.cfg.Options.Debug {
591 httpClient := log.NewHTTPClient()
592 opts = append(opts, anthropic.WithHTTPClient(httpClient))
593 }
594 return anthropic.New(opts...)
595}
596
597func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
598 opts := []openai.Option{
599 openai.WithAPIKey(apiKey),
600 openai.WithUseResponsesAPI(),
601 }
602 if c.cfg.Options.Debug {
603 httpClient := log.NewHTTPClient()
604 opts = append(opts, openai.WithHTTPClient(httpClient))
605 }
606 if len(headers) > 0 {
607 opts = append(opts, openai.WithHeaders(headers))
608 }
609 if baseURL != "" {
610 opts = append(opts, openai.WithBaseURL(baseURL))
611 }
612 return openai.New(opts...)
613}
614
615func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
616 opts := []openrouter.Option{
617 openrouter.WithAPIKey(apiKey),
618 }
619 if c.cfg.Options.Debug {
620 httpClient := log.NewHTTPClient()
621 opts = append(opts, openrouter.WithHTTPClient(httpClient))
622 }
623 if len(headers) > 0 {
624 opts = append(opts, openrouter.WithHeaders(headers))
625 }
626 return openrouter.New(opts...)
627}
628
629func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
630 opts := []vercel.Option{
631 vercel.WithAPIKey(apiKey),
632 }
633 if c.cfg.Options.Debug {
634 httpClient := log.NewHTTPClient()
635 opts = append(opts, vercel.WithHTTPClient(httpClient))
636 }
637 if len(headers) > 0 {
638 opts = append(opts, vercel.WithHeaders(headers))
639 }
640 return vercel.New(opts...)
641}
642
643func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
644 opts := []openaicompat.Option{
645 openaicompat.WithBaseURL(baseURL),
646 openaicompat.WithAPIKey(apiKey),
647 }
648
649 // Set HTTP client based on provider and debug mode.
650 var httpClient *http.Client
651 if providerID == string(catwalk.InferenceProviderCopilot) {
652 opts = append(opts, openaicompat.WithUseResponsesAPI())
653 httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
654 } else if c.cfg.Options.Debug {
655 httpClient = log.NewHTTPClient()
656 }
657 if httpClient != nil {
658 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
659 }
660
661 if len(headers) > 0 {
662 opts = append(opts, openaicompat.WithHeaders(headers))
663 }
664
665 for extraKey, extraValue := range extraBody {
666 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
667 }
668
669 return openaicompat.New(opts...)
670}
671
672func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
673 opts := []azure.Option{
674 azure.WithBaseURL(baseURL),
675 azure.WithAPIKey(apiKey),
676 azure.WithUseResponsesAPI(),
677 }
678 if c.cfg.Options.Debug {
679 httpClient := log.NewHTTPClient()
680 opts = append(opts, azure.WithHTTPClient(httpClient))
681 }
682 if options == nil {
683 options = make(map[string]string)
684 }
685 if apiVersion, ok := options["apiVersion"]; ok {
686 opts = append(opts, azure.WithAPIVersion(apiVersion))
687 }
688 if len(headers) > 0 {
689 opts = append(opts, azure.WithHeaders(headers))
690 }
691
692 return azure.New(opts...)
693}
694
695func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
696 var opts []bedrock.Option
697 if c.cfg.Options.Debug {
698 httpClient := log.NewHTTPClient()
699 opts = append(opts, bedrock.WithHTTPClient(httpClient))
700 }
701 if len(headers) > 0 {
702 opts = append(opts, bedrock.WithHeaders(headers))
703 }
704 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
705 if bearerToken != "" {
706 opts = append(opts, bedrock.WithAPIKey(bearerToken))
707 }
708 return bedrock.New(opts...)
709}
710
711func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
712 opts := []google.Option{
713 google.WithBaseURL(baseURL),
714 google.WithGeminiAPIKey(apiKey),
715 }
716 if c.cfg.Options.Debug {
717 httpClient := log.NewHTTPClient()
718 opts = append(opts, google.WithHTTPClient(httpClient))
719 }
720 if len(headers) > 0 {
721 opts = append(opts, google.WithHeaders(headers))
722 }
723 return google.New(opts...)
724}
725
726func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
727 opts := []google.Option{}
728 if c.cfg.Options.Debug {
729 httpClient := log.NewHTTPClient()
730 opts = append(opts, google.WithHTTPClient(httpClient))
731 }
732 if len(headers) > 0 {
733 opts = append(opts, google.WithHeaders(headers))
734 }
735
736 project := options["project"]
737 location := options["location"]
738
739 opts = append(opts, google.WithVertex(project, location))
740
741 return google.New(opts...)
742}
743
744func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
745 opts := []hyper.Option{
746 hyper.WithBaseURL(baseURL),
747 hyper.WithAPIKey(apiKey),
748 }
749 if c.cfg.Options.Debug {
750 httpClient := log.NewHTTPClient()
751 opts = append(opts, hyper.WithHTTPClient(httpClient))
752 }
753 return hyper.New(opts...)
754}
755
756func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
757 if model.Think {
758 return true
759 }
760
761 if model.ProviderOptions == nil {
762 return false
763 }
764
765 opts, err := anthropic.ParseOptions(model.ProviderOptions)
766 if err != nil {
767 return false
768 }
769 if opts.Thinking != nil {
770 return true
771 }
772 return false
773}
774
775func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
776 headers := maps.Clone(providerCfg.ExtraHeaders)
777 if headers == nil {
778 headers = make(map[string]string)
779 }
780
781 // handle special headers for anthropic
782 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
783 if v, ok := headers["anthropic-beta"]; ok {
784 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
785 } else {
786 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
787 }
788 }
789
790 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
791 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
792
793 switch providerCfg.Type {
794 case openai.Name:
795 return c.buildOpenaiProvider(baseURL, apiKey, headers)
796 case anthropic.Name:
797 return c.buildAnthropicProvider(baseURL, apiKey, headers)
798 case openrouter.Name:
799 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
800 case vercel.Name:
801 return c.buildVercelProvider(baseURL, apiKey, headers)
802 case azure.Name:
803 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
804 case bedrock.Name:
805 return c.buildBedrockProvider(headers)
806 case google.Name:
807 return c.buildGoogleProvider(baseURL, apiKey, headers)
808 case "google-vertex":
809 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
810 case openaicompat.Name:
811 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
812 if providerCfg.ExtraBody == nil {
813 providerCfg.ExtraBody = map[string]any{}
814 }
815 providerCfg.ExtraBody["tool_stream"] = true
816 }
817 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
818 case hyper.Name:
819 return c.buildHyperProvider(baseURL, apiKey)
820 default:
821 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
822 }
823}
824
825func isExactoSupported(modelID string) bool {
826 supportedModels := []string{
827 "moonshotai/kimi-k2-0905",
828 "deepseek/deepseek-v3.1-terminus",
829 "z-ai/glm-4.6",
830 "openai/gpt-oss-120b",
831 "qwen/qwen3-coder",
832 }
833 return slices.Contains(supportedModels, modelID)
834}
835
836func (c *coordinator) Cancel(sessionID string) {
837 c.currentAgent.Cancel(sessionID)
838}
839
840func (c *coordinator) CancelAll() {
841 c.currentAgent.CancelAll()
842}
843
844func (c *coordinator) ClearQueue(sessionID string) {
845 c.currentAgent.ClearQueue(sessionID)
846}
847
848func (c *coordinator) IsBusy() bool {
849 return c.currentAgent.IsBusy()
850}
851
852func (c *coordinator) IsSessionBusy(sessionID string) bool {
853 return c.currentAgent.IsSessionBusy(sessionID)
854}
855
856func (c *coordinator) Model() Model {
857 return c.currentAgent.Model()
858}
859
860func (c *coordinator) UpdateModels(ctx context.Context) error {
861 // build the models again so we make sure we get the latest config
862 large, small, err := c.buildAgentModels(ctx, false)
863 if err != nil {
864 return err
865 }
866 c.currentAgent.SetModels(large, small)
867
868 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
869 if !ok {
870 return errors.New("coder agent not configured")
871 }
872
873 tools, err := c.buildTools(ctx, agentCfg)
874 if err != nil {
875 return err
876 }
877 c.currentAgent.SetTools(tools)
878 return nil
879}
880
881func (c *coordinator) RefreshTools(ctx context.Context) error {
882 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
883 if !ok {
884 return errors.New("coder agent not configured")
885 }
886
887 tools, err := c.buildTools(ctx, agentCfg)
888 if err != nil {
889 return err
890 }
891 c.currentAgent.SetTools(tools)
892 slog.Debug("refreshed agent tools", "count", len(tools))
893 return nil
894}
895
896func (c *coordinator) QueuedPrompts(sessionID string) int {
897 return c.currentAgent.QueuedPrompts(sessionID)
898}
899
900func (c *coordinator) QueuedPromptsList(sessionID string) []string {
901 return c.currentAgent.QueuedPromptsList(sessionID)
902}
903
904func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
905 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
906 if !ok {
907 return errors.New("model provider not configured")
908 }
909 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
910}
911
912func (c *coordinator) isUnauthorized(err error) bool {
913 var providerErr *fantasy.ProviderError
914 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
915}
916
917func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
918 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
919 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
920 return err
921 }
922 if err := c.UpdateModels(ctx); err != nil {
923 return err
924 }
925 return nil
926}
927
928func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
929 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
930 if err != nil {
931 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
932 return err
933 }
934
935 providerCfg.APIKey = newAPIKey
936 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
937
938 if err := c.UpdateModels(ctx); err != nil {
939 return err
940 }
941 return nil
942}