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	RecoverSession(ctx context.Context, sessionID string) error
 60	Model() Model
 61	UpdateModels(ctx context.Context) error
 62}
 63
 64type coordinator struct {
 65	cfg         *config.Config
 66	sessions    session.Service
 67	messages    message.Service
 68	permissions permission.Service
 69	history     history.Service
 70	filetracker filetracker.Service
 71	lspClients  *csync.Map[string, *lsp.Client]
 72
 73	currentAgent SessionAgent
 74	agents       map[string]SessionAgent
 75
 76	readyWg errgroup.Group
 77}
 78
 79func NewCoordinator(
 80	ctx context.Context,
 81	cfg *config.Config,
 82	sessions session.Service,
 83	messages message.Service,
 84	permissions permission.Service,
 85	history history.Service,
 86	filetracker filetracker.Service,
 87	lspClients *csync.Map[string, *lsp.Client],
 88) (Coordinator, error) {
 89	c := &coordinator{
 90		cfg:         cfg,
 91		sessions:    sessions,
 92		messages:    messages,
 93		permissions: permissions,
 94		history:     history,
 95		filetracker: filetracker,
 96		lspClients:  lspClients,
 97		agents:      make(map[string]SessionAgent),
 98	}
 99
100	agentCfg, ok := cfg.Agents[config.AgentCoder]
101	if !ok {
102		return nil, errors.New("coder agent not configured")
103	}
104
105	// TODO: make this dynamic when we support multiple agents
106	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
107	if err != nil {
108		return nil, err
109	}
110
111	agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
112	if err != nil {
113		return nil, err
114	}
115	c.currentAgent = agent
116	c.agents[config.AgentCoder] = agent
117	return c, nil
118}
119
120// Run implements Coordinator.
121func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
122	if err := c.readyWg.Wait(); err != nil {
123		return nil, err
124	}
125
126	// refresh models before each run
127	if err := c.UpdateModels(ctx); err != nil {
128		return nil, fmt.Errorf("failed to update models: %w", err)
129	}
130
131	model := c.currentAgent.Model()
132	maxTokens := model.CatwalkCfg.DefaultMaxTokens
133	if model.ModelCfg.MaxTokens != 0 {
134		maxTokens = model.ModelCfg.MaxTokens
135	}
136
137	if !model.CatwalkCfg.SupportsImages && attachments != nil {
138		// filter out image attachments
139		filteredAttachments := make([]message.Attachment, 0, len(attachments))
140		for _, att := range attachments {
141			if att.IsText() {
142				filteredAttachments = append(filteredAttachments, att)
143			}
144		}
145		attachments = filteredAttachments
146	}
147
148	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
149	if !ok {
150		return nil, errors.New("model provider not configured")
151	}
152
153	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
154
155	if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
156		slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
157		if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
158			return nil, err
159		}
160	}
161
162	run := func() (*fantasy.AgentResult, error) {
163		return c.currentAgent.Run(ctx, SessionAgentCall{
164			SessionID:        sessionID,
165			Prompt:           prompt,
166			Attachments:      attachments,
167			MaxOutputTokens:  maxTokens,
168			ProviderOptions:  mergedOptions,
169			Temperature:      temp,
170			TopP:             topP,
171			TopK:             topK,
172			FrequencyPenalty: freqPenalty,
173			PresencePenalty:  presPenalty,
174		})
175	}
176	result, originalErr := run()
177
178	if c.isUnauthorized(originalErr) {
179		switch {
180		case providerCfg.OAuthToken != nil:
181			slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
182			if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
183				return nil, originalErr
184			}
185			slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
186			return run()
187		case strings.Contains(providerCfg.APIKeyTemplate, "$"):
188			slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
189			if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
190				return nil, originalErr
191			}
192			slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
193			return run()
194		}
195	}
196
197	return result, originalErr
198}
199
200func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
201	options := fantasy.ProviderOptions{}
202
203	cfgOpts := []byte("{}")
204	providerCfgOpts := []byte("{}")
205	catwalkOpts := []byte("{}")
206
207	if model.ModelCfg.ProviderOptions != nil {
208		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
209		if err == nil {
210			cfgOpts = data
211		}
212	}
213
214	if providerCfg.ProviderOptions != nil {
215		data, err := json.Marshal(providerCfg.ProviderOptions)
216		if err == nil {
217			providerCfgOpts = data
218		}
219	}
220
221	if model.CatwalkCfg.Options.ProviderOptions != nil {
222		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
223		if err == nil {
224			catwalkOpts = data
225		}
226	}
227
228	readers := []io.Reader{
229		bytes.NewReader(catwalkOpts),
230		bytes.NewReader(providerCfgOpts),
231		bytes.NewReader(cfgOpts),
232	}
233
234	got, err := jsons.Merge(readers)
235	if err != nil {
236		slog.Error("Could not merge call config", "err", err)
237		return options
238	}
239
240	mergedOptions := make(map[string]any)
241
242	err = json.Unmarshal([]byte(got), &mergedOptions)
243	if err != nil {
244		slog.Error("Could not create config for call", "err", err)
245		return options
246	}
247
248	providerType := providerCfg.Type
249	if providerType == "hyper" {
250		if strings.Contains(model.CatwalkCfg.ID, "claude") {
251			providerType = anthropic.Name
252		} else if strings.Contains(model.CatwalkCfg.ID, "gpt") {
253			providerType = openai.Name
254		} else if strings.Contains(model.CatwalkCfg.ID, "gemini") {
255			providerType = google.Name
256		} else {
257			providerType = openaicompat.Name
258		}
259	}
260
261	switch providerType {
262	case openai.Name, azure.Name:
263		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
264		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
265			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
266		}
267		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
268			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
269				mergedOptions["reasoning_summary"] = "auto"
270				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
271			}
272			parsed, err := openai.ParseResponsesOptions(mergedOptions)
273			if err == nil {
274				options[openai.Name] = parsed
275			}
276		} else {
277			parsed, err := openai.ParseOptions(mergedOptions)
278			if err == nil {
279				options[openai.Name] = parsed
280			}
281		}
282	case anthropic.Name:
283		_, hasThink := mergedOptions["thinking"]
284		if !hasThink && model.ModelCfg.Think {
285			mergedOptions["thinking"] = map[string]any{
286				// TODO: kujtim see if we need to make this dynamic
287				"budget_tokens": 2000,
288			}
289		}
290		parsed, err := anthropic.ParseOptions(mergedOptions)
291		if err == nil {
292			options[anthropic.Name] = parsed
293		}
294
295	case openrouter.Name:
296		_, hasReasoning := mergedOptions["reasoning"]
297		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
298			mergedOptions["reasoning"] = map[string]any{
299				"enabled": true,
300				"effort":  model.ModelCfg.ReasoningEffort,
301			}
302		}
303		parsed, err := openrouter.ParseOptions(mergedOptions)
304		if err == nil {
305			options[openrouter.Name] = parsed
306		}
307	case vercel.Name:
308		_, hasReasoning := mergedOptions["reasoning"]
309		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
310			mergedOptions["reasoning"] = map[string]any{
311				"enabled": true,
312				"effort":  model.ModelCfg.ReasoningEffort,
313			}
314		}
315		parsed, err := vercel.ParseOptions(mergedOptions)
316		if err == nil {
317			options[vercel.Name] = parsed
318		}
319	case google.Name:
320		_, hasReasoning := mergedOptions["thinking_config"]
321		if !hasReasoning {
322			mergedOptions["thinking_config"] = map[string]any{
323				"thinking_budget":  2000,
324				"include_thoughts": true,
325			}
326		}
327		parsed, err := google.ParseOptions(mergedOptions)
328		if err == nil {
329			options[google.Name] = parsed
330		}
331	case openaicompat.Name:
332		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
333		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
334			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
335		}
336		parsed, err := openaicompat.ParseOptions(mergedOptions)
337		if err == nil {
338			options[openaicompat.Name] = parsed
339		}
340	}
341
342	return options
343}
344
345func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
346	modelOptions := getProviderOptions(model, cfg)
347	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
348	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
349	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
350	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
351	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
352	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
353}
354
355func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
356	large, small, err := c.buildAgentModels(ctx, isSubAgent)
357	if err != nil {
358		return nil, err
359	}
360
361	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
362	result := NewSessionAgent(SessionAgentOptions{
363		large,
364		small,
365		largeProviderCfg.SystemPromptPrefix,
366		"",
367		isSubAgent,
368		c.cfg.Options.DisableAutoSummarize,
369		c.permissions.SkipRequests(),
370		c.sessions,
371		c.messages,
372		nil,
373	})
374
375	c.readyWg.Go(func() error {
376		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
377		if err != nil {
378			return err
379		}
380		result.SetSystemPrompt(systemPrompt)
381		return nil
382	})
383
384	c.readyWg.Go(func() error {
385		tools, err := c.buildTools(ctx, agent)
386		if err != nil {
387			return err
388		}
389		result.SetTools(tools)
390		return nil
391	})
392
393	return result, nil
394}
395
396func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
397	var allTools []fantasy.AgentTool
398	if slices.Contains(agent.AllowedTools, AgentToolName) {
399		agentTool, err := c.agentTool(ctx)
400		if err != nil {
401			return nil, err
402		}
403		allTools = append(allTools, agentTool)
404	}
405
406	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
407		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
408		if err != nil {
409			return nil, err
410		}
411		allTools = append(allTools, agenticFetchTool)
412	}
413
414	// Get the model name for the agent
415	modelName := ""
416	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
417		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
418			modelName = model.Name
419		}
420	}
421
422	allTools = append(allTools,
423		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
424		tools.NewJobOutputTool(),
425		tools.NewJobKillTool(),
426		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
427		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
428		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
429		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
430		tools.NewGlobTool(c.cfg.WorkingDir()),
431		tools.NewGrepTool(c.cfg.WorkingDir()),
432		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
433		tools.NewSourcegraphTool(nil),
434		tools.NewTodosTool(c.sessions),
435		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
436		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
437	)
438
439	if c.lspClients.Len() > 0 {
440		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
441	}
442
443	var filteredTools []fantasy.AgentTool
444	for _, tool := range allTools {
445		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
446			filteredTools = append(filteredTools, tool)
447		}
448	}
449
450	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
451		if agent.AllowedMCP == nil {
452			// No MCP restrictions
453			filteredTools = append(filteredTools, tool)
454			continue
455		}
456		if len(agent.AllowedMCP) == 0 {
457			// No MCPs allowed
458			slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
459			break
460		}
461
462		for mcp, tools := range agent.AllowedMCP {
463			if mcp != tool.MCP() {
464				continue
465			}
466			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
467				filteredTools = append(filteredTools, tool)
468			}
469		}
470		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
471	}
472	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
473		return strings.Compare(a.Info().Name, b.Info().Name)
474	})
475	return filteredTools, nil
476}
477
478// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
479func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
480	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
481	if !ok {
482		return Model{}, Model{}, errors.New("large model not selected")
483	}
484	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
485	if !ok {
486		return Model{}, Model{}, errors.New("small model not selected")
487	}
488
489	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
490	if !ok {
491		return Model{}, Model{}, errors.New("large model provider not configured")
492	}
493
494	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
495	if err != nil {
496		return Model{}, Model{}, err
497	}
498
499	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
500	if !ok {
501		return Model{}, Model{}, errors.New("large model provider not configured")
502	}
503
504	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg, true)
505	if err != nil {
506		return Model{}, Model{}, err
507	}
508
509	var largeCatwalkModel *catwalk.Model
510	var smallCatwalkModel *catwalk.Model
511
512	for _, m := range largeProviderCfg.Models {
513		if m.ID == largeModelCfg.Model {
514			largeCatwalkModel = &m
515		}
516	}
517	for _, m := range smallProviderCfg.Models {
518		if m.ID == smallModelCfg.Model {
519			smallCatwalkModel = &m
520		}
521	}
522
523	if largeCatwalkModel == nil {
524		return Model{}, Model{}, errors.New("large model not found in provider config")
525	}
526
527	if smallCatwalkModel == nil {
528		return Model{}, Model{}, errors.New("small model not found in provider config")
529	}
530
531	largeModelID := largeModelCfg.Model
532	smallModelID := smallModelCfg.Model
533
534	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
535		largeModelID += ":exacto"
536	}
537
538	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
539		smallModelID += ":exacto"
540	}
541
542	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
543	if err != nil {
544		return Model{}, Model{}, err
545	}
546	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
547	if err != nil {
548		return Model{}, Model{}, err
549	}
550
551	return Model{
552			Model:      largeModel,
553			CatwalkCfg: *largeCatwalkModel,
554			ModelCfg:   largeModelCfg,
555		}, Model{
556			Model:      smallModel,
557			CatwalkCfg: *smallCatwalkModel,
558			ModelCfg:   smallModelCfg,
559		}, nil
560}
561
562func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
563	var opts []anthropic.Option
564
565	if strings.HasPrefix(apiKey, "Bearer ") {
566		// NOTE: Prevent the SDK from picking up the API key from env.
567		os.Setenv("ANTHROPIC_API_KEY", "")
568		headers["Authorization"] = apiKey
569	} else if apiKey != "" {
570		// X-Api-Key header
571		opts = append(opts, anthropic.WithAPIKey(apiKey))
572	}
573
574	if len(headers) > 0 {
575		opts = append(opts, anthropic.WithHeaders(headers))
576	}
577
578	if baseURL != "" {
579		opts = append(opts, anthropic.WithBaseURL(baseURL))
580	}
581
582	if c.cfg.Options.Debug {
583		httpClient := log.NewHTTPClient()
584		opts = append(opts, anthropic.WithHTTPClient(httpClient))
585	}
586	return anthropic.New(opts...)
587}
588
589func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
590	opts := []openai.Option{
591		openai.WithAPIKey(apiKey),
592		openai.WithUseResponsesAPI(),
593	}
594	if c.cfg.Options.Debug {
595		httpClient := log.NewHTTPClient()
596		opts = append(opts, openai.WithHTTPClient(httpClient))
597	}
598	if len(headers) > 0 {
599		opts = append(opts, openai.WithHeaders(headers))
600	}
601	if baseURL != "" {
602		opts = append(opts, openai.WithBaseURL(baseURL))
603	}
604	return openai.New(opts...)
605}
606
607func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
608	opts := []openrouter.Option{
609		openrouter.WithAPIKey(apiKey),
610	}
611	if c.cfg.Options.Debug {
612		httpClient := log.NewHTTPClient()
613		opts = append(opts, openrouter.WithHTTPClient(httpClient))
614	}
615	if len(headers) > 0 {
616		opts = append(opts, openrouter.WithHeaders(headers))
617	}
618	return openrouter.New(opts...)
619}
620
621func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
622	opts := []vercel.Option{
623		vercel.WithAPIKey(apiKey),
624	}
625	if c.cfg.Options.Debug {
626		httpClient := log.NewHTTPClient()
627		opts = append(opts, vercel.WithHTTPClient(httpClient))
628	}
629	if len(headers) > 0 {
630		opts = append(opts, vercel.WithHeaders(headers))
631	}
632	return vercel.New(opts...)
633}
634
635func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
636	opts := []openaicompat.Option{
637		openaicompat.WithBaseURL(baseURL),
638		openaicompat.WithAPIKey(apiKey),
639	}
640
641	// Set HTTP client based on provider and debug mode.
642	var httpClient *http.Client
643	if providerID == string(catwalk.InferenceProviderCopilot) {
644		opts = append(opts, openaicompat.WithUseResponsesAPI())
645		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
646	} else if c.cfg.Options.Debug {
647		httpClient = log.NewHTTPClient()
648	}
649	if httpClient != nil {
650		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
651	}
652
653	if len(headers) > 0 {
654		opts = append(opts, openaicompat.WithHeaders(headers))
655	}
656
657	for extraKey, extraValue := range extraBody {
658		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
659	}
660
661	return openaicompat.New(opts...)
662}
663
664func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
665	opts := []azure.Option{
666		azure.WithBaseURL(baseURL),
667		azure.WithAPIKey(apiKey),
668		azure.WithUseResponsesAPI(),
669	}
670	if c.cfg.Options.Debug {
671		httpClient := log.NewHTTPClient()
672		opts = append(opts, azure.WithHTTPClient(httpClient))
673	}
674	if options == nil {
675		options = make(map[string]string)
676	}
677	if apiVersion, ok := options["apiVersion"]; ok {
678		opts = append(opts, azure.WithAPIVersion(apiVersion))
679	}
680	if len(headers) > 0 {
681		opts = append(opts, azure.WithHeaders(headers))
682	}
683
684	return azure.New(opts...)
685}
686
687func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
688	var opts []bedrock.Option
689	if c.cfg.Options.Debug {
690		httpClient := log.NewHTTPClient()
691		opts = append(opts, bedrock.WithHTTPClient(httpClient))
692	}
693	if len(headers) > 0 {
694		opts = append(opts, bedrock.WithHeaders(headers))
695	}
696	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
697	if bearerToken != "" {
698		opts = append(opts, bedrock.WithAPIKey(bearerToken))
699	}
700	return bedrock.New(opts...)
701}
702
703func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
704	opts := []google.Option{
705		google.WithBaseURL(baseURL),
706		google.WithGeminiAPIKey(apiKey),
707	}
708	if c.cfg.Options.Debug {
709		httpClient := log.NewHTTPClient()
710		opts = append(opts, google.WithHTTPClient(httpClient))
711	}
712	if len(headers) > 0 {
713		opts = append(opts, google.WithHeaders(headers))
714	}
715	return google.New(opts...)
716}
717
718func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
719	opts := []google.Option{}
720	if c.cfg.Options.Debug {
721		httpClient := log.NewHTTPClient()
722		opts = append(opts, google.WithHTTPClient(httpClient))
723	}
724	if len(headers) > 0 {
725		opts = append(opts, google.WithHeaders(headers))
726	}
727
728	project := options["project"]
729	location := options["location"]
730
731	opts = append(opts, google.WithVertex(project, location))
732
733	return google.New(opts...)
734}
735
736func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
737	opts := []hyper.Option{
738		hyper.WithBaseURL(baseURL),
739		hyper.WithAPIKey(apiKey),
740	}
741	if c.cfg.Options.Debug {
742		httpClient := log.NewHTTPClient()
743		opts = append(opts, hyper.WithHTTPClient(httpClient))
744	}
745	return hyper.New(opts...)
746}
747
748func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
749	if model.Think {
750		return true
751	}
752
753	if model.ProviderOptions == nil {
754		return false
755	}
756
757	opts, err := anthropic.ParseOptions(model.ProviderOptions)
758	if err != nil {
759		return false
760	}
761	if opts.Thinking != nil {
762		return true
763	}
764	return false
765}
766
767func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
768	headers := maps.Clone(providerCfg.ExtraHeaders)
769	if headers == nil {
770		headers = make(map[string]string)
771	}
772
773	// handle special headers for anthropic
774	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
775		if v, ok := headers["anthropic-beta"]; ok {
776			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
777		} else {
778			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
779		}
780	}
781
782	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
783	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
784
785	switch providerCfg.Type {
786	case openai.Name:
787		return c.buildOpenaiProvider(baseURL, apiKey, headers)
788	case anthropic.Name:
789		return c.buildAnthropicProvider(baseURL, apiKey, headers)
790	case openrouter.Name:
791		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
792	case vercel.Name:
793		return c.buildVercelProvider(baseURL, apiKey, headers)
794	case azure.Name:
795		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
796	case bedrock.Name:
797		return c.buildBedrockProvider(headers)
798	case google.Name:
799		return c.buildGoogleProvider(baseURL, apiKey, headers)
800	case "google-vertex":
801		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
802	case openaicompat.Name:
803		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
804			if providerCfg.ExtraBody == nil {
805				providerCfg.ExtraBody = map[string]any{}
806			}
807			providerCfg.ExtraBody["tool_stream"] = true
808		}
809		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
810	case hyper.Name:
811		return c.buildHyperProvider(baseURL, apiKey)
812	default:
813		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
814	}
815}
816
817func isExactoSupported(modelID string) bool {
818	supportedModels := []string{
819		"moonshotai/kimi-k2-0905",
820		"deepseek/deepseek-v3.1-terminus",
821		"z-ai/glm-4.6",
822		"openai/gpt-oss-120b",
823		"qwen/qwen3-coder",
824	}
825	return slices.Contains(supportedModels, modelID)
826}
827
828func (c *coordinator) Cancel(sessionID string) {
829	c.currentAgent.Cancel(sessionID)
830}
831
832func (c *coordinator) CancelAll() {
833	c.currentAgent.CancelAll()
834}
835
836func (c *coordinator) ClearQueue(sessionID string) {
837	c.currentAgent.ClearQueue(sessionID)
838}
839
840func (c *coordinator) IsBusy() bool {
841	return c.currentAgent.IsBusy()
842}
843
844func (c *coordinator) IsSessionBusy(sessionID string) bool {
845	return c.currentAgent.IsSessionBusy(sessionID)
846}
847
848func (c *coordinator) Model() Model {
849	return c.currentAgent.Model()
850}
851
852func (c *coordinator) UpdateModels(ctx context.Context) error {
853	// build the models again so we make sure we get the latest config
854	large, small, err := c.buildAgentModels(ctx, false)
855	if err != nil {
856		return err
857	}
858	c.currentAgent.SetModels(large, small)
859
860	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
861	if !ok {
862		return errors.New("coder agent not configured")
863	}
864
865	tools, err := c.buildTools(ctx, agentCfg)
866	if err != nil {
867		return err
868	}
869	c.currentAgent.SetTools(tools)
870	return nil
871}
872
873func (c *coordinator) QueuedPrompts(sessionID string) int {
874	return c.currentAgent.QueuedPrompts(sessionID)
875}
876
877func (c *coordinator) QueuedPromptsList(sessionID string) []string {
878	return c.currentAgent.QueuedPromptsList(sessionID)
879}
880
881func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
882	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
883	if !ok {
884		return errors.New("model provider not configured")
885	}
886	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
887}
888
889func (c *coordinator) RecoverSession(ctx context.Context, sessionID string) error {
890	if c.currentAgent != nil && c.currentAgent.IsSessionBusy(sessionID) {
891		return nil
892	}
893
894	msgs, err := c.messages.List(ctx, sessionID)
895	if err != nil {
896		return fmt.Errorf("failed to list messages: %w", err)
897	}
898
899	for _, msg := range msgs {
900		if msg.IsFinished() {
901			continue
902		}
903
904		msg.FinishThinking()
905		for _, tc := range msg.ToolCalls() {
906			if !tc.Finished {
907				msg.FinishToolCall(tc.ID)
908			}
909		}
910
911		msg.AddFinish(message.FinishReasonError, "Session interrupted", "The session was previously interrupted")
912		if updateErr := c.messages.Update(ctx, msg); updateErr != nil {
913			slog.Error("Failed to recover message", "message_id", msg.ID, "error", updateErr)
914		}
915	}
916
917	return nil
918}
919
920func (c *coordinator) isUnauthorized(err error) bool {
921	var providerErr *fantasy.ProviderError
922	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
923}
924
925func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
926	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
927		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
928		return err
929	}
930	if err := c.UpdateModels(ctx); err != nil {
931		return err
932	}
933	return nil
934}
935
936func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
937	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
938	if err != nil {
939		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
940		return err
941	}
942
943	providerCfg.APIKey = newAPIKey
944	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
945
946	if err := c.UpdateModels(ctx); err != nil {
947		return err
948	}
949	return nil
950}