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