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
 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	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
325		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
326		if err != nil {
327			return nil, err
328		}
329		allTools = append(allTools, agenticFetchTool)
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	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
668	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
669
670	switch providerCfg.Type {
671	case openai.Name:
672		return c.buildOpenaiProvider(baseURL, apiKey, headers)
673	case anthropic.Name:
674		return c.buildAnthropicProvider(baseURL, apiKey, headers)
675	case openrouter.Name:
676		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
677	case azure.Name:
678		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
679	case bedrock.Name:
680		return c.buildBedrockProvider(headers)
681	case google.Name:
682		return c.buildGoogleProvider(baseURL, apiKey, headers)
683	case "google-vertex":
684		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
685	case openaicompat.Name:
686		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
687	default:
688		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
689	}
690}
691
692func isExactoSupported(modelID string) bool {
693	supportedModels := []string{
694		"moonshotai/kimi-k2-0905",
695		"deepseek/deepseek-v3.1-terminus",
696		"z-ai/glm-4.6",
697		"openai/gpt-oss-120b",
698		"qwen/qwen3-coder",
699	}
700	return slices.Contains(supportedModels, modelID)
701}
702
703func (c *coordinator) Cancel(sessionID string) {
704	c.currentAgent.Cancel(sessionID)
705}
706
707func (c *coordinator) CancelAll() {
708	c.currentAgent.CancelAll()
709}
710
711func (c *coordinator) ClearQueue(sessionID string) {
712	c.currentAgent.ClearQueue(sessionID)
713}
714
715func (c *coordinator) IsBusy() bool {
716	return c.currentAgent.IsBusy()
717}
718
719func (c *coordinator) IsSessionBusy(sessionID string) bool {
720	return c.currentAgent.IsSessionBusy(sessionID)
721}
722
723func (c *coordinator) Model() Model {
724	return c.currentAgent.Model()
725}
726
727func (c *coordinator) UpdateModels(ctx context.Context) error {
728	// build the models again so we make sure we get the latest config
729	large, small, err := c.buildAgentModels(ctx)
730	if err != nil {
731		return err
732	}
733	c.currentAgent.SetModels(large, small)
734
735	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
736	if !ok {
737		return errors.New("coder agent not configured")
738	}
739
740	tools, err := c.buildTools(ctx, agentCfg)
741	if err != nil {
742		return err
743	}
744	c.currentAgent.SetTools(tools)
745	return nil
746}
747
748func (c *coordinator) QueuedPrompts(sessionID string) int {
749	return c.currentAgent.QueuedPrompts(sessionID)
750}
751
752func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
753	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
754	if !ok {
755		return errors.New("model provider not configured")
756	}
757	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
758}