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