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