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