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