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