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