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.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
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		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
378	}
379	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
380		return strings.Compare(a.Info().Name, b.Info().Name)
381	})
382	return filteredTools, nil
383}
384
385// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
386func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
387	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
388	if !ok {
389		return Model{}, Model{}, errors.New("large model not selected")
390	}
391	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
392	if !ok {
393		return Model{}, Model{}, errors.New("small model not selected")
394	}
395
396	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
397	if !ok {
398		return Model{}, Model{}, errors.New("large model provider not configured")
399	}
400
401	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
402	if err != nil {
403		return Model{}, Model{}, err
404	}
405
406	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
407	if !ok {
408		return Model{}, Model{}, errors.New("large model provider not configured")
409	}
410
411	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
412	if err != nil {
413		return Model{}, Model{}, err
414	}
415
416	var largeCatwalkModel *catwalk.Model
417	var smallCatwalkModel *catwalk.Model
418
419	for _, m := range largeProviderCfg.Models {
420		if m.ID == largeModelCfg.Model {
421			largeCatwalkModel = &m
422		}
423	}
424	for _, m := range smallProviderCfg.Models {
425		if m.ID == smallModelCfg.Model {
426			smallCatwalkModel = &m
427		}
428	}
429
430	if largeCatwalkModel == nil {
431		return Model{}, Model{}, errors.New("large model not found in provider config")
432	}
433
434	if smallCatwalkModel == nil {
435		return Model{}, Model{}, errors.New("snall model not found in provider config")
436	}
437
438	largeModelID := largeModelCfg.Model
439	smallModelID := smallModelCfg.Model
440
441	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
442		largeModelID += ":exacto"
443	}
444
445	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
446		smallModelID += ":exacto"
447	}
448
449	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
450	if err != nil {
451		return Model{}, Model{}, err
452	}
453	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
454	if err != nil {
455		return Model{}, Model{}, err
456	}
457
458	return Model{
459			Model:      largeModel,
460			CatwalkCfg: *largeCatwalkModel,
461			ModelCfg:   largeModelCfg,
462		}, Model{
463			Model:      smallModel,
464			CatwalkCfg: *smallCatwalkModel,
465			ModelCfg:   smallModelCfg,
466		}, nil
467}
468
469func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
470	hasBearerAuth := false
471	for key := range headers {
472		if strings.ToLower(key) == "authorization" {
473			hasBearerAuth = true
474			break
475		}
476	}
477
478	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
479
480	var opts []anthropic.Option
481	if apiKey != "" && !hasBearerAuth {
482		if isBearerToken {
483			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
484			headers["Authorization"] = apiKey
485			apiKey = "" // clear apiKey to avoid using X-Api-Key header
486		}
487	}
488
489	if apiKey != "" {
490		// Use standard X-Api-Key header
491		opts = append(opts, anthropic.WithAPIKey(apiKey))
492	}
493
494	if len(headers) > 0 {
495		opts = append(opts, anthropic.WithHeaders(headers))
496	}
497
498	if baseURL != "" {
499		opts = append(opts, anthropic.WithBaseURL(baseURL))
500	}
501
502	if c.cfg.Options.Debug {
503		httpClient := log.NewHTTPClient()
504		opts = append(opts, anthropic.WithHTTPClient(httpClient))
505	}
506
507	return anthropic.New(opts...)
508}
509
510func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
511	opts := []openai.Option{
512		openai.WithAPIKey(apiKey),
513		openai.WithUseResponsesAPI(),
514	}
515	if c.cfg.Options.Debug {
516		httpClient := log.NewHTTPClient()
517		opts = append(opts, openai.WithHTTPClient(httpClient))
518	}
519	if len(headers) > 0 {
520		opts = append(opts, openai.WithHeaders(headers))
521	}
522	if baseURL != "" {
523		opts = append(opts, openai.WithBaseURL(baseURL))
524	}
525	return openai.New(opts...)
526}
527
528func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
529	opts := []openrouter.Option{
530		openrouter.WithAPIKey(apiKey),
531	}
532	if c.cfg.Options.Debug {
533		httpClient := log.NewHTTPClient()
534		opts = append(opts, openrouter.WithHTTPClient(httpClient))
535	}
536	if len(headers) > 0 {
537		opts = append(opts, openrouter.WithHeaders(headers))
538	}
539	return openrouter.New(opts...)
540}
541
542func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
543	opts := []openaicompat.Option{
544		openaicompat.WithBaseURL(baseURL),
545		openaicompat.WithAPIKey(apiKey),
546	}
547	if c.cfg.Options.Debug {
548		httpClient := log.NewHTTPClient()
549		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
550	}
551	if len(headers) > 0 {
552		opts = append(opts, openaicompat.WithHeaders(headers))
553	}
554
555	for extraKey, extraValue := range extraBody {
556		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
557	}
558
559	return openaicompat.New(opts...)
560}
561
562func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
563	opts := []azure.Option{
564		azure.WithBaseURL(baseURL),
565		azure.WithAPIKey(apiKey),
566		azure.WithUseResponsesAPI(),
567	}
568	if c.cfg.Options.Debug {
569		httpClient := log.NewHTTPClient()
570		opts = append(opts, azure.WithHTTPClient(httpClient))
571	}
572	if options == nil {
573		options = make(map[string]string)
574	}
575	if apiVersion, ok := options["apiVersion"]; ok {
576		opts = append(opts, azure.WithAPIVersion(apiVersion))
577	}
578	if len(headers) > 0 {
579		opts = append(opts, azure.WithHeaders(headers))
580	}
581
582	return azure.New(opts...)
583}
584
585func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
586	var opts []bedrock.Option
587	if c.cfg.Options.Debug {
588		httpClient := log.NewHTTPClient()
589		opts = append(opts, bedrock.WithHTTPClient(httpClient))
590	}
591	if len(headers) > 0 {
592		opts = append(opts, bedrock.WithHeaders(headers))
593	}
594	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
595	if bearerToken != "" {
596		opts = append(opts, bedrock.WithAPIKey(bearerToken))
597	}
598	return bedrock.New(opts...)
599}
600
601func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
602	opts := []google.Option{
603		google.WithBaseURL(baseURL),
604		google.WithGeminiAPIKey(apiKey),
605	}
606	if c.cfg.Options.Debug {
607		httpClient := log.NewHTTPClient()
608		opts = append(opts, google.WithHTTPClient(httpClient))
609	}
610	if len(headers) > 0 {
611		opts = append(opts, google.WithHeaders(headers))
612	}
613	return google.New(opts...)
614}
615
616func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
617	opts := []google.Option{}
618	if c.cfg.Options.Debug {
619		httpClient := log.NewHTTPClient()
620		opts = append(opts, google.WithHTTPClient(httpClient))
621	}
622	if len(headers) > 0 {
623		opts = append(opts, google.WithHeaders(headers))
624	}
625
626	project := options["project"]
627	location := options["location"]
628
629	opts = append(opts, google.WithVertex(project, location))
630
631	return google.New(opts...)
632}
633
634func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
635	if model.Think {
636		return true
637	}
638
639	if model.ProviderOptions == nil {
640		return false
641	}
642
643	opts, err := anthropic.ParseOptions(model.ProviderOptions)
644	if err != nil {
645		return false
646	}
647	if opts.Thinking != nil {
648		return true
649	}
650	return false
651}
652
653func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
654	headers := maps.Clone(providerCfg.ExtraHeaders)
655	if headers == nil {
656		headers = make(map[string]string)
657	}
658
659	// handle special headers for anthropic
660	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
661		if v, ok := headers["anthropic-beta"]; ok {
662			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
663		} else {
664			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
665		}
666	}
667
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}