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		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
596	} else if c.cfg.Options.Debug {
597		httpClient = log.NewHTTPClient()
598	}
599	if httpClient != nil {
600		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
601	}
602
603	if len(headers) > 0 {
604		opts = append(opts, openaicompat.WithHeaders(headers))
605	}
606
607	for extraKey, extraValue := range extraBody {
608		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
609	}
610
611	return openaicompat.New(opts...)
612}
613
614func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
615	opts := []azure.Option{
616		azure.WithBaseURL(baseURL),
617		azure.WithAPIKey(apiKey),
618		azure.WithUseResponsesAPI(),
619	}
620	if c.cfg.Options.Debug {
621		httpClient := log.NewHTTPClient()
622		opts = append(opts, azure.WithHTTPClient(httpClient))
623	}
624	if options == nil {
625		options = make(map[string]string)
626	}
627	if apiVersion, ok := options["apiVersion"]; ok {
628		opts = append(opts, azure.WithAPIVersion(apiVersion))
629	}
630	if len(headers) > 0 {
631		opts = append(opts, azure.WithHeaders(headers))
632	}
633
634	return azure.New(opts...)
635}
636
637func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
638	var opts []bedrock.Option
639	if c.cfg.Options.Debug {
640		httpClient := log.NewHTTPClient()
641		opts = append(opts, bedrock.WithHTTPClient(httpClient))
642	}
643	if len(headers) > 0 {
644		opts = append(opts, bedrock.WithHeaders(headers))
645	}
646	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
647	if bearerToken != "" {
648		opts = append(opts, bedrock.WithAPIKey(bearerToken))
649	}
650	return bedrock.New(opts...)
651}
652
653func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
654	opts := []google.Option{
655		google.WithBaseURL(baseURL),
656		google.WithGeminiAPIKey(apiKey),
657	}
658	if c.cfg.Options.Debug {
659		httpClient := log.NewHTTPClient()
660		opts = append(opts, google.WithHTTPClient(httpClient))
661	}
662	if len(headers) > 0 {
663		opts = append(opts, google.WithHeaders(headers))
664	}
665	return google.New(opts...)
666}
667
668func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
669	opts := []google.Option{}
670	if c.cfg.Options.Debug {
671		httpClient := log.NewHTTPClient()
672		opts = append(opts, google.WithHTTPClient(httpClient))
673	}
674	if len(headers) > 0 {
675		opts = append(opts, google.WithHeaders(headers))
676	}
677
678	project := options["project"]
679	location := options["location"]
680
681	opts = append(opts, google.WithVertex(project, location))
682
683	return google.New(opts...)
684}
685
686func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
687	opts := []hyper.Option{
688		hyper.WithBaseURL(baseURL),
689		hyper.WithAPIKey(apiKey),
690	}
691	if c.cfg.Options.Debug {
692		httpClient := log.NewHTTPClient()
693		opts = append(opts, hyper.WithHTTPClient(httpClient))
694	}
695	return hyper.New(opts...)
696}
697
698func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
699	if model.Think {
700		return true
701	}
702
703	if model.ProviderOptions == nil {
704		return false
705	}
706
707	opts, err := anthropic.ParseOptions(model.ProviderOptions)
708	if err != nil {
709		return false
710	}
711	if opts.Thinking != nil {
712		return true
713	}
714	return false
715}
716
717func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
718	headers := maps.Clone(providerCfg.ExtraHeaders)
719	if headers == nil {
720		headers = make(map[string]string)
721	}
722
723	// handle special headers for anthropic
724	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
725		if v, ok := headers["anthropic-beta"]; ok {
726			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
727		} else {
728			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
729		}
730	}
731
732	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
733	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
734
735	switch providerCfg.Type {
736	case openai.Name:
737		return c.buildOpenaiProvider(baseURL, apiKey, headers)
738	case anthropic.Name:
739		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
740	case openrouter.Name:
741		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
742	case azure.Name:
743		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
744	case bedrock.Name:
745		return c.buildBedrockProvider(headers)
746	case google.Name:
747		return c.buildGoogleProvider(baseURL, apiKey, headers)
748	case "google-vertex":
749		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
750	case openaicompat.Name:
751		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
752			if providerCfg.ExtraBody == nil {
753				providerCfg.ExtraBody = map[string]any{}
754			}
755			providerCfg.ExtraBody["tool_stream"] = true
756		}
757		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
758	case hyper.Name:
759		return c.buildHyperProvider(baseURL, apiKey)
760	default:
761		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
762	}
763}
764
765func isExactoSupported(modelID string) bool {
766	supportedModels := []string{
767		"moonshotai/kimi-k2-0905",
768		"deepseek/deepseek-v3.1-terminus",
769		"z-ai/glm-4.6",
770		"openai/gpt-oss-120b",
771		"qwen/qwen3-coder",
772	}
773	return slices.Contains(supportedModels, modelID)
774}
775
776func (c *coordinator) Cancel(sessionID string) {
777	c.currentAgent.Cancel(sessionID)
778}
779
780func (c *coordinator) CancelAll() {
781	c.currentAgent.CancelAll()
782}
783
784func (c *coordinator) ClearQueue(sessionID string) {
785	c.currentAgent.ClearQueue(sessionID)
786}
787
788func (c *coordinator) IsBusy() bool {
789	return c.currentAgent.IsBusy()
790}
791
792func (c *coordinator) IsSessionBusy(sessionID string) bool {
793	return c.currentAgent.IsSessionBusy(sessionID)
794}
795
796func (c *coordinator) Model() Model {
797	return c.currentAgent.Model()
798}
799
800func (c *coordinator) UpdateModels(ctx context.Context) error {
801	// build the models again so we make sure we get the latest config
802	large, small, err := c.buildAgentModels(ctx, false)
803	if err != nil {
804		return err
805	}
806	c.currentAgent.SetModels(large, small)
807
808	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
809	if !ok {
810		return errors.New("coder agent not configured")
811	}
812
813	tools, err := c.buildTools(ctx, agentCfg)
814	if err != nil {
815		return err
816	}
817	c.currentAgent.SetTools(tools)
818	return nil
819}
820
821func (c *coordinator) QueuedPrompts(sessionID string) int {
822	return c.currentAgent.QueuedPrompts(sessionID)
823}
824
825func (c *coordinator) QueuedPromptsList(sessionID string) []string {
826	return c.currentAgent.QueuedPromptsList(sessionID)
827}
828
829func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
830	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
831	if !ok {
832		return errors.New("model provider not configured")
833	}
834	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
835}
836
837func (c *coordinator) isUnauthorized(err error) bool {
838	var providerErr *fantasy.ProviderError
839	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
840}
841
842func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
843	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
844		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
845		return err
846	}
847	if err := c.UpdateModels(ctx); err != nil {
848		return err
849	}
850	return nil
851}
852
853func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
854	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
855	if err != nil {
856		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
857		return err
858	}
859
860	providerCfg.APIKey = newAPIKey
861	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
862
863	if err := c.UpdateModels(ctx); err != nil {
864		return err
865	}
866	return nil
867}