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 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}