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		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	var opts []anthropic.Option
479
480	if strings.HasPrefix(apiKey, "Bearer ") {
481		// NOTE: Prevent the SDK from picking up the API key from env.
482		os.Setenv("ANTHROPIC_API_KEY", "")
483
484		headers["Authorization"] = apiKey
485	} else if apiKey != "" {
486		// X-Api-Key header
487		opts = append(opts, anthropic.WithAPIKey(apiKey))
488	}
489
490	if len(headers) > 0 {
491		opts = append(opts, anthropic.WithHeaders(headers))
492	}
493
494	if baseURL != "" {
495		opts = append(opts, anthropic.WithBaseURL(baseURL))
496	}
497
498	if c.cfg.Options.Debug {
499		httpClient := log.NewHTTPClient()
500		opts = append(opts, anthropic.WithHTTPClient(httpClient))
501	}
502
503	return anthropic.New(opts...)
504}
505
506func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
507	opts := []openai.Option{
508		openai.WithAPIKey(apiKey),
509		openai.WithUseResponsesAPI(),
510	}
511	if c.cfg.Options.Debug {
512		httpClient := log.NewHTTPClient()
513		opts = append(opts, openai.WithHTTPClient(httpClient))
514	}
515	if len(headers) > 0 {
516		opts = append(opts, openai.WithHeaders(headers))
517	}
518	if baseURL != "" {
519		opts = append(opts, openai.WithBaseURL(baseURL))
520	}
521	return openai.New(opts...)
522}
523
524func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
525	opts := []openrouter.Option{
526		openrouter.WithAPIKey(apiKey),
527	}
528	if c.cfg.Options.Debug {
529		httpClient := log.NewHTTPClient()
530		opts = append(opts, openrouter.WithHTTPClient(httpClient))
531	}
532	if len(headers) > 0 {
533		opts = append(opts, openrouter.WithHeaders(headers))
534	}
535	return openrouter.New(opts...)
536}
537
538func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
539	opts := []openaicompat.Option{
540		openaicompat.WithBaseURL(baseURL),
541		openaicompat.WithAPIKey(apiKey),
542	}
543	if c.cfg.Options.Debug {
544		httpClient := log.NewHTTPClient()
545		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
546	}
547	if len(headers) > 0 {
548		opts = append(opts, openaicompat.WithHeaders(headers))
549	}
550
551	for extraKey, extraValue := range extraBody {
552		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
553	}
554
555	return openaicompat.New(opts...)
556}
557
558func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
559	opts := []azure.Option{
560		azure.WithBaseURL(baseURL),
561		azure.WithAPIKey(apiKey),
562		azure.WithUseResponsesAPI(),
563	}
564	if c.cfg.Options.Debug {
565		httpClient := log.NewHTTPClient()
566		opts = append(opts, azure.WithHTTPClient(httpClient))
567	}
568	if options == nil {
569		options = make(map[string]string)
570	}
571	if apiVersion, ok := options["apiVersion"]; ok {
572		opts = append(opts, azure.WithAPIVersion(apiVersion))
573	}
574	if len(headers) > 0 {
575		opts = append(opts, azure.WithHeaders(headers))
576	}
577
578	return azure.New(opts...)
579}
580
581func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
582	var opts []bedrock.Option
583	if c.cfg.Options.Debug {
584		httpClient := log.NewHTTPClient()
585		opts = append(opts, bedrock.WithHTTPClient(httpClient))
586	}
587	if len(headers) > 0 {
588		opts = append(opts, bedrock.WithHeaders(headers))
589	}
590	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
591	if bearerToken != "" {
592		opts = append(opts, bedrock.WithAPIKey(bearerToken))
593	}
594	return bedrock.New(opts...)
595}
596
597func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
598	opts := []google.Option{
599		google.WithBaseURL(baseURL),
600		google.WithGeminiAPIKey(apiKey),
601	}
602	if c.cfg.Options.Debug {
603		httpClient := log.NewHTTPClient()
604		opts = append(opts, google.WithHTTPClient(httpClient))
605	}
606	if len(headers) > 0 {
607		opts = append(opts, google.WithHeaders(headers))
608	}
609	return google.New(opts...)
610}
611
612func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
613	opts := []google.Option{}
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
622	project := options["project"]
623	location := options["location"]
624
625	opts = append(opts, google.WithVertex(project, location))
626
627	return google.New(opts...)
628}
629
630func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
631	if model.Think {
632		return true
633	}
634
635	if model.ProviderOptions == nil {
636		return false
637	}
638
639	opts, err := anthropic.ParseOptions(model.ProviderOptions)
640	if err != nil {
641		return false
642	}
643	if opts.Thinking != nil {
644		return true
645	}
646	return false
647}
648
649func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
650	headers := maps.Clone(providerCfg.ExtraHeaders)
651	if headers == nil {
652		headers = make(map[string]string)
653	}
654
655	// handle special headers for anthropic
656	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
657		if v, ok := headers["anthropic-beta"]; ok {
658			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
659		} else {
660			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
661		}
662	}
663
664	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
665	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
666
667	switch providerCfg.Type {
668	case openai.Name:
669		return c.buildOpenaiProvider(baseURL, apiKey, headers)
670	case anthropic.Name:
671		return c.buildAnthropicProvider(baseURL, apiKey, headers)
672	case openrouter.Name:
673		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
674	case azure.Name:
675		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
676	case bedrock.Name:
677		return c.buildBedrockProvider(headers)
678	case google.Name:
679		return c.buildGoogleProvider(baseURL, apiKey, headers)
680	case "google-vertex":
681		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
682	case openaicompat.Name:
683		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
684	default:
685		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
686	}
687}
688
689func isExactoSupported(modelID string) bool {
690	supportedModels := []string{
691		"moonshotai/kimi-k2-0905",
692		"deepseek/deepseek-v3.1-terminus",
693		"z-ai/glm-4.6",
694		"openai/gpt-oss-120b",
695		"qwen/qwen3-coder",
696	}
697	return slices.Contains(supportedModels, modelID)
698}
699
700func (c *coordinator) Cancel(sessionID string) {
701	c.currentAgent.Cancel(sessionID)
702}
703
704func (c *coordinator) CancelAll() {
705	c.currentAgent.CancelAll()
706}
707
708func (c *coordinator) ClearQueue(sessionID string) {
709	c.currentAgent.ClearQueue(sessionID)
710}
711
712func (c *coordinator) IsBusy() bool {
713	return c.currentAgent.IsBusy()
714}
715
716func (c *coordinator) IsSessionBusy(sessionID string) bool {
717	return c.currentAgent.IsSessionBusy(sessionID)
718}
719
720func (c *coordinator) Model() Model {
721	return c.currentAgent.Model()
722}
723
724func (c *coordinator) UpdateModels(ctx context.Context) error {
725	// build the models again so we make sure we get the latest config
726	large, small, err := c.buildAgentModels(ctx)
727	if err != nil {
728		return err
729	}
730	c.currentAgent.SetModels(large, small)
731
732	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
733	if !ok {
734		return errors.New("coder agent not configured")
735	}
736
737	tools, err := c.buildTools(ctx, agentCfg)
738	if err != nil {
739		return err
740	}
741	c.currentAgent.SetTools(tools)
742	return nil
743}
744
745func (c *coordinator) QueuedPrompts(sessionID string) int {
746	return c.currentAgent.QueuedPrompts(sessionID)
747}
748
749func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
750	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
751	if !ok {
752		return errors.New("model provider not configured")
753	}
754	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
755}