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