coordinator.go

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