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