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