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