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