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