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/permission"
 30	"github.com/charmbracelet/crush/internal/session"
 31	"golang.org/x/sync/errgroup"
 32
 33	"charm.land/fantasy/providers/anthropic"
 34	"charm.land/fantasy/providers/azure"
 35	"charm.land/fantasy/providers/bedrock"
 36	"charm.land/fantasy/providers/google"
 37	"charm.land/fantasy/providers/openai"
 38	"charm.land/fantasy/providers/openaicompat"
 39	"charm.land/fantasy/providers/openrouter"
 40	openaisdk "github.com/openai/openai-go/v2/option"
 41	"github.com/qjebbs/go-jsons"
 42)
 43
 44type Coordinator interface {
 45	// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
 46	// SetMainAgent(string)
 47	Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
 48	Cancel(sessionID string)
 49	CancelAll()
 50	IsSessionBusy(sessionID string) bool
 51	IsBusy() bool
 52	QueuedPrompts(sessionID string) int
 53	QueuedPromptsList(sessionID string) []string
 54	ClearQueue(sessionID string)
 55	Summarize(context.Context, string) error
 56	Model() Model
 57	UpdateModels(ctx context.Context) error
 58}
 59
 60type coordinator struct {
 61	cfg         *config.Config
 62	sessions    session.Service
 63	messages    message.Service
 64	permissions permission.Service
 65	history     history.Service
 66	lspClients  *csync.Map[string, *lsp.Client]
 67
 68	currentAgent SessionAgent
 69	agents       map[string]SessionAgent
 70
 71	readyWg errgroup.Group
 72}
 73
 74func NewCoordinator(
 75	ctx context.Context,
 76	cfg *config.Config,
 77	sessions session.Service,
 78	messages message.Service,
 79	permissions permission.Service,
 80	history history.Service,
 81	lspClients *csync.Map[string, *lsp.Client],
 82) (Coordinator, error) {
 83	c := &coordinator{
 84		cfg:         cfg,
 85		sessions:    sessions,
 86		messages:    messages,
 87		permissions: permissions,
 88		history:     history,
 89		lspClients:  lspClients,
 90		agents:      make(map[string]SessionAgent),
 91	}
 92
 93	agentCfg, ok := cfg.Agents[config.AgentCoder]
 94	if !ok {
 95		return nil, errors.New("coder agent not configured")
 96	}
 97
 98	// TODO: make this dynamic when we support multiple agents
 99	agent, err := c.buildCoderAgent(ctx, agentCfg, false)
100	if err != nil {
101		return nil, err
102	}
103	c.currentAgent = agent
104	c.agents[config.AgentCoder] = agent
105	return c, nil
106}
107
108// Run implements Coordinator.
109func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
110	if err := c.readyWg.Wait(); err != nil {
111		return nil, err
112	}
113
114	model := c.currentAgent.Model()
115	maxTokens := model.CatwalkCfg.DefaultMaxTokens
116	if model.ModelCfg.MaxTokens != 0 {
117		maxTokens = model.ModelCfg.MaxTokens
118	}
119
120	if !model.CatwalkCfg.SupportsImages && attachments != nil {
121		// filter out image attachments
122		filteredAttachments := make([]message.Attachment, 0, len(attachments))
123		for _, att := range attachments {
124			if att.IsText() {
125				filteredAttachments = append(filteredAttachments, att)
126			}
127		}
128		attachments = filteredAttachments
129	}
130
131	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
132	if !ok {
133		return nil, errors.New("model provider not configured")
134	}
135
136	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
137
138	if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
139		slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
140		if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
141			return nil, err
142		}
143	}
144
145	run := func() (*fantasy.AgentResult, error) {
146		return c.currentAgent.Run(ctx, SessionAgentCall{
147			SessionID:        sessionID,
148			Prompt:           prompt,
149			Attachments:      attachments,
150			MaxOutputTokens:  maxTokens,
151			ProviderOptions:  mergedOptions,
152			Temperature:      temp,
153			TopP:             topP,
154			TopK:             topK,
155			FrequencyPenalty: freqPenalty,
156			PresencePenalty:  presPenalty,
157		})
158	}
159	result, originalErr := run()
160
161	if c.isUnauthorized(originalErr) {
162		switch {
163		case providerCfg.OAuthToken != nil:
164			slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
165			if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
166				return nil, originalErr
167			}
168			slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
169			return run()
170		case strings.Contains(providerCfg.APIKeyTemplate, "$"):
171			slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
172			if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
173				return nil, originalErr
174			}
175			slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
176			return run()
177		}
178	}
179
180	return result, originalErr
181}
182
183func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
184	options := fantasy.ProviderOptions{}
185
186	cfgOpts := []byte("{}")
187	providerCfgOpts := []byte("{}")
188	catwalkOpts := []byte("{}")
189
190	if model.ModelCfg.ProviderOptions != nil {
191		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
192		if err == nil {
193			cfgOpts = data
194		}
195	}
196
197	if providerCfg.ProviderOptions != nil {
198		data, err := json.Marshal(providerCfg.ProviderOptions)
199		if err == nil {
200			providerCfgOpts = data
201		}
202	}
203
204	if model.CatwalkCfg.Options.ProviderOptions != nil {
205		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
206		if err == nil {
207			catwalkOpts = data
208		}
209	}
210
211	readers := []io.Reader{
212		bytes.NewReader(catwalkOpts),
213		bytes.NewReader(providerCfgOpts),
214		bytes.NewReader(cfgOpts),
215	}
216
217	got, err := jsons.Merge(readers)
218	if err != nil {
219		slog.Error("Could not merge call config", "err", err)
220		return options
221	}
222
223	mergedOptions := make(map[string]any)
224
225	err = json.Unmarshal([]byte(got), &mergedOptions)
226	if err != nil {
227		slog.Error("Could not create config for call", "err", err)
228		return options
229	}
230
231	switch providerCfg.Type {
232	case openai.Name, azure.Name:
233		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
234		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
235			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
236		}
237		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
238			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
239				mergedOptions["reasoning_summary"] = "auto"
240				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
241			}
242			parsed, err := openai.ParseResponsesOptions(mergedOptions)
243			if err == nil {
244				options[openai.Name] = parsed
245			}
246		} else {
247			parsed, err := openai.ParseOptions(mergedOptions)
248			if err == nil {
249				options[openai.Name] = parsed
250			}
251		}
252	case anthropic.Name:
253		_, hasThink := mergedOptions["thinking"]
254		if !hasThink && model.ModelCfg.Think {
255			mergedOptions["thinking"] = map[string]any{
256				// TODO: kujtim see if we need to make this dynamic
257				"budget_tokens": 2000,
258			}
259		}
260		parsed, err := anthropic.ParseOptions(mergedOptions)
261		if err == nil {
262			options[anthropic.Name] = parsed
263		}
264
265	case openrouter.Name:
266		_, hasReasoning := mergedOptions["reasoning"]
267		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
268			mergedOptions["reasoning"] = map[string]any{
269				"enabled": true,
270				"effort":  model.ModelCfg.ReasoningEffort,
271			}
272		}
273		parsed, err := openrouter.ParseOptions(mergedOptions)
274		if err == nil {
275			options[openrouter.Name] = parsed
276		}
277	case google.Name:
278		_, hasReasoning := mergedOptions["thinking_config"]
279		if !hasReasoning {
280			mergedOptions["thinking_config"] = map[string]any{
281				"thinking_budget":  2000,
282				"include_thoughts": true,
283			}
284		}
285		parsed, err := google.ParseOptions(mergedOptions)
286		if err == nil {
287			options[google.Name] = parsed
288		}
289	case openaicompat.Name:
290		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
291		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
292			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
293		}
294		parsed, err := openaicompat.ParseOptions(mergedOptions)
295		if err == nil {
296			options[openaicompat.Name] = parsed
297		}
298	}
299
300	return options
301}
302
303func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
304	modelOptions := getProviderOptions(model, cfg)
305	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
306	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
307	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
308	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
309	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
310	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
311}
312
313func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
314	large, small, err := c.buildAgentModels(ctx)
315	if err != nil {
316		return nil, err
317	}
318
319	systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
320	if err != nil {
321		return nil, err
322	}
323
324	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
325	result := NewSessionAgent(SessionAgentOptions{
326		large,
327		small,
328		largeProviderCfg.SystemPromptPrefix,
329		systemPrompt,
330		isSubAgent,
331		c.cfg.Options.DisableAutoSummarize,
332		c.permissions.SkipRequests(),
333		c.sessions,
334		c.messages,
335		nil,
336	})
337	c.readyWg.Go(func() error {
338		tools, err := c.buildTools(ctx, agent)
339		if err != nil {
340			return err
341		}
342		result.SetTools(tools)
343		return nil
344	})
345
346	return result, nil
347}
348
349func (c *coordinator) buildCoderAgent(ctx context.Context, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
350	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
351	if !ok {
352		return nil, errors.New("large model not selected")
353	}
354
355	promptInstance, err := coderPrompt(largeModelCfg.Model, prompt.WithWorkingDir(c.cfg.WorkingDir()))
356	if err != nil {
357		return nil, err
358	}
359
360	return c.buildAgent(ctx, promptInstance, agent, isSubAgent)
361}
362
363func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
364	var allTools []fantasy.AgentTool
365	if slices.Contains(agent.AllowedTools, AgentToolName) {
366		agentTool, err := c.agentTool(ctx)
367		if err != nil {
368			return nil, err
369		}
370		allTools = append(allTools, agentTool)
371	}
372
373	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
374		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
375		if err != nil {
376			return nil, err
377		}
378		allTools = append(allTools, agenticFetchTool)
379	}
380
381	// Get the model name for the agent
382	modelName := ""
383	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
384		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
385			modelName = model.Name
386		}
387	}
388
389	allTools = append(allTools,
390		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
391		tools.NewJobOutputTool(),
392		tools.NewJobKillTool(),
393		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
394		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
395		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
396		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
397		tools.NewGlobTool(c.cfg.WorkingDir()),
398		tools.NewGrepTool(c.cfg.WorkingDir()),
399		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
400		tools.NewSourcegraphTool(nil),
401		tools.NewTodosTool(c.sessions),
402		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
403		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
404	)
405
406	if len(c.cfg.LSP) > 0 {
407		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
408	}
409
410	var filteredTools []fantasy.AgentTool
411	for _, tool := range allTools {
412		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
413			filteredTools = append(filteredTools, tool)
414		}
415	}
416
417	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
418		// Check MCP-specific disabled tools.
419		if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
420			if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
421				continue
422			}
423		}
424		if agent.AllowedMCP == nil {
425			// No MCP restrictions
426			filteredTools = append(filteredTools, tool)
427			continue
428		}
429		if len(agent.AllowedMCP) == 0 {
430			// No MCPs allowed
431			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
432			break
433		}
434
435		for mcp, tools := range agent.AllowedMCP {
436			if mcp != tool.MCP() {
437				continue
438			}
439			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
440				filteredTools = append(filteredTools, tool)
441			}
442		}
443		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
444	}
445	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
446		return strings.Compare(a.Info().Name, b.Info().Name)
447	})
448	return filteredTools, nil
449}
450
451// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
452func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
453	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
454	if !ok {
455		return Model{}, Model{}, errors.New("large model not selected")
456	}
457	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
458	if !ok {
459		return Model{}, Model{}, errors.New("small model not selected")
460	}
461
462	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
463	if !ok {
464		return Model{}, Model{}, errors.New("large model provider not configured")
465	}
466
467	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
468	if err != nil {
469		return Model{}, Model{}, err
470	}
471
472	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
473	if !ok {
474		return Model{}, Model{}, errors.New("large model provider not configured")
475	}
476
477	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
478	if err != nil {
479		return Model{}, Model{}, err
480	}
481
482	var largeCatwalkModel *catwalk.Model
483	var smallCatwalkModel *catwalk.Model
484
485	for _, m := range largeProviderCfg.Models {
486		if m.ID == largeModelCfg.Model {
487			largeCatwalkModel = &m
488		}
489	}
490	for _, m := range smallProviderCfg.Models {
491		if m.ID == smallModelCfg.Model {
492			smallCatwalkModel = &m
493		}
494	}
495
496	if largeCatwalkModel == nil {
497		return Model{}, Model{}, errors.New("large model not found in provider config")
498	}
499
500	if smallCatwalkModel == nil {
501		return Model{}, Model{}, errors.New("snall model not found in provider config")
502	}
503
504	largeModelID := largeModelCfg.Model
505	smallModelID := smallModelCfg.Model
506
507	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
508		largeModelID += ":exacto"
509	}
510
511	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
512		smallModelID += ":exacto"
513	}
514
515	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
516	if err != nil {
517		return Model{}, Model{}, err
518	}
519	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
520	if err != nil {
521		return Model{}, Model{}, err
522	}
523
524	return Model{
525			Model:      largeModel,
526			CatwalkCfg: *largeCatwalkModel,
527			ModelCfg:   largeModelCfg,
528		}, Model{
529			Model:      smallModel,
530			CatwalkCfg: *smallCatwalkModel,
531			ModelCfg:   smallModelCfg,
532		}, nil
533}
534
535func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
536	var opts []anthropic.Option
537
538	if isOauth {
539		// NOTE: Prevent the SDK from picking up the API key from env.
540		os.Setenv("ANTHROPIC_API_KEY", "")
541		headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
542	} else if apiKey != "" {
543		// X-Api-Key header
544		opts = append(opts, anthropic.WithAPIKey(apiKey))
545	}
546
547	if len(headers) > 0 {
548		opts = append(opts, anthropic.WithHeaders(headers))
549	}
550
551	if baseURL != "" {
552		opts = append(opts, anthropic.WithBaseURL(baseURL))
553	}
554
555	if c.cfg.Options.Debug {
556		httpClient := log.NewHTTPClient()
557		opts = append(opts, anthropic.WithHTTPClient(httpClient))
558	}
559	return anthropic.New(opts...)
560}
561
562func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
563	opts := []openai.Option{
564		openai.WithAPIKey(apiKey),
565		openai.WithUseResponsesAPI(),
566	}
567	if c.cfg.Options.Debug {
568		httpClient := log.NewHTTPClient()
569		opts = append(opts, openai.WithHTTPClient(httpClient))
570	}
571	if len(headers) > 0 {
572		opts = append(opts, openai.WithHeaders(headers))
573	}
574	if baseURL != "" {
575		opts = append(opts, openai.WithBaseURL(baseURL))
576	}
577	return openai.New(opts...)
578}
579
580func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
581	opts := []openrouter.Option{
582		openrouter.WithAPIKey(apiKey),
583	}
584	if c.cfg.Options.Debug {
585		httpClient := log.NewHTTPClient()
586		opts = append(opts, openrouter.WithHTTPClient(httpClient))
587	}
588	if len(headers) > 0 {
589		opts = append(opts, openrouter.WithHeaders(headers))
590	}
591	return openrouter.New(opts...)
592}
593
594func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
595	opts := []openaicompat.Option{
596		openaicompat.WithBaseURL(baseURL),
597		openaicompat.WithAPIKey(apiKey),
598	}
599	if c.cfg.Options.Debug {
600		httpClient := log.NewHTTPClient()
601		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
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) (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)
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)
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}