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			mergedOptions["thinking_config"] = map[string]any{
321				"thinking_budget":  2000,
322				"include_thoughts": true,
323			}
324		}
325		parsed, err := google.ParseOptions(mergedOptions)
326		if err == nil {
327			options[google.Name] = parsed
328		}
329	case openaicompat.Name:
330		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
331		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
332			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
333		}
334		parsed, err := openaicompat.ParseOptions(mergedOptions)
335		if err == nil {
336			options[openaicompat.Name] = parsed
337		}
338	}
339
340	return options
341}
342
343func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
344	modelOptions := getProviderOptions(model, cfg)
345	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
346	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
347	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
348	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
349	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
350	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
351}
352
353func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
354	large, small, err := c.buildAgentModels(ctx, isSubAgent)
355	if err != nil {
356		return nil, err
357	}
358
359	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
360	result := NewSessionAgent(SessionAgentOptions{
361		large,
362		small,
363		largeProviderCfg.SystemPromptPrefix,
364		"",
365		isSubAgent,
366		c.cfg.Options.DisableAutoSummarize,
367		c.permissions.SkipRequests(),
368		c.sessions,
369		c.messages,
370		nil,
371	})
372
373	c.readyWg.Go(func() error {
374		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
375		if err != nil {
376			return err
377		}
378		result.SetSystemPrompt(systemPrompt)
379		return nil
380	})
381
382	c.readyWg.Go(func() error {
383		tools, err := c.buildTools(ctx, agent)
384		if err != nil {
385			return err
386		}
387		result.SetTools(tools)
388		return nil
389	})
390
391	return result, nil
392}
393
394func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
395	var allTools []fantasy.AgentTool
396	if slices.Contains(agent.AllowedTools, AgentToolName) {
397		agentTool, err := c.agentTool(ctx)
398		if err != nil {
399			return nil, err
400		}
401		allTools = append(allTools, agentTool)
402	}
403
404	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
405		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
406		if err != nil {
407			return nil, err
408		}
409		allTools = append(allTools, agenticFetchTool)
410	}
411
412	// Get the model name for the agent
413	modelName := ""
414	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
415		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
416			modelName = model.Name
417		}
418	}
419
420	allTools = append(allTools,
421		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
422		tools.NewJobOutputTool(),
423		tools.NewJobKillTool(),
424		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
425		tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
426		tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
427		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
428		tools.NewGlobTool(c.cfg.WorkingDir()),
429		tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
430		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
431		tools.NewSourcegraphTool(nil),
432		tools.NewTodosTool(c.sessions),
433		tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
434		tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
435	)
436
437	// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
438	if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
439		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
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, smallModelCfg, 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, providerID string) (fantasy.Provider, error) {
570	var opts []anthropic.Option
571
572	switch {
573	case strings.HasPrefix(apiKey, "Bearer "):
574		// NOTE: Prevent the SDK from picking up the API key from env.
575		os.Setenv("ANTHROPIC_API_KEY", "")
576		headers["Authorization"] = apiKey
577	case providerID == string(catwalk.InferenceProviderMiniMax):
578		// NOTE: Prevent the SDK from picking up the API key from env.
579		os.Setenv("ANTHROPIC_API_KEY", "")
580		headers["Authorization"] = "Bearer " + apiKey
581	case apiKey != "":
582		// X-Api-Key header
583		opts = append(opts, anthropic.WithAPIKey(apiKey))
584	}
585
586	if len(headers) > 0 {
587		opts = append(opts, anthropic.WithHeaders(headers))
588	}
589
590	if baseURL != "" {
591		opts = append(opts, anthropic.WithBaseURL(baseURL))
592	}
593
594	if c.cfg.Options.Debug {
595		httpClient := log.NewHTTPClient()
596		opts = append(opts, anthropic.WithHTTPClient(httpClient))
597	}
598	return anthropic.New(opts...)
599}
600
601func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
602	opts := []openai.Option{
603		openai.WithAPIKey(apiKey),
604		openai.WithUseResponsesAPI(),
605	}
606	if c.cfg.Options.Debug {
607		httpClient := log.NewHTTPClient()
608		opts = append(opts, openai.WithHTTPClient(httpClient))
609	}
610	if len(headers) > 0 {
611		opts = append(opts, openai.WithHeaders(headers))
612	}
613	if baseURL != "" {
614		opts = append(opts, openai.WithBaseURL(baseURL))
615	}
616	return openai.New(opts...)
617}
618
619func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
620	opts := []openrouter.Option{
621		openrouter.WithAPIKey(apiKey),
622	}
623	if c.cfg.Options.Debug {
624		httpClient := log.NewHTTPClient()
625		opts = append(opts, openrouter.WithHTTPClient(httpClient))
626	}
627	if len(headers) > 0 {
628		opts = append(opts, openrouter.WithHeaders(headers))
629	}
630	return openrouter.New(opts...)
631}
632
633func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
634	opts := []vercel.Option{
635		vercel.WithAPIKey(apiKey),
636	}
637	if c.cfg.Options.Debug {
638		httpClient := log.NewHTTPClient()
639		opts = append(opts, vercel.WithHTTPClient(httpClient))
640	}
641	if len(headers) > 0 {
642		opts = append(opts, vercel.WithHeaders(headers))
643	}
644	return vercel.New(opts...)
645}
646
647func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
648	opts := []openaicompat.Option{
649		openaicompat.WithBaseURL(baseURL),
650		openaicompat.WithAPIKey(apiKey),
651	}
652
653	// Set HTTP client based on provider and debug mode.
654	var httpClient *http.Client
655	if providerID == string(catwalk.InferenceProviderCopilot) {
656		opts = append(opts, openaicompat.WithUseResponsesAPI())
657		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
658	} else if c.cfg.Options.Debug {
659		httpClient = log.NewHTTPClient()
660	}
661	if httpClient != nil {
662		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
663	}
664
665	if len(headers) > 0 {
666		opts = append(opts, openaicompat.WithHeaders(headers))
667	}
668
669	for extraKey, extraValue := range extraBody {
670		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
671	}
672
673	return openaicompat.New(opts...)
674}
675
676func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
677	opts := []azure.Option{
678		azure.WithBaseURL(baseURL),
679		azure.WithAPIKey(apiKey),
680		azure.WithUseResponsesAPI(),
681	}
682	if c.cfg.Options.Debug {
683		httpClient := log.NewHTTPClient()
684		opts = append(opts, azure.WithHTTPClient(httpClient))
685	}
686	if options == nil {
687		options = make(map[string]string)
688	}
689	if apiVersion, ok := options["apiVersion"]; ok {
690		opts = append(opts, azure.WithAPIVersion(apiVersion))
691	}
692	if len(headers) > 0 {
693		opts = append(opts, azure.WithHeaders(headers))
694	}
695
696	return azure.New(opts...)
697}
698
699func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
700	var opts []bedrock.Option
701	if c.cfg.Options.Debug {
702		httpClient := log.NewHTTPClient()
703		opts = append(opts, bedrock.WithHTTPClient(httpClient))
704	}
705	if len(headers) > 0 {
706		opts = append(opts, bedrock.WithHeaders(headers))
707	}
708	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
709	if bearerToken != "" {
710		opts = append(opts, bedrock.WithAPIKey(bearerToken))
711	}
712	return bedrock.New(opts...)
713}
714
715func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
716	opts := []google.Option{
717		google.WithBaseURL(baseURL),
718		google.WithGeminiAPIKey(apiKey),
719	}
720	if c.cfg.Options.Debug {
721		httpClient := log.NewHTTPClient()
722		opts = append(opts, google.WithHTTPClient(httpClient))
723	}
724	if len(headers) > 0 {
725		opts = append(opts, google.WithHeaders(headers))
726	}
727	return google.New(opts...)
728}
729
730func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
731	opts := []google.Option{}
732	if c.cfg.Options.Debug {
733		httpClient := log.NewHTTPClient()
734		opts = append(opts, google.WithHTTPClient(httpClient))
735	}
736	if len(headers) > 0 {
737		opts = append(opts, google.WithHeaders(headers))
738	}
739
740	project := options["project"]
741	location := options["location"]
742
743	opts = append(opts, google.WithVertex(project, location))
744
745	return google.New(opts...)
746}
747
748func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
749	opts := []hyper.Option{
750		hyper.WithBaseURL(baseURL),
751		hyper.WithAPIKey(apiKey),
752	}
753	if c.cfg.Options.Debug {
754		httpClient := log.NewHTTPClient()
755		opts = append(opts, hyper.WithHTTPClient(httpClient))
756	}
757	return hyper.New(opts...)
758}
759
760func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
761	if model.Think {
762		return true
763	}
764
765	if model.ProviderOptions == nil {
766		return false
767	}
768
769	opts, err := anthropic.ParseOptions(model.ProviderOptions)
770	if err != nil {
771		return false
772	}
773	if opts.Thinking != nil {
774		return true
775	}
776	return false
777}
778
779func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
780	headers := maps.Clone(providerCfg.ExtraHeaders)
781	if headers == nil {
782		headers = make(map[string]string)
783	}
784
785	// handle special headers for anthropic
786	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
787		if v, ok := headers["anthropic-beta"]; ok {
788			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
789		} else {
790			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
791		}
792	}
793
794	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
795	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
796
797	switch providerCfg.Type {
798	case openai.Name:
799		return c.buildOpenaiProvider(baseURL, apiKey, headers)
800	case anthropic.Name:
801		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
802	case openrouter.Name:
803		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
804	case vercel.Name:
805		return c.buildVercelProvider(baseURL, apiKey, headers)
806	case azure.Name:
807		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
808	case bedrock.Name:
809		return c.buildBedrockProvider(headers)
810	case google.Name:
811		return c.buildGoogleProvider(baseURL, apiKey, headers)
812	case "google-vertex":
813		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
814	case openaicompat.Name:
815		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
816			if providerCfg.ExtraBody == nil {
817				providerCfg.ExtraBody = map[string]any{}
818			}
819			providerCfg.ExtraBody["tool_stream"] = true
820		}
821		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
822	case hyper.Name:
823		return c.buildHyperProvider(baseURL, apiKey)
824	default:
825		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
826	}
827}
828
829func isExactoSupported(modelID string) bool {
830	supportedModels := []string{
831		"moonshotai/kimi-k2-0905",
832		"deepseek/deepseek-v3.1-terminus",
833		"z-ai/glm-4.6",
834		"openai/gpt-oss-120b",
835		"qwen/qwen3-coder",
836	}
837	return slices.Contains(supportedModels, modelID)
838}
839
840func (c *coordinator) Cancel(sessionID string) {
841	c.currentAgent.Cancel(sessionID)
842}
843
844func (c *coordinator) CancelAll() {
845	c.currentAgent.CancelAll()
846}
847
848func (c *coordinator) ClearQueue(sessionID string) {
849	c.currentAgent.ClearQueue(sessionID)
850}
851
852func (c *coordinator) IsBusy() bool {
853	return c.currentAgent.IsBusy()
854}
855
856func (c *coordinator) IsSessionBusy(sessionID string) bool {
857	return c.currentAgent.IsSessionBusy(sessionID)
858}
859
860func (c *coordinator) Model() Model {
861	return c.currentAgent.Model()
862}
863
864func (c *coordinator) UpdateModels(ctx context.Context) error {
865	// build the models again so we make sure we get the latest config
866	large, small, err := c.buildAgentModels(ctx, false)
867	if err != nil {
868		return err
869	}
870	c.currentAgent.SetModels(large, small)
871
872	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
873	if !ok {
874		return errors.New("coder agent not configured")
875	}
876
877	tools, err := c.buildTools(ctx, agentCfg)
878	if err != nil {
879		return err
880	}
881	c.currentAgent.SetTools(tools)
882	return nil
883}
884
885func (c *coordinator) QueuedPrompts(sessionID string) int {
886	return c.currentAgent.QueuedPrompts(sessionID)
887}
888
889func (c *coordinator) QueuedPromptsList(sessionID string) []string {
890	return c.currentAgent.QueuedPromptsList(sessionID)
891}
892
893func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
894	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
895	if !ok {
896		return errors.New("model provider not configured")
897	}
898	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
899}
900
901func (c *coordinator) isUnauthorized(err error) bool {
902	var providerErr *fantasy.ProviderError
903	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
904}
905
906func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
907	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
908		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
909		return err
910	}
911	if err := c.UpdateModels(ctx); err != nil {
912		return err
913	}
914	return nil
915}
916
917func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
918	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
919	if err != nil {
920		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
921		return err
922	}
923
924	providerCfg.APIKey = newAPIKey
925	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
926
927	if err := c.UpdateModels(ctx); err != nil {
928		return err
929	}
930	return nil
931}