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, azure.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 openaicompat.Name:
254		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
255		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
256			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
257		}
258		parsed, err := openaicompat.ParseOptions(mergedOptions)
259		if err == nil {
260			options[openaicompat.Name] = parsed
261		}
262	}
263
264	return options
265}
266
267func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
268	modelOptions := getProviderOptions(model, cfg)
269	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
270	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
271	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
272	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
273	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
274	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
275}
276
277func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
278	large, small, err := c.buildAgentModels(ctx)
279	if err != nil {
280		return nil, err
281	}
282
283	systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
284	if err != nil {
285		return nil, err
286	}
287
288	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
289	result := NewSessionAgent(SessionAgentOptions{
290		large,
291		small,
292		largeProviderCfg.SystemPromptPrefix,
293		systemPrompt,
294		c.cfg.Options.DisableAutoSummarize,
295		c.permissions.SkipRequests(),
296		c.sessions,
297		c.messages,
298		nil,
299	})
300	c.readyWg.Go(func() error {
301		tools, err := c.buildTools(ctx, agent)
302		if err != nil {
303			return err
304		}
305		result.SetTools(tools)
306		return nil
307	})
308
309	return result, nil
310}
311
312func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
313	var allTools []fantasy.AgentTool
314	if slices.Contains(agent.AllowedTools, AgentToolName) {
315		agentTool, err := c.agentTool(ctx)
316		if err != nil {
317			return nil, err
318		}
319		allTools = append(allTools, agentTool)
320	}
321
322	allTools = append(allTools,
323		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
324		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
325		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
326		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
327		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
328		tools.NewGlobTool(c.cfg.WorkingDir()),
329		tools.NewGrepTool(c.cfg.WorkingDir()),
330		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
331		tools.NewSourcegraphTool(nil),
332		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
333		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
334	)
335
336	if len(c.cfg.LSP) > 0 {
337		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
338	}
339
340	var filteredTools []fantasy.AgentTool
341	for _, tool := range allTools {
342		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
343			filteredTools = append(filteredTools, tool)
344		}
345	}
346
347	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
348		if agent.AllowedMCP == nil {
349			// No MCP restrictions
350			filteredTools = append(filteredTools, tool)
351			continue
352		}
353		if len(agent.AllowedMCP) == 0 {
354			// No MCPs allowed
355			slog.Warn("MCPs not allowed")
356			break
357		}
358
359		for mcp, tools := range agent.AllowedMCP {
360			if mcp != tool.MCP() {
361				continue
362			}
363			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
364				filteredTools = append(filteredTools, tool)
365			}
366		}
367	}
368	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
369		return strings.Compare(a.Info().Name, b.Info().Name)
370	})
371	return filteredTools, nil
372}
373
374// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
375func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
376	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
377	if !ok {
378		return Model{}, Model{}, errors.New("large model not selected")
379	}
380	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
381	if !ok {
382		return Model{}, Model{}, errors.New("small model not selected")
383	}
384
385	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
386	if !ok {
387		return Model{}, Model{}, errors.New("large model provider not configured")
388	}
389
390	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
391	if err != nil {
392		return Model{}, Model{}, err
393	}
394
395	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
396	if !ok {
397		return Model{}, Model{}, errors.New("large model provider not configured")
398	}
399
400	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
401	if err != nil {
402		return Model{}, Model{}, err
403	}
404
405	var largeCatwalkModel *catwalk.Model
406	var smallCatwalkModel *catwalk.Model
407
408	for _, m := range largeProviderCfg.Models {
409		if m.ID == largeModelCfg.Model {
410			largeCatwalkModel = &m
411		}
412	}
413	for _, m := range smallProviderCfg.Models {
414		if m.ID == smallModelCfg.Model {
415			smallCatwalkModel = &m
416		}
417	}
418
419	if largeCatwalkModel == nil {
420		return Model{}, Model{}, errors.New("large model not found in provider config")
421	}
422
423	if smallCatwalkModel == nil {
424		return Model{}, Model{}, errors.New("snall model not found in provider config")
425	}
426
427	largeModelID := largeModelCfg.Model
428	smallModelID := smallModelCfg.Model
429
430	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
431		largeModelID += ":exacto"
432	}
433
434	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
435		smallModelID += ":exacto"
436	}
437
438	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
439	if err != nil {
440		return Model{}, Model{}, err
441	}
442	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
443	if err != nil {
444		return Model{}, Model{}, err
445	}
446
447	return Model{
448			Model:      largeModel,
449			CatwalkCfg: *largeCatwalkModel,
450			ModelCfg:   largeModelCfg,
451		}, Model{
452			Model:      smallModel,
453			CatwalkCfg: *smallCatwalkModel,
454			ModelCfg:   smallModelCfg,
455		}, nil
456}
457
458func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
459	hasBearerAuth := false
460	for key := range headers {
461		if strings.ToLower(key) == "authorization" {
462			hasBearerAuth = true
463			break
464		}
465	}
466
467	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
468
469	var opts []anthropic.Option
470	if apiKey != "" && !hasBearerAuth {
471		if isBearerToken {
472			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
473			headers["Authorization"] = apiKey
474			apiKey = "" // clear apiKey to avoid using X-Api-Key header
475		}
476	}
477
478	if apiKey != "" {
479		// Use standard X-Api-Key header
480		opts = append(opts, anthropic.WithAPIKey(apiKey))
481	}
482
483	if len(headers) > 0 {
484		opts = append(opts, anthropic.WithHeaders(headers))
485	}
486
487	if baseURL != "" {
488		opts = append(opts, anthropic.WithBaseURL(baseURL))
489	}
490
491	if c.cfg.Options.Debug {
492		httpClient := log.NewHTTPClient()
493		opts = append(opts, anthropic.WithHTTPClient(httpClient))
494	}
495
496	return anthropic.New(opts...)
497}
498
499func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
500	opts := []openai.Option{
501		openai.WithAPIKey(apiKey),
502		openai.WithUseResponsesAPI(),
503	}
504	if c.cfg.Options.Debug {
505		httpClient := log.NewHTTPClient()
506		opts = append(opts, openai.WithHTTPClient(httpClient))
507	}
508	if len(headers) > 0 {
509		opts = append(opts, openai.WithHeaders(headers))
510	}
511	if baseURL != "" {
512		opts = append(opts, openai.WithBaseURL(baseURL))
513	}
514	return openai.New(opts...)
515}
516
517func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
518	opts := []openrouter.Option{
519		openrouter.WithAPIKey(apiKey),
520	}
521	if c.cfg.Options.Debug {
522		httpClient := log.NewHTTPClient()
523		opts = append(opts, openrouter.WithHTTPClient(httpClient))
524	}
525	if len(headers) > 0 {
526		opts = append(opts, openrouter.WithHeaders(headers))
527	}
528	return openrouter.New(opts...)
529}
530
531func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
532	opts := []openaicompat.Option{
533		openaicompat.WithBaseURL(baseURL),
534		openaicompat.WithAPIKey(apiKey),
535	}
536	if c.cfg.Options.Debug {
537		httpClient := log.NewHTTPClient()
538		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
539	}
540	if len(headers) > 0 {
541		opts = append(opts, openaicompat.WithHeaders(headers))
542	}
543
544	for extraKey, extraValue := range extraBody {
545		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
546	}
547
548	return openaicompat.New(opts...)
549}
550
551func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
552	opts := []azure.Option{
553		azure.WithBaseURL(baseURL),
554		azure.WithAPIKey(apiKey),
555		azure.WithUseResponsesAPI(),
556	}
557	if c.cfg.Options.Debug {
558		httpClient := log.NewHTTPClient()
559		opts = append(opts, azure.WithHTTPClient(httpClient))
560	}
561	if options == nil {
562		options = make(map[string]string)
563	}
564	if apiVersion, ok := options["apiVersion"]; ok {
565		opts = append(opts, azure.WithAPIVersion(apiVersion))
566	}
567	if len(headers) > 0 {
568		opts = append(opts, azure.WithHeaders(headers))
569	}
570
571	return azure.New(opts...)
572}
573
574func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
575	var opts []bedrock.Option
576	if c.cfg.Options.Debug {
577		httpClient := log.NewHTTPClient()
578		opts = append(opts, bedrock.WithHTTPClient(httpClient))
579	}
580	if len(headers) > 0 {
581		opts = append(opts, bedrock.WithHeaders(headers))
582	}
583	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
584	if bearerToken != "" {
585		opts = append(opts, bedrock.WithAPIKey(bearerToken))
586	}
587	return bedrock.New(opts...)
588}
589
590func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
591	opts := []google.Option{
592		google.WithBaseURL(baseURL),
593		google.WithGeminiAPIKey(apiKey),
594	}
595	if c.cfg.Options.Debug {
596		httpClient := log.NewHTTPClient()
597		opts = append(opts, google.WithHTTPClient(httpClient))
598	}
599	if len(headers) > 0 {
600		opts = append(opts, google.WithHeaders(headers))
601	}
602	return google.New(opts...)
603}
604
605func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
606	opts := []google.Option{}
607	if c.cfg.Options.Debug {
608		httpClient := log.NewHTTPClient()
609		opts = append(opts, google.WithHTTPClient(httpClient))
610	}
611	if len(headers) > 0 {
612		opts = append(opts, google.WithHeaders(headers))
613	}
614
615	project := options["project"]
616	location := options["location"]
617
618	opts = append(opts, google.WithVertex(project, location))
619
620	return google.New(opts...)
621}
622
623func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
624	if model.Think {
625		return true
626	}
627
628	if model.ProviderOptions == nil {
629		return false
630	}
631
632	opts, err := anthropic.ParseOptions(model.ProviderOptions)
633	if err != nil {
634		return false
635	}
636	if opts.Thinking != nil {
637		return true
638	}
639	return false
640}
641
642func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
643	headers := maps.Clone(providerCfg.ExtraHeaders)
644	if headers == nil {
645		headers = make(map[string]string)
646	}
647
648	// handle special headers for anthropic
649	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
650		if v, ok := headers["anthropic-beta"]; ok {
651			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
652		} else {
653			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
654		}
655	}
656
657	// TODO: make sure we have
658	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
659	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
660
661	switch providerCfg.Type {
662	case openai.Name:
663		return c.buildOpenaiProvider(baseURL, apiKey, headers)
664	case anthropic.Name:
665		return c.buildAnthropicProvider(baseURL, apiKey, headers)
666	case openrouter.Name:
667		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
668	case azure.Name:
669		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
670	case bedrock.Name:
671		return c.buildBedrockProvider(headers)
672	case google.Name:
673		return c.buildGoogleProvider(baseURL, apiKey, headers)
674	case "google-vertex":
675		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
676	case openaicompat.Name:
677		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
678	default:
679		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
680	}
681}
682
683func isExactoSupported(modelID string) bool {
684	supportedModels := []string{
685		"moonshotai/kimi-k2-0905",
686		"deepseek/deepseek-v3.1-terminus",
687		"z-ai/glm-4.6",
688		"openai/gpt-oss-120b",
689		"qwen/qwen3-coder",
690	}
691	return slices.Contains(supportedModels, modelID)
692}
693
694func (c *coordinator) Cancel(sessionID string) {
695	c.currentAgent.Cancel(sessionID)
696}
697
698func (c *coordinator) CancelAll() {
699	c.currentAgent.CancelAll()
700}
701
702func (c *coordinator) ClearQueue(sessionID string) {
703	c.currentAgent.ClearQueue(sessionID)
704}
705
706func (c *coordinator) IsBusy() bool {
707	return c.currentAgent.IsBusy()
708}
709
710func (c *coordinator) IsSessionBusy(sessionID string) bool {
711	return c.currentAgent.IsSessionBusy(sessionID)
712}
713
714func (c *coordinator) Model() Model {
715	return c.currentAgent.Model()
716}
717
718func (c *coordinator) UpdateModels(ctx context.Context) error {
719	// build the models again so we make sure we get the latest config
720	large, small, err := c.buildAgentModels(ctx)
721	if err != nil {
722		return err
723	}
724	c.currentAgent.SetModels(large, small)
725
726	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
727	if !ok {
728		return errors.New("coder agent not configured")
729	}
730
731	tools, err := c.buildTools(ctx, agentCfg)
732	if err != nil {
733		return err
734	}
735	c.currentAgent.SetTools(tools)
736	return nil
737}
738
739func (c *coordinator) QueuedPrompts(sessionID string) int {
740	return c.currentAgent.QueuedPrompts(sessionID)
741}
742
743func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
744	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
745	if !ok {
746		return errors.New("model provider not configured")
747	}
748	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
749}