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