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