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