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 "github.com/charmbracelet/catwalk/pkg/catwalk"
20 "github.com/charmbracelet/crush/internal/agent/hyper"
21 "github.com/charmbracelet/crush/internal/agent/prompt"
22 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/csync"
25 "github.com/charmbracelet/crush/internal/history"
26 "github.com/charmbracelet/crush/internal/log"
27 "github.com/charmbracelet/crush/internal/lsp"
28 "github.com/charmbracelet/crush/internal/message"
29 "github.com/charmbracelet/crush/internal/permission"
30 "github.com/charmbracelet/crush/internal/session"
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()),
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 // Check MCP-specific disabled tools.
410 if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
411 if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
412 continue
413 }
414 }
415 if agent.AllowedMCP == nil {
416 // No MCP restrictions
417 filteredTools = append(filteredTools, tool)
418 continue
419 }
420 if len(agent.AllowedMCP) == 0 {
421 // No MCPs allowed
422 slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
423 break
424 }
425
426 for mcp, tools := range agent.AllowedMCP {
427 if mcp != tool.MCP() {
428 continue
429 }
430 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
431 filteredTools = append(filteredTools, tool)
432 }
433 }
434 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
435 }
436 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
437 return strings.Compare(a.Info().Name, b.Info().Name)
438 })
439 return filteredTools, nil
440}
441
442// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
443func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
444 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
445 if !ok {
446 return Model{}, Model{}, errors.New("large model not selected")
447 }
448 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
449 if !ok {
450 return Model{}, Model{}, errors.New("small model not selected")
451 }
452
453 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
454 if !ok {
455 return Model{}, Model{}, errors.New("large model provider not configured")
456 }
457
458 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
459 if err != nil {
460 return Model{}, Model{}, err
461 }
462
463 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
464 if !ok {
465 return Model{}, Model{}, errors.New("large model provider not configured")
466 }
467
468 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
469 if err != nil {
470 return Model{}, Model{}, err
471 }
472
473 var largeCatwalkModel *catwalk.Model
474 var smallCatwalkModel *catwalk.Model
475
476 for _, m := range largeProviderCfg.Models {
477 if m.ID == largeModelCfg.Model {
478 largeCatwalkModel = &m
479 }
480 }
481 for _, m := range smallProviderCfg.Models {
482 if m.ID == smallModelCfg.Model {
483 smallCatwalkModel = &m
484 }
485 }
486
487 if largeCatwalkModel == nil {
488 return Model{}, Model{}, errors.New("large model not found in provider config")
489 }
490
491 if smallCatwalkModel == nil {
492 return Model{}, Model{}, errors.New("snall model not found in provider config")
493 }
494
495 largeModelID := largeModelCfg.Model
496 smallModelID := smallModelCfg.Model
497
498 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
499 largeModelID += ":exacto"
500 }
501
502 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
503 smallModelID += ":exacto"
504 }
505
506 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
507 if err != nil {
508 return Model{}, Model{}, err
509 }
510 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
511 if err != nil {
512 return Model{}, Model{}, err
513 }
514
515 return Model{
516 Model: largeModel,
517 CatwalkCfg: *largeCatwalkModel,
518 ModelCfg: largeModelCfg,
519 }, Model{
520 Model: smallModel,
521 CatwalkCfg: *smallCatwalkModel,
522 ModelCfg: smallModelCfg,
523 }, nil
524}
525
526func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
527 var opts []anthropic.Option
528
529 if isOauth {
530 // NOTE: Prevent the SDK from picking up the API key from env.
531 os.Setenv("ANTHROPIC_API_KEY", "")
532 headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
533 } else if apiKey != "" {
534 // X-Api-Key header
535 opts = append(opts, anthropic.WithAPIKey(apiKey))
536 }
537
538 if len(headers) > 0 {
539 opts = append(opts, anthropic.WithHeaders(headers))
540 }
541
542 if baseURL != "" {
543 opts = append(opts, anthropic.WithBaseURL(baseURL))
544 }
545
546 if c.cfg.Options.Debug {
547 httpClient := log.NewHTTPClient()
548 opts = append(opts, anthropic.WithHTTPClient(httpClient))
549 }
550 return anthropic.New(opts...)
551}
552
553func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
554 opts := []openai.Option{
555 openai.WithAPIKey(apiKey),
556 openai.WithUseResponsesAPI(),
557 }
558 if c.cfg.Options.Debug {
559 httpClient := log.NewHTTPClient()
560 opts = append(opts, openai.WithHTTPClient(httpClient))
561 }
562 if len(headers) > 0 {
563 opts = append(opts, openai.WithHeaders(headers))
564 }
565 if baseURL != "" {
566 opts = append(opts, openai.WithBaseURL(baseURL))
567 }
568 return openai.New(opts...)
569}
570
571func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
572 opts := []openrouter.Option{
573 openrouter.WithAPIKey(apiKey),
574 }
575 if c.cfg.Options.Debug {
576 httpClient := log.NewHTTPClient()
577 opts = append(opts, openrouter.WithHTTPClient(httpClient))
578 }
579 if len(headers) > 0 {
580 opts = append(opts, openrouter.WithHeaders(headers))
581 }
582 return openrouter.New(opts...)
583}
584
585func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
586 opts := []openaicompat.Option{
587 openaicompat.WithBaseURL(baseURL),
588 openaicompat.WithAPIKey(apiKey),
589 }
590 if c.cfg.Options.Debug {
591 httpClient := log.NewHTTPClient()
592 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
593 }
594 if len(headers) > 0 {
595 opts = append(opts, openaicompat.WithHeaders(headers))
596 }
597
598 for extraKey, extraValue := range extraBody {
599 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
600 }
601
602 return openaicompat.New(opts...)
603}
604
605func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
606 opts := []azure.Option{
607 azure.WithBaseURL(baseURL),
608 azure.WithAPIKey(apiKey),
609 azure.WithUseResponsesAPI(),
610 }
611 if c.cfg.Options.Debug {
612 httpClient := log.NewHTTPClient()
613 opts = append(opts, azure.WithHTTPClient(httpClient))
614 }
615 if options == nil {
616 options = make(map[string]string)
617 }
618 if apiVersion, ok := options["apiVersion"]; ok {
619 opts = append(opts, azure.WithAPIVersion(apiVersion))
620 }
621 if len(headers) > 0 {
622 opts = append(opts, azure.WithHeaders(headers))
623 }
624
625 return azure.New(opts...)
626}
627
628func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
629 var opts []bedrock.Option
630 if c.cfg.Options.Debug {
631 httpClient := log.NewHTTPClient()
632 opts = append(opts, bedrock.WithHTTPClient(httpClient))
633 }
634 if len(headers) > 0 {
635 opts = append(opts, bedrock.WithHeaders(headers))
636 }
637 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
638 if bearerToken != "" {
639 opts = append(opts, bedrock.WithAPIKey(bearerToken))
640 }
641 return bedrock.New(opts...)
642}
643
644func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
645 opts := []google.Option{
646 google.WithBaseURL(baseURL),
647 google.WithGeminiAPIKey(apiKey),
648 }
649 if c.cfg.Options.Debug {
650 httpClient := log.NewHTTPClient()
651 opts = append(opts, google.WithHTTPClient(httpClient))
652 }
653 if len(headers) > 0 {
654 opts = append(opts, google.WithHeaders(headers))
655 }
656 return google.New(opts...)
657}
658
659func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
660 opts := []google.Option{}
661 if c.cfg.Options.Debug {
662 httpClient := log.NewHTTPClient()
663 opts = append(opts, google.WithHTTPClient(httpClient))
664 }
665 if len(headers) > 0 {
666 opts = append(opts, google.WithHeaders(headers))
667 }
668
669 project := options["project"]
670 location := options["location"]
671
672 opts = append(opts, google.WithVertex(project, location))
673
674 return google.New(opts...)
675}
676
677func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
678 opts := []hyper.Option{
679 hyper.WithBaseURL(baseURL),
680 hyper.WithAPIKey(apiKey),
681 }
682 if c.cfg.Options.Debug {
683 httpClient := log.NewHTTPClient()
684 opts = append(opts, hyper.WithHTTPClient(httpClient))
685 }
686 return hyper.New(opts...)
687}
688
689func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
690 if model.Think {
691 return true
692 }
693
694 if model.ProviderOptions == nil {
695 return false
696 }
697
698 opts, err := anthropic.ParseOptions(model.ProviderOptions)
699 if err != nil {
700 return false
701 }
702 if opts.Thinking != nil {
703 return true
704 }
705 return false
706}
707
708func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
709 headers := maps.Clone(providerCfg.ExtraHeaders)
710 if headers == nil {
711 headers = make(map[string]string)
712 }
713
714 // handle special headers for anthropic
715 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
716 if v, ok := headers["anthropic-beta"]; ok {
717 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
718 } else {
719 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
720 }
721 }
722
723 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
724 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
725
726 switch providerCfg.Type {
727 case openai.Name:
728 return c.buildOpenaiProvider(baseURL, apiKey, headers)
729 case anthropic.Name:
730 return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
731 case openrouter.Name:
732 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
733 case azure.Name:
734 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
735 case bedrock.Name:
736 return c.buildBedrockProvider(headers)
737 case google.Name:
738 return c.buildGoogleProvider(baseURL, apiKey, headers)
739 case "google-vertex":
740 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
741 case openaicompat.Name:
742 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
743 if providerCfg.ExtraBody == nil {
744 providerCfg.ExtraBody = map[string]any{}
745 }
746 providerCfg.ExtraBody["tool_stream"] = true
747 }
748 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
749 case hyper.Name:
750 return c.buildHyperProvider(baseURL, apiKey)
751 default:
752 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
753 }
754}
755
756func isExactoSupported(modelID string) bool {
757 supportedModels := []string{
758 "moonshotai/kimi-k2-0905",
759 "deepseek/deepseek-v3.1-terminus",
760 "z-ai/glm-4.6",
761 "openai/gpt-oss-120b",
762 "qwen/qwen3-coder",
763 }
764 return slices.Contains(supportedModels, modelID)
765}
766
767func (c *coordinator) Cancel(sessionID string) {
768 c.currentAgent.Cancel(sessionID)
769}
770
771func (c *coordinator) CancelAll() {
772 c.currentAgent.CancelAll()
773}
774
775func (c *coordinator) ClearQueue(sessionID string) {
776 c.currentAgent.ClearQueue(sessionID)
777}
778
779func (c *coordinator) IsBusy() bool {
780 return c.currentAgent.IsBusy()
781}
782
783func (c *coordinator) IsSessionBusy(sessionID string) bool {
784 return c.currentAgent.IsSessionBusy(sessionID)
785}
786
787func (c *coordinator) Model() Model {
788 return c.currentAgent.Model()
789}
790
791func (c *coordinator) UpdateModels(ctx context.Context) error {
792 // build the models again so we make sure we get the latest config
793 large, small, err := c.buildAgentModels(ctx)
794 if err != nil {
795 return err
796 }
797 c.currentAgent.SetModels(large, small)
798
799 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
800 if !ok {
801 return errors.New("coder agent not configured")
802 }
803
804 tools, err := c.buildTools(ctx, agentCfg)
805 if err != nil {
806 return err
807 }
808 c.currentAgent.SetTools(tools)
809 return nil
810}
811
812func (c *coordinator) QueuedPrompts(sessionID string) int {
813 return c.currentAgent.QueuedPrompts(sessionID)
814}
815
816func (c *coordinator) QueuedPromptsList(sessionID string) []string {
817 return c.currentAgent.QueuedPromptsList(sessionID)
818}
819
820func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
821 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
822 if !ok {
823 return errors.New("model provider not configured")
824 }
825 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
826}
827
828func (c *coordinator) isUnauthorized(err error) bool {
829 var providerErr *fantasy.ProviderError
830 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
831}
832
833func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
834 if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
835 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
836 return err
837 }
838 if err := c.UpdateModels(ctx); err != nil {
839 return err
840 }
841 return nil
842}
843
844func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
845 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
846 if err != nil {
847 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
848 return err
849 }
850
851 providerCfg.APIKey = newAPIKey
852 c.cfg.Providers.Set(providerCfg.ID, providerCfg)
853
854 if err := c.UpdateModels(ctx); err != nil {
855 return err
856 }
857 return nil
858}