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