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/notification"
 28	"github.com/charmbracelet/crush/internal/permission"
 29	"github.com/charmbracelet/crush/internal/session"
 30	"golang.org/x/sync/errgroup"
 31
 32	"charm.land/fantasy/providers/anthropic"
 33	"charm.land/fantasy/providers/azure"
 34	"charm.land/fantasy/providers/bedrock"
 35	"charm.land/fantasy/providers/google"
 36	"charm.land/fantasy/providers/openai"
 37	"charm.land/fantasy/providers/openaicompat"
 38	"charm.land/fantasy/providers/openrouter"
 39	openaisdk "github.com/openai/openai-go/v2/option"
 40	"github.com/qjebbs/go-jsons"
 41)
 42
 43type Coordinator interface {
 44	// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
 45	// SetMainAgent(string)
 46	Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
 47	Cancel(sessionID string)
 48	CancelAll()
 49	IsSessionBusy(sessionID string) bool
 50	IsBusy() bool
 51	QueuedPrompts(sessionID string) int
 52	ClearQueue(sessionID string)
 53	Summarize(context.Context, string) error
 54	Model() Model
 55	UpdateModels(ctx context.Context) error
 56}
 57
 58type coordinator struct {
 59	cfg         *config.Config
 60	sessions    session.Service
 61	messages    message.Service
 62	permissions permission.Service
 63	history     history.Service
 64	lspClients  *csync.Map[string, *lsp.Client]
 65	notifier    *notification.Notifier
 66	notifyCtx   context.Context
 67
 68	currentAgent SessionAgent
 69	agents       map[string]SessionAgent
 70
 71	readyWg errgroup.Group
 72}
 73
 74func NewCoordinator(
 75	ctx context.Context,
 76	cfg *config.Config,
 77	sessions session.Service,
 78	messages message.Service,
 79	permissions permission.Service,
 80	history history.Service,
 81	lspClients *csync.Map[string, *lsp.Client],
 82	notifier *notification.Notifier,
 83) (Coordinator, error) {
 84	if ctx == nil {
 85		ctx = context.Background()
 86	}
 87	c := &coordinator{
 88		cfg:         cfg,
 89		sessions:    sessions,
 90		messages:    messages,
 91		permissions: permissions,
 92		history:     history,
 93		lspClients:  lspClients,
 94		notifier:    notifier,
 95		notifyCtx:   ctx,
 96		agents:      make(map[string]SessionAgent),
 97	}
 98
 99	agentCfg, ok := cfg.Agents[config.AgentCoder]
100	if !ok {
101		return nil, errors.New("coder agent not configured")
102	}
103
104	// TODO: make this dynamic when we support multiple agents
105	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
106	if err != nil {
107		return nil, err
108	}
109
110	agent, err := c.buildAgent(ctx, prompt, agentCfg)
111	if err != nil {
112		return nil, err
113	}
114	c.currentAgent = agent
115	c.agents[config.AgentCoder] = agent
116	return c, nil
117}
118
119// Run implements Coordinator.
120func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
121	if err := c.readyWg.Wait(); err != nil {
122		return nil, err
123	}
124
125	model := c.currentAgent.Model()
126	maxTokens := model.CatwalkCfg.DefaultMaxTokens
127	if model.ModelCfg.MaxTokens != 0 {
128		maxTokens = model.ModelCfg.MaxTokens
129	}
130
131	if !model.CatwalkCfg.SupportsImages && attachments != nil {
132		attachments = nil
133	}
134
135	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
136	if !ok {
137		return nil, errors.New("model provider not configured")
138	}
139
140	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
141
142	return c.currentAgent.Run(ctx, SessionAgentCall{
143		SessionID:        sessionID,
144		Prompt:           prompt,
145		Attachments:      attachments,
146		MaxOutputTokens:  maxTokens,
147		ProviderOptions:  mergedOptions,
148		Temperature:      temp,
149		TopP:             topP,
150		TopK:             topK,
151		FrequencyPenalty: freqPenalty,
152		PresencePenalty:  presPenalty,
153	})
154}
155
156func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
157	options := fantasy.ProviderOptions{}
158
159	cfgOpts := []byte("{}")
160	providerCfgOpts := []byte("{}")
161	catwalkOpts := []byte("{}")
162
163	if model.ModelCfg.ProviderOptions != nil {
164		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
165		if err == nil {
166			cfgOpts = data
167		}
168	}
169
170	if providerCfg.ProviderOptions != nil {
171		data, err := json.Marshal(providerCfg.ProviderOptions)
172		if err == nil {
173			providerCfgOpts = data
174		}
175	}
176
177	if model.CatwalkCfg.Options.ProviderOptions != nil {
178		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
179		if err == nil {
180			catwalkOpts = data
181		}
182	}
183
184	readers := []io.Reader{
185		bytes.NewReader(catwalkOpts),
186		bytes.NewReader(providerCfgOpts),
187		bytes.NewReader(cfgOpts),
188	}
189
190	got, err := jsons.Merge(readers)
191	if err != nil {
192		slog.Error("Could not merge call config", "err", err)
193		return options
194	}
195
196	mergedOptions := make(map[string]any)
197
198	err = json.Unmarshal([]byte(got), &mergedOptions)
199	if err != nil {
200		slog.Error("Could not create config for call", "err", err)
201		return options
202	}
203
204	switch providerCfg.Type {
205	case openai.Name, azure.Name:
206		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
207		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
208			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
209		}
210		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
211			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
212				mergedOptions["reasoning_summary"] = "auto"
213				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
214			}
215			parsed, err := openai.ParseResponsesOptions(mergedOptions)
216			if err == nil {
217				options[openai.Name] = parsed
218			}
219		} else {
220			parsed, err := openai.ParseOptions(mergedOptions)
221			if err == nil {
222				options[openai.Name] = parsed
223			}
224		}
225	case anthropic.Name:
226		_, hasThink := mergedOptions["thinking"]
227		if !hasThink && model.ModelCfg.Think {
228			mergedOptions["thinking"] = map[string]any{
229				// TODO: kujtim see if we need to make this dynamic
230				"budget_tokens": 2000,
231			}
232		}
233		parsed, err := anthropic.ParseOptions(mergedOptions)
234		if err == nil {
235			options[anthropic.Name] = parsed
236		}
237
238	case openrouter.Name:
239		_, hasReasoning := mergedOptions["reasoning"]
240		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
241			mergedOptions["reasoning"] = map[string]any{
242				"enabled": true,
243				"effort":  model.ModelCfg.ReasoningEffort,
244			}
245		}
246		parsed, err := openrouter.ParseOptions(mergedOptions)
247		if err == nil {
248			options[openrouter.Name] = parsed
249		}
250	case google.Name:
251		_, hasReasoning := mergedOptions["thinking_config"]
252		if !hasReasoning {
253			mergedOptions["thinking_config"] = map[string]any{
254				"thinking_budget":  2000,
255				"include_thoughts": true,
256			}
257		}
258		parsed, err := google.ParseOptions(mergedOptions)
259		if err == nil {
260			options[google.Name] = parsed
261		}
262	case openaicompat.Name:
263		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
264		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
265			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
266		}
267		parsed, err := openaicompat.ParseOptions(mergedOptions)
268		if err == nil {
269			options[openaicompat.Name] = parsed
270		}
271	}
272
273	return options
274}
275
276func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
277	modelOptions := getProviderOptions(model, cfg)
278	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
279	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
280	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
281	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
282	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
283	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
284}
285
286func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
287	large, small, err := c.buildAgentModels(ctx)
288	if err != nil {
289		return nil, err
290	}
291
292	systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
293	if err != nil {
294		return nil, err
295	}
296
297	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
298	result := NewSessionAgent(SessionAgentOptions{
299		LargeModel:           large,
300		SmallModel:           small,
301		SystemPromptPrefix:   largeProviderCfg.SystemPromptPrefix,
302		SystemPrompt:         systemPrompt,
303		DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
304		IsYolo:               c.permissions.SkipRequests(),
305		Sessions:             c.sessions,
306		Messages:             c.messages,
307		Tools:                nil,
308		Notifier:             c.notifier,
309		NotificationCtx:      c.notifyCtx,
310	})
311	c.readyWg.Go(func() error {
312		tools, err := c.buildTools(ctx, agent)
313		if err != nil {
314			return err
315		}
316		result.SetTools(tools)
317		return nil
318	})
319
320	return result, nil
321}
322
323func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
324	var allTools []fantasy.AgentTool
325	if slices.Contains(agent.AllowedTools, AgentToolName) {
326		agentTool, err := c.agentTool(ctx)
327		if err != nil {
328			return nil, err
329		}
330		allTools = append(allTools, agentTool)
331	}
332
333	allTools = append(allTools,
334		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
335		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
336		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
337		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
338		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
339		tools.NewGlobTool(c.cfg.WorkingDir()),
340		tools.NewGrepTool(c.cfg.WorkingDir()),
341		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
342		tools.NewSourcegraphTool(nil),
343		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
344		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
345	)
346
347	if len(c.cfg.LSP) > 0 {
348		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
349	}
350
351	var filteredTools []fantasy.AgentTool
352	for _, tool := range allTools {
353		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
354			filteredTools = append(filteredTools, tool)
355		}
356	}
357
358	mcpTools := tools.GetMCPTools(context.Background(), c.permissions, c.cfg)
359
360	for _, mcpTool := range mcpTools {
361		if agent.AllowedMCP == nil {
362			// No MCP restrictions
363			filteredTools = append(filteredTools, mcpTool)
364		} else if len(agent.AllowedMCP) == 0 {
365			// no mcps allowed
366			break
367		}
368
369		for mcp, tools := range agent.AllowedMCP {
370			if mcp == mcpTool.MCP() {
371				if len(tools) == 0 {
372					filteredTools = append(filteredTools, mcpTool)
373				}
374				for _, t := range tools {
375					if t == mcpTool.MCPToolName() {
376						filteredTools = append(filteredTools, mcpTool)
377					}
378				}
379				break
380			}
381		}
382	}
383	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
384		return strings.Compare(a.Info().Name, b.Info().Name)
385	})
386	return filteredTools, nil
387}
388
389// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
390func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
391	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
392	if !ok {
393		return Model{}, Model{}, errors.New("large model not selected")
394	}
395	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
396	if !ok {
397		return Model{}, Model{}, errors.New("small model not selected")
398	}
399
400	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
401	if !ok {
402		return Model{}, Model{}, errors.New("large model provider not configured")
403	}
404
405	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
406	if err != nil {
407		return Model{}, Model{}, err
408	}
409
410	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
411	if !ok {
412		return Model{}, Model{}, errors.New("large model provider not configured")
413	}
414
415	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
416	if err != nil {
417		return Model{}, Model{}, err
418	}
419
420	var largeCatwalkModel *catwalk.Model
421	var smallCatwalkModel *catwalk.Model
422
423	for _, m := range largeProviderCfg.Models {
424		if m.ID == largeModelCfg.Model {
425			largeCatwalkModel = &m
426		}
427	}
428	for _, m := range smallProviderCfg.Models {
429		if m.ID == smallModelCfg.Model {
430			smallCatwalkModel = &m
431		}
432	}
433
434	if largeCatwalkModel == nil {
435		return Model{}, Model{}, errors.New("large model not found in provider config")
436	}
437
438	if smallCatwalkModel == nil {
439		return Model{}, Model{}, errors.New("snall model not found in provider config")
440	}
441
442	largeModelID := largeModelCfg.Model
443	smallModelID := smallModelCfg.Model
444
445	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
446		largeModelID += ":exacto"
447	}
448
449	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
450		smallModelID += ":exacto"
451	}
452
453	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
454	if err != nil {
455		return Model{}, Model{}, err
456	}
457	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
458	if err != nil {
459		return Model{}, Model{}, err
460	}
461
462	return Model{
463			Model:      largeModel,
464			CatwalkCfg: *largeCatwalkModel,
465			ModelCfg:   largeModelCfg,
466		}, Model{
467			Model:      smallModel,
468			CatwalkCfg: *smallCatwalkModel,
469			ModelCfg:   smallModelCfg,
470		}, nil
471}
472
473func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
474	hasBearerAuth := false
475	for key := range headers {
476		if strings.ToLower(key) == "authorization" {
477			hasBearerAuth = true
478			break
479		}
480	}
481
482	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
483
484	var opts []anthropic.Option
485	if apiKey != "" && !hasBearerAuth {
486		if isBearerToken {
487			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
488			headers["Authorization"] = apiKey
489			apiKey = "" // clear apiKey to avoid using X-Api-Key header
490		}
491	}
492
493	if apiKey != "" {
494		// Use standard X-Api-Key header
495		opts = append(opts, anthropic.WithAPIKey(apiKey))
496	}
497
498	if len(headers) > 0 {
499		opts = append(opts, anthropic.WithHeaders(headers))
500	}
501
502	if baseURL != "" {
503		opts = append(opts, anthropic.WithBaseURL(baseURL))
504	}
505
506	if c.cfg.Options.Debug {
507		httpClient := log.NewHTTPClient()
508		opts = append(opts, anthropic.WithHTTPClient(httpClient))
509	}
510
511	return anthropic.New(opts...)
512}
513
514func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
515	opts := []openai.Option{
516		openai.WithAPIKey(apiKey),
517		openai.WithUseResponsesAPI(),
518	}
519	if c.cfg.Options.Debug {
520		httpClient := log.NewHTTPClient()
521		opts = append(opts, openai.WithHTTPClient(httpClient))
522	}
523	if len(headers) > 0 {
524		opts = append(opts, openai.WithHeaders(headers))
525	}
526	if baseURL != "" {
527		opts = append(opts, openai.WithBaseURL(baseURL))
528	}
529	return openai.New(opts...)
530}
531
532func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
533	opts := []openrouter.Option{
534		openrouter.WithAPIKey(apiKey),
535	}
536	if c.cfg.Options.Debug {
537		httpClient := log.NewHTTPClient()
538		opts = append(opts, openrouter.WithHTTPClient(httpClient))
539	}
540	if len(headers) > 0 {
541		opts = append(opts, openrouter.WithHeaders(headers))
542	}
543	return openrouter.New(opts...)
544}
545
546func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
547	opts := []openaicompat.Option{
548		openaicompat.WithBaseURL(baseURL),
549		openaicompat.WithAPIKey(apiKey),
550	}
551	if c.cfg.Options.Debug {
552		httpClient := log.NewHTTPClient()
553		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
554	}
555	if len(headers) > 0 {
556		opts = append(opts, openaicompat.WithHeaders(headers))
557	}
558
559	for extraKey, extraValue := range extraBody {
560		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
561	}
562
563	return openaicompat.New(opts...)
564}
565
566func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
567	opts := []azure.Option{
568		azure.WithBaseURL(baseURL),
569		azure.WithAPIKey(apiKey),
570		azure.WithUseResponsesAPI(),
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	if headers == nil {
660		headers = make(map[string]string)
661	}
662
663	// handle special headers for anthropic
664	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
665		if v, ok := headers["anthropic-beta"]; ok {
666			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
667		} else {
668			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
669		}
670	}
671
672	// TODO: make sure we have
673	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
674	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
675
676	switch providerCfg.Type {
677	case openai.Name:
678		return c.buildOpenaiProvider(baseURL, apiKey, headers)
679	case anthropic.Name:
680		return c.buildAnthropicProvider(baseURL, apiKey, headers)
681	case openrouter.Name:
682		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
683	case azure.Name:
684		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
685	case bedrock.Name:
686		return c.buildBedrockProvider(headers)
687	case google.Name:
688		return c.buildGoogleProvider(baseURL, apiKey, headers)
689	case "google-vertex":
690		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
691	case openaicompat.Name:
692		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
693	default:
694		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
695	}
696}
697
698func isExactoSupported(modelID string) bool {
699	supportedModels := []string{
700		"moonshotai/kimi-k2-0905",
701		"deepseek/deepseek-v3.1-terminus",
702		"z-ai/glm-4.6",
703		"openai/gpt-oss-120b",
704		"qwen/qwen3-coder",
705	}
706	return slices.Contains(supportedModels, modelID)
707}
708
709func (c *coordinator) Cancel(sessionID string) {
710	c.currentAgent.Cancel(sessionID)
711}
712
713func (c *coordinator) CancelAll() {
714	c.currentAgent.CancelAll()
715}
716
717func (c *coordinator) ClearQueue(sessionID string) {
718	c.currentAgent.ClearQueue(sessionID)
719}
720
721func (c *coordinator) IsBusy() bool {
722	return c.currentAgent.IsBusy()
723}
724
725func (c *coordinator) IsSessionBusy(sessionID string) bool {
726	return c.currentAgent.IsSessionBusy(sessionID)
727}
728
729func (c *coordinator) Model() Model {
730	return c.currentAgent.Model()
731}
732
733func (c *coordinator) UpdateModels(ctx context.Context) error {
734	// build the models again so we make sure we get the latest config
735	large, small, err := c.buildAgentModels(ctx)
736	if err != nil {
737		return err
738	}
739	c.currentAgent.SetModels(large, small)
740
741	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
742	if !ok {
743		return errors.New("coder agent not configured")
744	}
745
746	tools, err := c.buildTools(ctx, agentCfg)
747	if err != nil {
748		return err
749	}
750	c.currentAgent.SetTools(tools)
751	return nil
752}
753
754func (c *coordinator) QueuedPrompts(sessionID string) int {
755	return c.currentAgent.QueuedPrompts(sessionID)
756}
757
758func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
759	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
760	if !ok {
761		return errors.New("model provider not configured")
762	}
763	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
764}