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