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	RefreshTools(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	lspManager  *lsp.Manager
 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	lspManager *lsp.Manager,
 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		lspManager:  lspManager,
 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.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
427		tools.NewMultiEditTool(c.lspManager, 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(), c.cfg.Tools.Grep),
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.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
435		tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
436	)
437
438	// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
439	if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
440		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
441	}
442
443	if len(c.cfg.MCP) > 0 {
444		allTools = append(
445			allTools,
446			tools.NewListMCPResourcesTool(c.cfg, c.permissions),
447			tools.NewReadMCPResourceTool(c.cfg, c.permissions),
448		)
449	}
450
451	var filteredTools []fantasy.AgentTool
452	for _, tool := range allTools {
453		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
454			filteredTools = append(filteredTools, tool)
455		}
456	}
457
458	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
459		if agent.AllowedMCP == nil {
460			// No MCP restrictions
461			filteredTools = append(filteredTools, tool)
462			continue
463		}
464		if len(agent.AllowedMCP) == 0 {
465			// No MCPs allowed
466			slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
467			break
468		}
469
470		for mcp, tools := range agent.AllowedMCP {
471			if mcp != tool.MCP() {
472				continue
473			}
474			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
475				filteredTools = append(filteredTools, tool)
476			}
477		}
478		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
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("large model provider not configured")
510	}
511
512	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg, 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) (fantasy.Provider, error) {
571	var opts []anthropic.Option
572
573	if 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	} else if apiKey != "" {
578		// X-Api-Key header
579		opts = append(opts, anthropic.WithAPIKey(apiKey))
580	}
581
582	if len(headers) > 0 {
583		opts = append(opts, anthropic.WithHeaders(headers))
584	}
585
586	if baseURL != "" {
587		opts = append(opts, anthropic.WithBaseURL(baseURL))
588	}
589
590	if c.cfg.Options.Debug {
591		httpClient := log.NewHTTPClient()
592		opts = append(opts, anthropic.WithHTTPClient(httpClient))
593	}
594	return anthropic.New(opts...)
595}
596
597func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
598	opts := []openai.Option{
599		openai.WithAPIKey(apiKey),
600		openai.WithUseResponsesAPI(),
601	}
602	if c.cfg.Options.Debug {
603		httpClient := log.NewHTTPClient()
604		opts = append(opts, openai.WithHTTPClient(httpClient))
605	}
606	if len(headers) > 0 {
607		opts = append(opts, openai.WithHeaders(headers))
608	}
609	if baseURL != "" {
610		opts = append(opts, openai.WithBaseURL(baseURL))
611	}
612	return openai.New(opts...)
613}
614
615func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
616	opts := []openrouter.Option{
617		openrouter.WithAPIKey(apiKey),
618	}
619	if c.cfg.Options.Debug {
620		httpClient := log.NewHTTPClient()
621		opts = append(opts, openrouter.WithHTTPClient(httpClient))
622	}
623	if len(headers) > 0 {
624		opts = append(opts, openrouter.WithHeaders(headers))
625	}
626	return openrouter.New(opts...)
627}
628
629func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
630	opts := []vercel.Option{
631		vercel.WithAPIKey(apiKey),
632	}
633	if c.cfg.Options.Debug {
634		httpClient := log.NewHTTPClient()
635		opts = append(opts, vercel.WithHTTPClient(httpClient))
636	}
637	if len(headers) > 0 {
638		opts = append(opts, vercel.WithHeaders(headers))
639	}
640	return vercel.New(opts...)
641}
642
643func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
644	opts := []openaicompat.Option{
645		openaicompat.WithBaseURL(baseURL),
646		openaicompat.WithAPIKey(apiKey),
647	}
648
649	// Set HTTP client based on provider and debug mode.
650	var httpClient *http.Client
651	if providerID == string(catwalk.InferenceProviderCopilot) {
652		opts = append(opts, openaicompat.WithUseResponsesAPI())
653		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
654	} else if c.cfg.Options.Debug {
655		httpClient = log.NewHTTPClient()
656	}
657	if httpClient != nil {
658		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
659	}
660
661	if len(headers) > 0 {
662		opts = append(opts, openaicompat.WithHeaders(headers))
663	}
664
665	for extraKey, extraValue := range extraBody {
666		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
667	}
668
669	return openaicompat.New(opts...)
670}
671
672func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
673	opts := []azure.Option{
674		azure.WithBaseURL(baseURL),
675		azure.WithAPIKey(apiKey),
676		azure.WithUseResponsesAPI(),
677	}
678	if c.cfg.Options.Debug {
679		httpClient := log.NewHTTPClient()
680		opts = append(opts, azure.WithHTTPClient(httpClient))
681	}
682	if options == nil {
683		options = make(map[string]string)
684	}
685	if apiVersion, ok := options["apiVersion"]; ok {
686		opts = append(opts, azure.WithAPIVersion(apiVersion))
687	}
688	if len(headers) > 0 {
689		opts = append(opts, azure.WithHeaders(headers))
690	}
691
692	return azure.New(opts...)
693}
694
695func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
696	var opts []bedrock.Option
697	if c.cfg.Options.Debug {
698		httpClient := log.NewHTTPClient()
699		opts = append(opts, bedrock.WithHTTPClient(httpClient))
700	}
701	if len(headers) > 0 {
702		opts = append(opts, bedrock.WithHeaders(headers))
703	}
704	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
705	if bearerToken != "" {
706		opts = append(opts, bedrock.WithAPIKey(bearerToken))
707	}
708	return bedrock.New(opts...)
709}
710
711func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
712	opts := []google.Option{
713		google.WithBaseURL(baseURL),
714		google.WithGeminiAPIKey(apiKey),
715	}
716	if c.cfg.Options.Debug {
717		httpClient := log.NewHTTPClient()
718		opts = append(opts, google.WithHTTPClient(httpClient))
719	}
720	if len(headers) > 0 {
721		opts = append(opts, google.WithHeaders(headers))
722	}
723	return google.New(opts...)
724}
725
726func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
727	opts := []google.Option{}
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
736	project := options["project"]
737	location := options["location"]
738
739	opts = append(opts, google.WithVertex(project, location))
740
741	return google.New(opts...)
742}
743
744func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
745	opts := []hyper.Option{
746		hyper.WithBaseURL(baseURL),
747		hyper.WithAPIKey(apiKey),
748	}
749	if c.cfg.Options.Debug {
750		httpClient := log.NewHTTPClient()
751		opts = append(opts, hyper.WithHTTPClient(httpClient))
752	}
753	return hyper.New(opts...)
754}
755
756func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
757	if model.Think {
758		return true
759	}
760
761	if model.ProviderOptions == nil {
762		return false
763	}
764
765	opts, err := anthropic.ParseOptions(model.ProviderOptions)
766	if err != nil {
767		return false
768	}
769	if opts.Thinking != nil {
770		return true
771	}
772	return false
773}
774
775func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
776	headers := maps.Clone(providerCfg.ExtraHeaders)
777	if headers == nil {
778		headers = make(map[string]string)
779	}
780
781	// handle special headers for anthropic
782	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
783		if v, ok := headers["anthropic-beta"]; ok {
784			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
785		} else {
786			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
787		}
788	}
789
790	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
791	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
792
793	switch providerCfg.Type {
794	case openai.Name:
795		return c.buildOpenaiProvider(baseURL, apiKey, headers)
796	case anthropic.Name:
797		return c.buildAnthropicProvider(baseURL, apiKey, headers)
798	case openrouter.Name:
799		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
800	case vercel.Name:
801		return c.buildVercelProvider(baseURL, apiKey, headers)
802	case azure.Name:
803		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
804	case bedrock.Name:
805		return c.buildBedrockProvider(headers)
806	case google.Name:
807		return c.buildGoogleProvider(baseURL, apiKey, headers)
808	case "google-vertex":
809		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
810	case openaicompat.Name:
811		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
812			if providerCfg.ExtraBody == nil {
813				providerCfg.ExtraBody = map[string]any{}
814			}
815			providerCfg.ExtraBody["tool_stream"] = true
816		}
817		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
818	case hyper.Name:
819		return c.buildHyperProvider(baseURL, apiKey)
820	default:
821		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
822	}
823}
824
825func isExactoSupported(modelID string) bool {
826	supportedModels := []string{
827		"moonshotai/kimi-k2-0905",
828		"deepseek/deepseek-v3.1-terminus",
829		"z-ai/glm-4.6",
830		"openai/gpt-oss-120b",
831		"qwen/qwen3-coder",
832	}
833	return slices.Contains(supportedModels, modelID)
834}
835
836func (c *coordinator) Cancel(sessionID string) {
837	c.currentAgent.Cancel(sessionID)
838}
839
840func (c *coordinator) CancelAll() {
841	c.currentAgent.CancelAll()
842}
843
844func (c *coordinator) ClearQueue(sessionID string) {
845	c.currentAgent.ClearQueue(sessionID)
846}
847
848func (c *coordinator) IsBusy() bool {
849	return c.currentAgent.IsBusy()
850}
851
852func (c *coordinator) IsSessionBusy(sessionID string) bool {
853	return c.currentAgent.IsSessionBusy(sessionID)
854}
855
856func (c *coordinator) Model() Model {
857	return c.currentAgent.Model()
858}
859
860func (c *coordinator) UpdateModels(ctx context.Context) error {
861	// build the models again so we make sure we get the latest config
862	large, small, err := c.buildAgentModels(ctx, false)
863	if err != nil {
864		return err
865	}
866	c.currentAgent.SetModels(large, small)
867
868	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
869	if !ok {
870		return errors.New("coder agent not configured")
871	}
872
873	tools, err := c.buildTools(ctx, agentCfg)
874	if err != nil {
875		return err
876	}
877	c.currentAgent.SetTools(tools)
878	return nil
879}
880
881func (c *coordinator) RefreshTools(ctx context.Context) error {
882	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
883	if !ok {
884		return errors.New("coder agent not configured")
885	}
886
887	tools, err := c.buildTools(ctx, agentCfg)
888	if err != nil {
889		return err
890	}
891	c.currentAgent.SetTools(tools)
892	slog.Debug("refreshed agent tools", "count", len(tools))
893	return nil
894}
895
896func (c *coordinator) QueuedPrompts(sessionID string) int {
897	return c.currentAgent.QueuedPrompts(sessionID)
898}
899
900func (c *coordinator) QueuedPromptsList(sessionID string) []string {
901	return c.currentAgent.QueuedPromptsList(sessionID)
902}
903
904func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
905	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
906	if !ok {
907		return errors.New("model provider not configured")
908	}
909	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
910}
911
912func (c *coordinator) isUnauthorized(err error) bool {
913	var providerErr *fantasy.ProviderError
914	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
915}
916
917func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
918	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
919		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
920		return err
921	}
922	if err := c.UpdateModels(ctx); err != nil {
923		return err
924	}
925	return nil
926}
927
928func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
929	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
930	if err != nil {
931		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
932		return err
933	}
934
935	providerCfg.APIKey = newAPIKey
936	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
937
938	if err := c.UpdateModels(ctx); err != nil {
939		return err
940	}
941	return nil
942}