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		_, hasThink := mergedOptions["thinking"]
282		if !hasThink && model.ModelCfg.Think {
283			mergedOptions["thinking"] = map[string]any{
284				// TODO: kujtim see if we need to make this dynamic
285				"budget_tokens": 2000,
286			}
287		}
288		parsed, err := anthropic.ParseOptions(mergedOptions)
289		if err == nil {
290			options[anthropic.Name] = parsed
291		}
292
293	case openrouter.Name:
294		_, hasReasoning := mergedOptions["reasoning"]
295		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
296			mergedOptions["reasoning"] = map[string]any{
297				"enabled": true,
298				"effort":  model.ModelCfg.ReasoningEffort,
299			}
300		}
301		parsed, err := openrouter.ParseOptions(mergedOptions)
302		if err == nil {
303			options[openrouter.Name] = parsed
304		}
305	case vercel.Name:
306		_, hasReasoning := mergedOptions["reasoning"]
307		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
308			mergedOptions["reasoning"] = map[string]any{
309				"enabled": true,
310				"effort":  model.ModelCfg.ReasoningEffort,
311			}
312		}
313		parsed, err := vercel.ParseOptions(mergedOptions)
314		if err == nil {
315			options[vercel.Name] = parsed
316		}
317	case google.Name:
318		_, hasReasoning := mergedOptions["thinking_config"]
319		if !hasReasoning {
320			if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") {
321				mergedOptions["thinking_config"] = map[string]any{
322					"thinking_budget":  2000,
323					"include_thoughts": true,
324				}
325			} else {
326				mergedOptions["thinking_config"] = map[string]any{
327					"thinking_level":   model.ModelCfg.ReasoningEffort,
328					"include_thoughts": true,
329				}
330			}
331		}
332		parsed, err := google.ParseOptions(mergedOptions)
333		if err == nil {
334			options[google.Name] = parsed
335		}
336	case openaicompat.Name:
337		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
338		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
339			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
340		}
341		parsed, err := openaicompat.ParseOptions(mergedOptions)
342		if err == nil {
343			options[openaicompat.Name] = parsed
344		}
345	}
346
347	return options
348}
349
350func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
351	modelOptions := getProviderOptions(model, cfg)
352	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
353	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
354	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
355	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
356	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
357	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
358}
359
360func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
361	large, small, err := c.buildAgentModels(ctx, isSubAgent)
362	if err != nil {
363		return nil, err
364	}
365
366	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
367	result := NewSessionAgent(SessionAgentOptions{
368		large,
369		small,
370		largeProviderCfg.SystemPromptPrefix,
371		"",
372		isSubAgent,
373		c.cfg.Options.DisableAutoSummarize,
374		c.permissions.SkipRequests(),
375		c.sessions,
376		c.messages,
377		nil,
378	})
379
380	c.readyWg.Go(func() error {
381		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
382		if err != nil {
383			return err
384		}
385		result.SetSystemPrompt(systemPrompt)
386		return nil
387	})
388
389	c.readyWg.Go(func() error {
390		tools, err := c.buildTools(ctx, agent)
391		if err != nil {
392			return err
393		}
394		result.SetTools(tools)
395		return nil
396	})
397
398	return result, nil
399}
400
401func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
402	var allTools []fantasy.AgentTool
403	if slices.Contains(agent.AllowedTools, AgentToolName) {
404		agentTool, err := c.agentTool(ctx)
405		if err != nil {
406			return nil, err
407		}
408		allTools = append(allTools, agentTool)
409	}
410
411	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
412		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
413		if err != nil {
414			return nil, err
415		}
416		allTools = append(allTools, agenticFetchTool)
417	}
418
419	// Get the model name for the agent
420	modelName := ""
421	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
422		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
423			modelName = model.Name
424		}
425	}
426
427	allTools = append(allTools,
428		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
429		tools.NewJobOutputTool(),
430		tools.NewJobKillTool(),
431		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
432		tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
433		tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
434		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
435		tools.NewGlobTool(c.cfg.WorkingDir()),
436		tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
437		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
438		tools.NewSourcegraphTool(nil),
439		tools.NewTodosTool(c.sessions),
440		tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
441		tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
442	)
443
444	// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
445	if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
446		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
447	}
448
449	if len(c.cfg.MCP) > 0 {
450		allTools = append(
451			allTools,
452			tools.NewListMCPResourcesTool(c.cfg, c.permissions),
453			tools.NewReadMCPResourceTool(c.cfg, c.permissions),
454		)
455	}
456
457	var filteredTools []fantasy.AgentTool
458	for _, tool := range allTools {
459		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
460			filteredTools = append(filteredTools, tool)
461		}
462	}
463
464	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
465		if agent.AllowedMCP == nil {
466			// No MCP restrictions
467			filteredTools = append(filteredTools, tool)
468			continue
469		}
470		if len(agent.AllowedMCP) == 0 {
471			// No MCPs allowed
472			slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
473			break
474		}
475
476		for mcp, tools := range agent.AllowedMCP {
477			if mcp != tool.MCP() {
478				continue
479			}
480			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
481				filteredTools = append(filteredTools, tool)
482				break
483			}
484			slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
485		}
486	}
487	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
488		return strings.Compare(a.Info().Name, b.Info().Name)
489	})
490	return filteredTools, nil
491}
492
493// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
494func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
495	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
496	if !ok {
497		return Model{}, Model{}, errors.New("large model not selected")
498	}
499	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
500	if !ok {
501		return Model{}, Model{}, errors.New("small model not selected")
502	}
503
504	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
505	if !ok {
506		return Model{}, Model{}, errors.New("large model provider not configured")
507	}
508
509	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
510	if err != nil {
511		return Model{}, Model{}, err
512	}
513
514	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
515	if !ok {
516		return Model{}, Model{}, errors.New("small model provider not configured")
517	}
518
519	smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
520	if err != nil {
521		return Model{}, Model{}, err
522	}
523
524	var largeCatwalkModel *catwalk.Model
525	var smallCatwalkModel *catwalk.Model
526
527	for _, m := range largeProviderCfg.Models {
528		if m.ID == largeModelCfg.Model {
529			largeCatwalkModel = &m
530		}
531	}
532	for _, m := range smallProviderCfg.Models {
533		if m.ID == smallModelCfg.Model {
534			smallCatwalkModel = &m
535		}
536	}
537
538	if largeCatwalkModel == nil {
539		return Model{}, Model{}, errors.New("large model not found in provider config")
540	}
541
542	if smallCatwalkModel == nil {
543		return Model{}, Model{}, errors.New("small model not found in provider config")
544	}
545
546	largeModelID := largeModelCfg.Model
547	smallModelID := smallModelCfg.Model
548
549	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
550		largeModelID += ":exacto"
551	}
552
553	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
554		smallModelID += ":exacto"
555	}
556
557	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
558	if err != nil {
559		return Model{}, Model{}, err
560	}
561	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
562	if err != nil {
563		return Model{}, Model{}, err
564	}
565
566	return Model{
567			Model:      largeModel,
568			CatwalkCfg: *largeCatwalkModel,
569			ModelCfg:   largeModelCfg,
570		}, Model{
571			Model:      smallModel,
572			CatwalkCfg: *smallCatwalkModel,
573			ModelCfg:   smallModelCfg,
574		}, nil
575}
576
577func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
578	var opts []anthropic.Option
579
580	switch {
581	case strings.HasPrefix(apiKey, "Bearer "):
582		// NOTE: Prevent the SDK from picking up the API key from env.
583		os.Setenv("ANTHROPIC_API_KEY", "")
584		headers["Authorization"] = apiKey
585	case providerID == string(catwalk.InferenceProviderMiniMax):
586		// NOTE: Prevent the SDK from picking up the API key from env.
587		os.Setenv("ANTHROPIC_API_KEY", "")
588		headers["Authorization"] = "Bearer " + apiKey
589	case apiKey != "":
590		// X-Api-Key header
591		opts = append(opts, anthropic.WithAPIKey(apiKey))
592	}
593
594	if len(headers) > 0 {
595		opts = append(opts, anthropic.WithHeaders(headers))
596	}
597
598	if baseURL != "" {
599		opts = append(opts, anthropic.WithBaseURL(baseURL))
600	}
601
602	if c.cfg.Options.Debug {
603		httpClient := log.NewHTTPClient()
604		opts = append(opts, anthropic.WithHTTPClient(httpClient))
605	}
606	return anthropic.New(opts...)
607}
608
609func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
610	opts := []openai.Option{
611		openai.WithAPIKey(apiKey),
612		openai.WithUseResponsesAPI(),
613	}
614	if c.cfg.Options.Debug {
615		httpClient := log.NewHTTPClient()
616		opts = append(opts, openai.WithHTTPClient(httpClient))
617	}
618	if len(headers) > 0 {
619		opts = append(opts, openai.WithHeaders(headers))
620	}
621	if baseURL != "" {
622		opts = append(opts, openai.WithBaseURL(baseURL))
623	}
624	return openai.New(opts...)
625}
626
627func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
628	opts := []openrouter.Option{
629		openrouter.WithAPIKey(apiKey),
630	}
631	if c.cfg.Options.Debug {
632		httpClient := log.NewHTTPClient()
633		opts = append(opts, openrouter.WithHTTPClient(httpClient))
634	}
635	if len(headers) > 0 {
636		opts = append(opts, openrouter.WithHeaders(headers))
637	}
638	return openrouter.New(opts...)
639}
640
641func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
642	opts := []vercel.Option{
643		vercel.WithAPIKey(apiKey),
644	}
645	if c.cfg.Options.Debug {
646		httpClient := log.NewHTTPClient()
647		opts = append(opts, vercel.WithHTTPClient(httpClient))
648	}
649	if len(headers) > 0 {
650		opts = append(opts, vercel.WithHeaders(headers))
651	}
652	return vercel.New(opts...)
653}
654
655func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
656	opts := []openaicompat.Option{
657		openaicompat.WithBaseURL(baseURL),
658		openaicompat.WithAPIKey(apiKey),
659	}
660
661	// Set HTTP client based on provider and debug mode.
662	var httpClient *http.Client
663	if providerID == string(catwalk.InferenceProviderCopilot) {
664		opts = append(opts, openaicompat.WithUseResponsesAPI())
665		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
666	} else if c.cfg.Options.Debug {
667		httpClient = log.NewHTTPClient()
668	}
669	if httpClient != nil {
670		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
671	}
672
673	if len(headers) > 0 {
674		opts = append(opts, openaicompat.WithHeaders(headers))
675	}
676
677	for extraKey, extraValue := range extraBody {
678		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
679	}
680
681	return openaicompat.New(opts...)
682}
683
684func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
685	opts := []azure.Option{
686		azure.WithBaseURL(baseURL),
687		azure.WithAPIKey(apiKey),
688		azure.WithUseResponsesAPI(),
689	}
690	if c.cfg.Options.Debug {
691		httpClient := log.NewHTTPClient()
692		opts = append(opts, azure.WithHTTPClient(httpClient))
693	}
694	if options == nil {
695		options = make(map[string]string)
696	}
697	if apiVersion, ok := options["apiVersion"]; ok {
698		opts = append(opts, azure.WithAPIVersion(apiVersion))
699	}
700	if len(headers) > 0 {
701		opts = append(opts, azure.WithHeaders(headers))
702	}
703
704	return azure.New(opts...)
705}
706
707func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
708	var opts []bedrock.Option
709	if c.cfg.Options.Debug {
710		httpClient := log.NewHTTPClient()
711		opts = append(opts, bedrock.WithHTTPClient(httpClient))
712	}
713	if len(headers) > 0 {
714		opts = append(opts, bedrock.WithHeaders(headers))
715	}
716	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
717	if bearerToken != "" {
718		opts = append(opts, bedrock.WithAPIKey(bearerToken))
719	}
720	return bedrock.New(opts...)
721}
722
723func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
724	opts := []google.Option{
725		google.WithBaseURL(baseURL),
726		google.WithGeminiAPIKey(apiKey),
727	}
728	if c.cfg.Options.Debug {
729		httpClient := log.NewHTTPClient()
730		opts = append(opts, google.WithHTTPClient(httpClient))
731	}
732	if len(headers) > 0 {
733		opts = append(opts, google.WithHeaders(headers))
734	}
735	return google.New(opts...)
736}
737
738func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
739	opts := []google.Option{}
740	if c.cfg.Options.Debug {
741		httpClient := log.NewHTTPClient()
742		opts = append(opts, google.WithHTTPClient(httpClient))
743	}
744	if len(headers) > 0 {
745		opts = append(opts, google.WithHeaders(headers))
746	}
747
748	project := options["project"]
749	location := options["location"]
750
751	opts = append(opts, google.WithVertex(project, location))
752
753	return google.New(opts...)
754}
755
756func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
757	opts := []hyper.Option{
758		hyper.WithBaseURL(baseURL),
759		hyper.WithAPIKey(apiKey),
760	}
761	if c.cfg.Options.Debug {
762		httpClient := log.NewHTTPClient()
763		opts = append(opts, hyper.WithHTTPClient(httpClient))
764	}
765	return hyper.New(opts...)
766}
767
768func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
769	if model.Think {
770		return true
771	}
772
773	if model.ProviderOptions == nil {
774		return false
775	}
776
777	opts, err := anthropic.ParseOptions(model.ProviderOptions)
778	if err != nil {
779		return false
780	}
781	if opts.Thinking != nil {
782		return true
783	}
784	return false
785}
786
787func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
788	headers := maps.Clone(providerCfg.ExtraHeaders)
789	if headers == nil {
790		headers = make(map[string]string)
791	}
792
793	// handle special headers for anthropic
794	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
795		if v, ok := headers["anthropic-beta"]; ok {
796			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
797		} else {
798			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
799		}
800	}
801
802	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
803	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
804
805	switch providerCfg.Type {
806	case openai.Name:
807		return c.buildOpenaiProvider(baseURL, apiKey, headers)
808	case anthropic.Name:
809		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
810	case openrouter.Name:
811		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
812	case vercel.Name:
813		return c.buildVercelProvider(baseURL, apiKey, headers)
814	case azure.Name:
815		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
816	case bedrock.Name:
817		return c.buildBedrockProvider(headers)
818	case google.Name:
819		return c.buildGoogleProvider(baseURL, apiKey, headers)
820	case "google-vertex":
821		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
822	case openaicompat.Name:
823		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
824			if providerCfg.ExtraBody == nil {
825				providerCfg.ExtraBody = map[string]any{}
826			}
827			providerCfg.ExtraBody["tool_stream"] = true
828		}
829		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
830	case hyper.Name:
831		return c.buildHyperProvider(baseURL, apiKey)
832	default:
833		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
834	}
835}
836
837func isExactoSupported(modelID string) bool {
838	supportedModels := []string{
839		"moonshotai/kimi-k2-0905",
840		"deepseek/deepseek-v3.1-terminus",
841		"z-ai/glm-4.6",
842		"openai/gpt-oss-120b",
843		"qwen/qwen3-coder",
844	}
845	return slices.Contains(supportedModels, modelID)
846}
847
848func (c *coordinator) Cancel(sessionID string) {
849	c.currentAgent.Cancel(sessionID)
850}
851
852func (c *coordinator) CancelAll() {
853	c.currentAgent.CancelAll()
854}
855
856func (c *coordinator) ClearQueue(sessionID string) {
857	c.currentAgent.ClearQueue(sessionID)
858}
859
860func (c *coordinator) IsBusy() bool {
861	return c.currentAgent.IsBusy()
862}
863
864func (c *coordinator) IsSessionBusy(sessionID string) bool {
865	return c.currentAgent.IsSessionBusy(sessionID)
866}
867
868func (c *coordinator) Model() Model {
869	return c.currentAgent.Model()
870}
871
872func (c *coordinator) UpdateModels(ctx context.Context) error {
873	// build the models again so we make sure we get the latest config
874	large, small, err := c.buildAgentModels(ctx, false)
875	if err != nil {
876		return err
877	}
878	c.currentAgent.SetModels(large, small)
879
880	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
881	if !ok {
882		return errors.New("coder agent not configured")
883	}
884
885	tools, err := c.buildTools(ctx, agentCfg)
886	if err != nil {
887		return err
888	}
889	c.currentAgent.SetTools(tools)
890	return nil
891}
892
893func (c *coordinator) QueuedPrompts(sessionID string) int {
894	return c.currentAgent.QueuedPrompts(sessionID)
895}
896
897func (c *coordinator) QueuedPromptsList(sessionID string) []string {
898	return c.currentAgent.QueuedPromptsList(sessionID)
899}
900
901func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
902	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
903	if !ok {
904		return errors.New("model provider not configured")
905	}
906	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
907}
908
909func (c *coordinator) isUnauthorized(err error) bool {
910	var providerErr *fantasy.ProviderError
911	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
912}
913
914func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
915	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
916		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
917		return err
918	}
919	if err := c.UpdateModels(ctx); err != nil {
920		return err
921	}
922	return nil
923}
924
925func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
926	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
927	if err != nil {
928		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
929		return err
930	}
931
932	providerCfg.APIKey = newAPIKey
933	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
934
935	if err := c.UpdateModels(ctx); err != nil {
936		return err
937	}
938	return nil
939}