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