coordinator.go

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