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		} else if len(agent.AllowedMCP) == 0 {
352			// no mcps allowed
353			break
354		}
355
356		for mcp, tools := range agent.AllowedMCP {
357			if mcp == tool.MCP() {
358				if len(tools) == 0 {
359					filteredTools = append(filteredTools, tool)
360				}
361				for _, t := range tools {
362					if t == tool.MCPToolName() {
363						filteredTools = append(filteredTools, tool)
364					}
365				}
366				break
367			}
368		}
369	}
370	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
371		return strings.Compare(a.Info().Name, b.Info().Name)
372	})
373	return filteredTools, nil
374}
375
376// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
377func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
378	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
379	if !ok {
380		return Model{}, Model{}, errors.New("large model not selected")
381	}
382	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
383	if !ok {
384		return Model{}, Model{}, errors.New("small model not selected")
385	}
386
387	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
388	if !ok {
389		return Model{}, Model{}, errors.New("large model provider not configured")
390	}
391
392	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
393	if err != nil {
394		return Model{}, Model{}, err
395	}
396
397	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
398	if !ok {
399		return Model{}, Model{}, errors.New("large model provider not configured")
400	}
401
402	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
403	if err != nil {
404		return Model{}, Model{}, err
405	}
406
407	var largeCatwalkModel *catwalk.Model
408	var smallCatwalkModel *catwalk.Model
409
410	for _, m := range largeProviderCfg.Models {
411		if m.ID == largeModelCfg.Model {
412			largeCatwalkModel = &m
413		}
414	}
415	for _, m := range smallProviderCfg.Models {
416		if m.ID == smallModelCfg.Model {
417			smallCatwalkModel = &m
418		}
419	}
420
421	if largeCatwalkModel == nil {
422		return Model{}, Model{}, errors.New("large model not found in provider config")
423	}
424
425	if smallCatwalkModel == nil {
426		return Model{}, Model{}, errors.New("snall model not found in provider config")
427	}
428
429	largeModelID := largeModelCfg.Model
430	smallModelID := smallModelCfg.Model
431
432	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
433		largeModelID += ":exacto"
434	}
435
436	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
437		smallModelID += ":exacto"
438	}
439
440	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
441	if err != nil {
442		return Model{}, Model{}, err
443	}
444	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
445	if err != nil {
446		return Model{}, Model{}, err
447	}
448
449	return Model{
450			Model:      largeModel,
451			CatwalkCfg: *largeCatwalkModel,
452			ModelCfg:   largeModelCfg,
453		}, Model{
454			Model:      smallModel,
455			CatwalkCfg: *smallCatwalkModel,
456			ModelCfg:   smallModelCfg,
457		}, nil
458}
459
460func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
461	hasBearerAuth := false
462	for key := range headers {
463		if strings.ToLower(key) == "authorization" {
464			hasBearerAuth = true
465			break
466		}
467	}
468
469	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
470
471	var opts []anthropic.Option
472	if apiKey != "" && !hasBearerAuth {
473		if isBearerToken {
474			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
475			headers["Authorization"] = apiKey
476			apiKey = "" // clear apiKey to avoid using X-Api-Key header
477		}
478	}
479
480	if apiKey != "" {
481		// Use standard X-Api-Key header
482		opts = append(opts, anthropic.WithAPIKey(apiKey))
483	}
484
485	if len(headers) > 0 {
486		opts = append(opts, anthropic.WithHeaders(headers))
487	}
488
489	if baseURL != "" {
490		opts = append(opts, anthropic.WithBaseURL(baseURL))
491	}
492
493	if c.cfg.Options.Debug {
494		httpClient := log.NewHTTPClient()
495		opts = append(opts, anthropic.WithHTTPClient(httpClient))
496	}
497
498	return anthropic.New(opts...)
499}
500
501func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
502	opts := []openai.Option{
503		openai.WithAPIKey(apiKey),
504		openai.WithUseResponsesAPI(),
505	}
506	if c.cfg.Options.Debug {
507		httpClient := log.NewHTTPClient()
508		opts = append(opts, openai.WithHTTPClient(httpClient))
509	}
510	if len(headers) > 0 {
511		opts = append(opts, openai.WithHeaders(headers))
512	}
513	if baseURL != "" {
514		opts = append(opts, openai.WithBaseURL(baseURL))
515	}
516	return openai.New(opts...)
517}
518
519func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
520	opts := []openrouter.Option{
521		openrouter.WithAPIKey(apiKey),
522	}
523	if c.cfg.Options.Debug {
524		httpClient := log.NewHTTPClient()
525		opts = append(opts, openrouter.WithHTTPClient(httpClient))
526	}
527	if len(headers) > 0 {
528		opts = append(opts, openrouter.WithHeaders(headers))
529	}
530	return openrouter.New(opts...)
531}
532
533func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
534	opts := []openaicompat.Option{
535		openaicompat.WithBaseURL(baseURL),
536		openaicompat.WithAPIKey(apiKey),
537	}
538	if c.cfg.Options.Debug {
539		httpClient := log.NewHTTPClient()
540		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
541	}
542	if len(headers) > 0 {
543		opts = append(opts, openaicompat.WithHeaders(headers))
544	}
545
546	for extraKey, extraValue := range extraBody {
547		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
548	}
549
550	return openaicompat.New(opts...)
551}
552
553func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
554	opts := []azure.Option{
555		azure.WithBaseURL(baseURL),
556		azure.WithAPIKey(apiKey),
557		azure.WithUseResponsesAPI(),
558	}
559	if c.cfg.Options.Debug {
560		httpClient := log.NewHTTPClient()
561		opts = append(opts, azure.WithHTTPClient(httpClient))
562	}
563	if options == nil {
564		options = make(map[string]string)
565	}
566	if apiVersion, ok := options["apiVersion"]; ok {
567		opts = append(opts, azure.WithAPIVersion(apiVersion))
568	}
569	if len(headers) > 0 {
570		opts = append(opts, azure.WithHeaders(headers))
571	}
572
573	return azure.New(opts...)
574}
575
576func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
577	var opts []bedrock.Option
578	if c.cfg.Options.Debug {
579		httpClient := log.NewHTTPClient()
580		opts = append(opts, bedrock.WithHTTPClient(httpClient))
581	}
582	if len(headers) > 0 {
583		opts = append(opts, bedrock.WithHeaders(headers))
584	}
585	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
586	if bearerToken != "" {
587		opts = append(opts, bedrock.WithAPIKey(bearerToken))
588	}
589	return bedrock.New(opts...)
590}
591
592func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
593	opts := []google.Option{
594		google.WithBaseURL(baseURL),
595		google.WithGeminiAPIKey(apiKey),
596	}
597	if c.cfg.Options.Debug {
598		httpClient := log.NewHTTPClient()
599		opts = append(opts, google.WithHTTPClient(httpClient))
600	}
601	if len(headers) > 0 {
602		opts = append(opts, google.WithHeaders(headers))
603	}
604	return google.New(opts...)
605}
606
607func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
608	opts := []google.Option{}
609	if c.cfg.Options.Debug {
610		httpClient := log.NewHTTPClient()
611		opts = append(opts, google.WithHTTPClient(httpClient))
612	}
613	if len(headers) > 0 {
614		opts = append(opts, google.WithHeaders(headers))
615	}
616
617	project := options["project"]
618	location := options["location"]
619
620	opts = append(opts, google.WithVertex(project, location))
621
622	return google.New(opts...)
623}
624
625func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
626	if model.Think {
627		return true
628	}
629
630	if model.ProviderOptions == nil {
631		return false
632	}
633
634	opts, err := anthropic.ParseOptions(model.ProviderOptions)
635	if err != nil {
636		return false
637	}
638	if opts.Thinking != nil {
639		return true
640	}
641	return false
642}
643
644func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
645	headers := maps.Clone(providerCfg.ExtraHeaders)
646	if headers == nil {
647		headers = make(map[string]string)
648	}
649
650	// handle special headers for anthropic
651	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
652		if v, ok := headers["anthropic-beta"]; ok {
653			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
654		} else {
655			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
656		}
657	}
658
659	// TODO: make sure we have
660	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
661	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
662
663	switch providerCfg.Type {
664	case openai.Name:
665		return c.buildOpenaiProvider(baseURL, apiKey, headers)
666	case anthropic.Name:
667		return c.buildAnthropicProvider(baseURL, apiKey, headers)
668	case openrouter.Name:
669		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
670	case azure.Name:
671		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
672	case bedrock.Name:
673		return c.buildBedrockProvider(headers)
674	case google.Name:
675		return c.buildGoogleProvider(baseURL, apiKey, headers)
676	case "google-vertex":
677		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
678	case openaicompat.Name:
679		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
680	default:
681		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
682	}
683}
684
685func isExactoSupported(modelID string) bool {
686	supportedModels := []string{
687		"moonshotai/kimi-k2-0905",
688		"deepseek/deepseek-v3.1-terminus",
689		"z-ai/glm-4.6",
690		"openai/gpt-oss-120b",
691		"qwen/qwen3-coder",
692	}
693	return slices.Contains(supportedModels, modelID)
694}
695
696func (c *coordinator) Cancel(sessionID string) {
697	c.currentAgent.Cancel(sessionID)
698}
699
700func (c *coordinator) CancelAll() {
701	c.currentAgent.CancelAll()
702}
703
704func (c *coordinator) ClearQueue(sessionID string) {
705	c.currentAgent.ClearQueue(sessionID)
706}
707
708func (c *coordinator) IsBusy() bool {
709	return c.currentAgent.IsBusy()
710}
711
712func (c *coordinator) IsSessionBusy(sessionID string) bool {
713	return c.currentAgent.IsSessionBusy(sessionID)
714}
715
716func (c *coordinator) Model() Model {
717	return c.currentAgent.Model()
718}
719
720func (c *coordinator) UpdateModels(ctx context.Context) error {
721	// build the models again so we make sure we get the latest config
722	large, small, err := c.buildAgentModels(ctx)
723	if err != nil {
724		return err
725	}
726	c.currentAgent.SetModels(large, small)
727
728	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
729	if !ok {
730		return errors.New("coder agent not configured")
731	}
732
733	tools, err := c.buildTools(ctx, agentCfg)
734	if err != nil {
735		return err
736	}
737	c.currentAgent.SetTools(tools)
738	return nil
739}
740
741func (c *coordinator) QueuedPrompts(sessionID string) int {
742	return c.currentAgent.QueuedPrompts(sessionID)
743}
744
745func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
746	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
747	if !ok {
748		return errors.New("model provider not configured")
749	}
750	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
751}