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