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