coordinator.go

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