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