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