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