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