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.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
374 tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
375 tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
376 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
377 tools.NewGlobTool(c.cfg.WorkingDir()),
378 tools.NewGrepTool(c.cfg.WorkingDir()),
379 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
380 tools.NewSourcegraphTool(nil),
381 tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
382 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
383 )
384
385 if len(c.cfg.LSP) > 0 {
386 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
387 }
388
389 var filteredTools []fantasy.AgentTool
390 for _, tool := range allTools {
391 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
392 filteredTools = append(filteredTools, tool)
393 }
394 }
395
396 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
397 if agent.AllowedMCP == nil {
398 // No MCP restrictions
399 filteredTools = append(filteredTools, tool)
400 continue
401 }
402 if len(agent.AllowedMCP) == 0 {
403 // No MCPs allowed
404 slog.Warn("MCPs not allowed")
405 break
406 }
407
408 for mcp, tools := range agent.AllowedMCP {
409 if mcp != tool.MCP() {
410 continue
411 }
412 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
413 filteredTools = append(filteredTools, tool)
414 }
415 }
416 }
417 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
418 return strings.Compare(a.Info().Name, b.Info().Name)
419 })
420 return filteredTools, nil
421}
422
423// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
424func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
425 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
426 if !ok {
427 return Model{}, Model{}, errors.New("large model not selected")
428 }
429 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
430 if !ok {
431 return Model{}, Model{}, errors.New("small model not selected")
432 }
433
434 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
435 if !ok {
436 return Model{}, Model{}, errors.New("large model provider not configured")
437 }
438
439 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
440 if err != nil {
441 return Model{}, Model{}, err
442 }
443
444 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
445 if !ok {
446 return Model{}, Model{}, errors.New("large model provider not configured")
447 }
448
449 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
450 if err != nil {
451 return Model{}, Model{}, err
452 }
453
454 var largeCatwalkModel *catwalk.Model
455 var smallCatwalkModel *catwalk.Model
456
457 for _, m := range largeProviderCfg.Models {
458 if m.ID == largeModelCfg.Model {
459 largeCatwalkModel = &m
460 }
461 }
462 for _, m := range smallProviderCfg.Models {
463 if m.ID == smallModelCfg.Model {
464 smallCatwalkModel = &m
465 }
466 }
467
468 if largeCatwalkModel == nil {
469 return Model{}, Model{}, errors.New("large model not found in provider config")
470 }
471
472 if smallCatwalkModel == nil {
473 return Model{}, Model{}, errors.New("snall model not found in provider config")
474 }
475
476 largeModelID := largeModelCfg.Model
477 smallModelID := smallModelCfg.Model
478
479 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
480 largeModelID += ":exacto"
481 }
482
483 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
484 smallModelID += ":exacto"
485 }
486
487 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
488 if err != nil {
489 return Model{}, Model{}, err
490 }
491 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
492 if err != nil {
493 return Model{}, Model{}, err
494 }
495
496 return Model{
497 Model: largeModel,
498 CatwalkCfg: *largeCatwalkModel,
499 ModelCfg: largeModelCfg,
500 }, Model{
501 Model: smallModel,
502 CatwalkCfg: *smallCatwalkModel,
503 ModelCfg: smallModelCfg,
504 }, nil
505}
506
507func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
508 hasBearerAuth := false
509 for key := range headers {
510 if strings.ToLower(key) == "authorization" {
511 hasBearerAuth = true
512 break
513 }
514 }
515
516 isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
517
518 var opts []anthropic.Option
519 if apiKey != "" && !hasBearerAuth {
520 if isBearerToken {
521 slog.Debug("API key starts with 'Bearer ', using as Authorization header")
522 headers["Authorization"] = apiKey
523 apiKey = "" // clear apiKey to avoid using X-Api-Key header
524 }
525 }
526
527 if apiKey != "" {
528 // Use standard X-Api-Key header
529 opts = append(opts, anthropic.WithAPIKey(apiKey))
530 }
531
532 if len(headers) > 0 {
533 opts = append(opts, anthropic.WithHeaders(headers))
534 }
535
536 if baseURL != "" {
537 opts = append(opts, anthropic.WithBaseURL(baseURL))
538 }
539
540 if c.cfg.Options.Debug {
541 httpClient := log.NewHTTPClient()
542 opts = append(opts, anthropic.WithHTTPClient(httpClient))
543 }
544
545 return anthropic.New(opts...)
546}
547
548func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
549 opts := []openai.Option{
550 openai.WithAPIKey(apiKey),
551 openai.WithUseResponsesAPI(),
552 }
553 if c.cfg.Options.Debug {
554 httpClient := log.NewHTTPClient()
555 opts = append(opts, openai.WithHTTPClient(httpClient))
556 }
557 if len(headers) > 0 {
558 opts = append(opts, openai.WithHeaders(headers))
559 }
560 if baseURL != "" {
561 opts = append(opts, openai.WithBaseURL(baseURL))
562 }
563 return openai.New(opts...)
564}
565
566func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
567 opts := []openrouter.Option{
568 openrouter.WithAPIKey(apiKey),
569 }
570 if c.cfg.Options.Debug {
571 httpClient := log.NewHTTPClient()
572 opts = append(opts, openrouter.WithHTTPClient(httpClient))
573 }
574 if len(headers) > 0 {
575 opts = append(opts, openrouter.WithHeaders(headers))
576 }
577 return openrouter.New(opts...)
578}
579
580func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
581 opts := []openaicompat.Option{
582 openaicompat.WithBaseURL(baseURL),
583 openaicompat.WithAPIKey(apiKey),
584 }
585 if c.cfg.Options.Debug {
586 httpClient := log.NewHTTPClient()
587 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
588 }
589 if len(headers) > 0 {
590 opts = append(opts, openaicompat.WithHeaders(headers))
591 }
592
593 for extraKey, extraValue := range extraBody {
594 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
595 }
596
597 return openaicompat.New(opts...)
598}
599
600func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
601 opts := []azure.Option{
602 azure.WithBaseURL(baseURL),
603 azure.WithAPIKey(apiKey),
604 azure.WithUseResponsesAPI(),
605 }
606 if c.cfg.Options.Debug {
607 httpClient := log.NewHTTPClient()
608 opts = append(opts, azure.WithHTTPClient(httpClient))
609 }
610 if options == nil {
611 options = make(map[string]string)
612 }
613 if apiVersion, ok := options["apiVersion"]; ok {
614 opts = append(opts, azure.WithAPIVersion(apiVersion))
615 }
616 if len(headers) > 0 {
617 opts = append(opts, azure.WithHeaders(headers))
618 }
619
620 return azure.New(opts...)
621}
622
623func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
624 var opts []bedrock.Option
625 if c.cfg.Options.Debug {
626 httpClient := log.NewHTTPClient()
627 opts = append(opts, bedrock.WithHTTPClient(httpClient))
628 }
629 if len(headers) > 0 {
630 opts = append(opts, bedrock.WithHeaders(headers))
631 }
632 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
633 if bearerToken != "" {
634 opts = append(opts, bedrock.WithAPIKey(bearerToken))
635 }
636 return bedrock.New(opts...)
637}
638
639func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
640 opts := []google.Option{
641 google.WithBaseURL(baseURL),
642 google.WithGeminiAPIKey(apiKey),
643 }
644 if c.cfg.Options.Debug {
645 httpClient := log.NewHTTPClient()
646 opts = append(opts, google.WithHTTPClient(httpClient))
647 }
648 if len(headers) > 0 {
649 opts = append(opts, google.WithHeaders(headers))
650 }
651 return google.New(opts...)
652}
653
654func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
655 opts := []google.Option{}
656 if c.cfg.Options.Debug {
657 httpClient := log.NewHTTPClient()
658 opts = append(opts, google.WithHTTPClient(httpClient))
659 }
660 if len(headers) > 0 {
661 opts = append(opts, google.WithHeaders(headers))
662 }
663
664 project := options["project"]
665 location := options["location"]
666
667 opts = append(opts, google.WithVertex(project, location))
668
669 return google.New(opts...)
670}
671
672func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
673 if model.Think {
674 return true
675 }
676
677 if model.ProviderOptions == nil {
678 return false
679 }
680
681 opts, err := anthropic.ParseOptions(model.ProviderOptions)
682 if err != nil {
683 return false
684 }
685 if opts.Thinking != nil {
686 return true
687 }
688 return false
689}
690
691func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
692 headers := maps.Clone(providerCfg.ExtraHeaders)
693 if headers == nil {
694 headers = make(map[string]string)
695 }
696
697 // handle special headers for anthropic
698 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
699 if v, ok := headers["anthropic-beta"]; ok {
700 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
701 } else {
702 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
703 }
704 }
705
706 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
707 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
708
709 switch providerCfg.Type {
710 case openai.Name:
711 return c.buildOpenaiProvider(baseURL, apiKey, headers)
712 case anthropic.Name:
713 return c.buildAnthropicProvider(baseURL, apiKey, headers)
714 case openrouter.Name:
715 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
716 case azure.Name:
717 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
718 case bedrock.Name:
719 return c.buildBedrockProvider(headers)
720 case google.Name:
721 return c.buildGoogleProvider(baseURL, apiKey, headers)
722 case "google-vertex":
723 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
724 case openaicompat.Name:
725 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
726 default:
727 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
728 }
729}
730
731func isExactoSupported(modelID string) bool {
732 supportedModels := []string{
733 "moonshotai/kimi-k2-0905",
734 "deepseek/deepseek-v3.1-terminus",
735 "z-ai/glm-4.6",
736 "openai/gpt-oss-120b",
737 "qwen/qwen3-coder",
738 }
739 return slices.Contains(supportedModels, modelID)
740}
741
742func (c *coordinator) Cancel(sessionID string) {
743 c.currentAgent.Cancel(sessionID)
744}
745
746func (c *coordinator) CancelAll() {
747 c.currentAgent.CancelAll()
748}
749
750func (c *coordinator) ClearQueue(sessionID string) {
751 c.currentAgent.ClearQueue(sessionID)
752}
753
754func (c *coordinator) IsBusy() bool {
755 return c.currentAgent.IsBusy()
756}
757
758func (c *coordinator) IsSessionBusy(sessionID string) bool {
759 return c.currentAgent.IsSessionBusy(sessionID)
760}
761
762func (c *coordinator) Model() Model {
763 return c.currentAgent.Model()
764}
765
766func (c *coordinator) UpdateModels(ctx context.Context) error {
767 // build the models again so we make sure we get the latest config
768 large, small, err := c.buildAgentModels(ctx)
769 if err != nil {
770 return err
771 }
772 c.currentAgent.SetModels(large, small)
773
774 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
775 if !ok {
776 return errors.New("coder agent not configured")
777 }
778
779 tools, err := c.buildTools(ctx, agentCfg)
780 if err != nil {
781 return err
782 }
783 c.currentAgent.SetTools(tools)
784 return nil
785}
786
787func (c *coordinator) QueuedPrompts(sessionID string) int {
788 return c.currentAgent.QueuedPrompts(sessionID)
789}
790
791func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
792 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
793 if !ok {
794 return errors.New("model provider not configured")
795 }
796 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
797}