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				break
476			}
477			slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
478		}
479	}
480	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
481		return strings.Compare(a.Info().Name, b.Info().Name)
482	})
483	return filteredTools, nil
484}
485
486// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
487func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
488	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
489	if !ok {
490		return Model{}, Model{}, errors.New("large model not selected")
491	}
492	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
493	if !ok {
494		return Model{}, Model{}, errors.New("small model not selected")
495	}
496
497	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
498	if !ok {
499		return Model{}, Model{}, errors.New("large model provider not configured")
500	}
501
502	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
503	if err != nil {
504		return Model{}, Model{}, err
505	}
506
507	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
508	if !ok {
509		return Model{}, Model{}, errors.New("small model provider not configured")
510	}
511
512	smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
513	if err != nil {
514		return Model{}, Model{}, err
515	}
516
517	var largeCatwalkModel *catwalk.Model
518	var smallCatwalkModel *catwalk.Model
519
520	for _, m := range largeProviderCfg.Models {
521		if m.ID == largeModelCfg.Model {
522			largeCatwalkModel = &m
523		}
524	}
525	for _, m := range smallProviderCfg.Models {
526		if m.ID == smallModelCfg.Model {
527			smallCatwalkModel = &m
528		}
529	}
530
531	if largeCatwalkModel == nil {
532		return Model{}, Model{}, errors.New("large model not found in provider config")
533	}
534
535	if smallCatwalkModel == nil {
536		return Model{}, Model{}, errors.New("small model not found in provider config")
537	}
538
539	largeModelID := largeModelCfg.Model
540	smallModelID := smallModelCfg.Model
541
542	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
543		largeModelID += ":exacto"
544	}
545
546	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
547		smallModelID += ":exacto"
548	}
549
550	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
551	if err != nil {
552		return Model{}, Model{}, err
553	}
554	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
555	if err != nil {
556		return Model{}, Model{}, err
557	}
558
559	return Model{
560			Model:      largeModel,
561			CatwalkCfg: *largeCatwalkModel,
562			ModelCfg:   largeModelCfg,
563		}, Model{
564			Model:      smallModel,
565			CatwalkCfg: *smallCatwalkModel,
566			ModelCfg:   smallModelCfg,
567		}, nil
568}
569
570func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
571	var opts []anthropic.Option
572
573	switch {
574	case strings.HasPrefix(apiKey, "Bearer "):
575		// NOTE: Prevent the SDK from picking up the API key from env.
576		os.Setenv("ANTHROPIC_API_KEY", "")
577		headers["Authorization"] = apiKey
578	case providerID == string(catwalk.InferenceProviderMiniMax):
579		// NOTE: Prevent the SDK from picking up the API key from env.
580		os.Setenv("ANTHROPIC_API_KEY", "")
581		headers["Authorization"] = "Bearer " + apiKey
582	case apiKey != "":
583		// X-Api-Key header
584		opts = append(opts, anthropic.WithAPIKey(apiKey))
585	}
586
587	if len(headers) > 0 {
588		opts = append(opts, anthropic.WithHeaders(headers))
589	}
590
591	if baseURL != "" {
592		opts = append(opts, anthropic.WithBaseURL(baseURL))
593	}
594
595	if c.cfg.Options.Debug {
596		httpClient := log.NewHTTPClient()
597		opts = append(opts, anthropic.WithHTTPClient(httpClient))
598	}
599	return anthropic.New(opts...)
600}
601
602func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
603	opts := []openai.Option{
604		openai.WithAPIKey(apiKey),
605		openai.WithUseResponsesAPI(),
606	}
607	if c.cfg.Options.Debug {
608		httpClient := log.NewHTTPClient()
609		opts = append(opts, openai.WithHTTPClient(httpClient))
610	}
611	if len(headers) > 0 {
612		opts = append(opts, openai.WithHeaders(headers))
613	}
614	if baseURL != "" {
615		opts = append(opts, openai.WithBaseURL(baseURL))
616	}
617	return openai.New(opts...)
618}
619
620func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
621	opts := []openrouter.Option{
622		openrouter.WithAPIKey(apiKey),
623	}
624	if c.cfg.Options.Debug {
625		httpClient := log.NewHTTPClient()
626		opts = append(opts, openrouter.WithHTTPClient(httpClient))
627	}
628	if len(headers) > 0 {
629		opts = append(opts, openrouter.WithHeaders(headers))
630	}
631	return openrouter.New(opts...)
632}
633
634func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
635	opts := []vercel.Option{
636		vercel.WithAPIKey(apiKey),
637	}
638	if c.cfg.Options.Debug {
639		httpClient := log.NewHTTPClient()
640		opts = append(opts, vercel.WithHTTPClient(httpClient))
641	}
642	if len(headers) > 0 {
643		opts = append(opts, vercel.WithHeaders(headers))
644	}
645	return vercel.New(opts...)
646}
647
648func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
649	opts := []openaicompat.Option{
650		openaicompat.WithBaseURL(baseURL),
651		openaicompat.WithAPIKey(apiKey),
652	}
653
654	// Set HTTP client based on provider and debug mode.
655	var httpClient *http.Client
656	if providerID == string(catwalk.InferenceProviderCopilot) {
657		opts = append(opts, openaicompat.WithUseResponsesAPI())
658		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
659	} else if c.cfg.Options.Debug {
660		httpClient = log.NewHTTPClient()
661	}
662	if httpClient != nil {
663		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
664	}
665
666	if len(headers) > 0 {
667		opts = append(opts, openaicompat.WithHeaders(headers))
668	}
669
670	for extraKey, extraValue := range extraBody {
671		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
672	}
673
674	return openaicompat.New(opts...)
675}
676
677func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
678	opts := []azure.Option{
679		azure.WithBaseURL(baseURL),
680		azure.WithAPIKey(apiKey),
681		azure.WithUseResponsesAPI(),
682	}
683	if c.cfg.Options.Debug {
684		httpClient := log.NewHTTPClient()
685		opts = append(opts, azure.WithHTTPClient(httpClient))
686	}
687	if options == nil {
688		options = make(map[string]string)
689	}
690	if apiVersion, ok := options["apiVersion"]; ok {
691		opts = append(opts, azure.WithAPIVersion(apiVersion))
692	}
693	if len(headers) > 0 {
694		opts = append(opts, azure.WithHeaders(headers))
695	}
696
697	return azure.New(opts...)
698}
699
700func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
701	var opts []bedrock.Option
702	if c.cfg.Options.Debug {
703		httpClient := log.NewHTTPClient()
704		opts = append(opts, bedrock.WithHTTPClient(httpClient))
705	}
706	if len(headers) > 0 {
707		opts = append(opts, bedrock.WithHeaders(headers))
708	}
709	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
710	if bearerToken != "" {
711		opts = append(opts, bedrock.WithAPIKey(bearerToken))
712	}
713	return bedrock.New(opts...)
714}
715
716func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
717	opts := []google.Option{
718		google.WithBaseURL(baseURL),
719		google.WithGeminiAPIKey(apiKey),
720	}
721	if c.cfg.Options.Debug {
722		httpClient := log.NewHTTPClient()
723		opts = append(opts, google.WithHTTPClient(httpClient))
724	}
725	if len(headers) > 0 {
726		opts = append(opts, google.WithHeaders(headers))
727	}
728	return google.New(opts...)
729}
730
731func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
732	opts := []google.Option{}
733	if c.cfg.Options.Debug {
734		httpClient := log.NewHTTPClient()
735		opts = append(opts, google.WithHTTPClient(httpClient))
736	}
737	if len(headers) > 0 {
738		opts = append(opts, google.WithHeaders(headers))
739	}
740
741	project := options["project"]
742	location := options["location"]
743
744	opts = append(opts, google.WithVertex(project, location))
745
746	return google.New(opts...)
747}
748
749func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
750	opts := []hyper.Option{
751		hyper.WithBaseURL(baseURL),
752		hyper.WithAPIKey(apiKey),
753	}
754	if c.cfg.Options.Debug {
755		httpClient := log.NewHTTPClient()
756		opts = append(opts, hyper.WithHTTPClient(httpClient))
757	}
758	return hyper.New(opts...)
759}
760
761func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
762	if model.Think {
763		return true
764	}
765
766	if model.ProviderOptions == nil {
767		return false
768	}
769
770	opts, err := anthropic.ParseOptions(model.ProviderOptions)
771	if err != nil {
772		return false
773	}
774	if opts.Thinking != nil {
775		return true
776	}
777	return false
778}
779
780func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
781	headers := maps.Clone(providerCfg.ExtraHeaders)
782	if headers == nil {
783		headers = make(map[string]string)
784	}
785
786	// handle special headers for anthropic
787	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
788		if v, ok := headers["anthropic-beta"]; ok {
789			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
790		} else {
791			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
792		}
793	}
794
795	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
796	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
797
798	switch providerCfg.Type {
799	case openai.Name:
800		return c.buildOpenaiProvider(baseURL, apiKey, headers)
801	case anthropic.Name:
802		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
803	case openrouter.Name:
804		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
805	case vercel.Name:
806		return c.buildVercelProvider(baseURL, apiKey, headers)
807	case azure.Name:
808		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
809	case bedrock.Name:
810		return c.buildBedrockProvider(headers)
811	case google.Name:
812		return c.buildGoogleProvider(baseURL, apiKey, headers)
813	case "google-vertex":
814		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
815	case openaicompat.Name:
816		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
817			if providerCfg.ExtraBody == nil {
818				providerCfg.ExtraBody = map[string]any{}
819			}
820			providerCfg.ExtraBody["tool_stream"] = true
821		}
822		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
823	case hyper.Name:
824		return c.buildHyperProvider(baseURL, apiKey)
825	default:
826		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
827	}
828}
829
830func isExactoSupported(modelID string) bool {
831	supportedModels := []string{
832		"moonshotai/kimi-k2-0905",
833		"deepseek/deepseek-v3.1-terminus",
834		"z-ai/glm-4.6",
835		"openai/gpt-oss-120b",
836		"qwen/qwen3-coder",
837	}
838	return slices.Contains(supportedModels, modelID)
839}
840
841func (c *coordinator) Cancel(sessionID string) {
842	c.currentAgent.Cancel(sessionID)
843}
844
845func (c *coordinator) CancelAll() {
846	c.currentAgent.CancelAll()
847}
848
849func (c *coordinator) ClearQueue(sessionID string) {
850	c.currentAgent.ClearQueue(sessionID)
851}
852
853func (c *coordinator) IsBusy() bool {
854	return c.currentAgent.IsBusy()
855}
856
857func (c *coordinator) IsSessionBusy(sessionID string) bool {
858	return c.currentAgent.IsSessionBusy(sessionID)
859}
860
861func (c *coordinator) Model() Model {
862	return c.currentAgent.Model()
863}
864
865func (c *coordinator) UpdateModels(ctx context.Context) error {
866	// build the models again so we make sure we get the latest config
867	large, small, err := c.buildAgentModels(ctx, false)
868	if err != nil {
869		return err
870	}
871	c.currentAgent.SetModels(large, small)
872
873	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
874	if !ok {
875		return errors.New("coder agent not configured")
876	}
877
878	tools, err := c.buildTools(ctx, agentCfg)
879	if err != nil {
880		return err
881	}
882	c.currentAgent.SetTools(tools)
883	return nil
884}
885
886func (c *coordinator) QueuedPrompts(sessionID string) int {
887	return c.currentAgent.QueuedPrompts(sessionID)
888}
889
890func (c *coordinator) QueuedPromptsList(sessionID string) []string {
891	return c.currentAgent.QueuedPromptsList(sessionID)
892}
893
894func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
895	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
896	if !ok {
897		return errors.New("model provider not configured")
898	}
899	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
900}
901
902func (c *coordinator) isUnauthorized(err error) bool {
903	var providerErr *fantasy.ProviderError
904	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
905}
906
907func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
908	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
909		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
910		return err
911	}
912	if err := c.UpdateModels(ctx); err != nil {
913		return err
914	}
915	return nil
916}
917
918func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
919	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
920	if err != nil {
921		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
922		return err
923	}
924
925	providerCfg.APIKey = newAPIKey
926	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
927
928	if err := c.UpdateModels(ctx); err != nil {
929		return err
930	}
931	return nil
932}