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