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