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