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