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