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