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