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