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