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