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