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