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		// 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()),
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		// Check MCP-specific disabled tools.
410		if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
411			if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
412				continue
413			}
414		}
415		if agent.AllowedMCP == nil {
416			// No MCP restrictions
417			filteredTools = append(filteredTools, tool)
418			continue
419		}
420		if len(agent.AllowedMCP) == 0 {
421			// No MCPs allowed
422			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
423			break
424		}
425
426		for mcp, tools := range agent.AllowedMCP {
427			if mcp != tool.MCP() {
428				continue
429			}
430			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
431				filteredTools = append(filteredTools, tool)
432			}
433		}
434		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
435	}
436	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
437		return strings.Compare(a.Info().Name, b.Info().Name)
438	})
439	return filteredTools, nil
440}
441
442// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
443func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
444	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
445	if !ok {
446		return Model{}, Model{}, errors.New("large model not selected")
447	}
448	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
449	if !ok {
450		return Model{}, Model{}, errors.New("small model not selected")
451	}
452
453	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
454	if !ok {
455		return Model{}, Model{}, errors.New("large model provider not configured")
456	}
457
458	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
459	if err != nil {
460		return Model{}, Model{}, err
461	}
462
463	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
464	if !ok {
465		return Model{}, Model{}, errors.New("large model provider not configured")
466	}
467
468	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
469	if err != nil {
470		return Model{}, Model{}, err
471	}
472
473	var largeCatwalkModel *catwalk.Model
474	var smallCatwalkModel *catwalk.Model
475
476	for _, m := range largeProviderCfg.Models {
477		if m.ID == largeModelCfg.Model {
478			largeCatwalkModel = &m
479		}
480	}
481	for _, m := range smallProviderCfg.Models {
482		if m.ID == smallModelCfg.Model {
483			smallCatwalkModel = &m
484		}
485	}
486
487	if largeCatwalkModel == nil {
488		return Model{}, Model{}, errors.New("large model not found in provider config")
489	}
490
491	if smallCatwalkModel == nil {
492		return Model{}, Model{}, errors.New("snall model not found in provider config")
493	}
494
495	largeModelID := largeModelCfg.Model
496	smallModelID := smallModelCfg.Model
497
498	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
499		largeModelID += ":exacto"
500	}
501
502	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
503		smallModelID += ":exacto"
504	}
505
506	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
507	if err != nil {
508		return Model{}, Model{}, err
509	}
510	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
511	if err != nil {
512		return Model{}, Model{}, err
513	}
514
515	return Model{
516			Model:      largeModel,
517			CatwalkCfg: *largeCatwalkModel,
518			ModelCfg:   largeModelCfg,
519		}, Model{
520			Model:      smallModel,
521			CatwalkCfg: *smallCatwalkModel,
522			ModelCfg:   smallModelCfg,
523		}, nil
524}
525
526func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
527	var opts []anthropic.Option
528
529	if isOauth {
530		// NOTE: Prevent the SDK from picking up the API key from env.
531		os.Setenv("ANTHROPIC_API_KEY", "")
532		headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
533	} else if apiKey != "" {
534		// X-Api-Key header
535		opts = append(opts, anthropic.WithAPIKey(apiKey))
536	}
537
538	if len(headers) > 0 {
539		opts = append(opts, anthropic.WithHeaders(headers))
540	}
541
542	if baseURL != "" {
543		opts = append(opts, anthropic.WithBaseURL(baseURL))
544	}
545
546	if c.cfg.Options.Debug {
547		httpClient := log.NewHTTPClient()
548		opts = append(opts, anthropic.WithHTTPClient(httpClient))
549	}
550	return anthropic.New(opts...)
551}
552
553func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
554	opts := []openai.Option{
555		openai.WithAPIKey(apiKey),
556		openai.WithUseResponsesAPI(),
557	}
558	if c.cfg.Options.Debug {
559		httpClient := log.NewHTTPClient()
560		opts = append(opts, openai.WithHTTPClient(httpClient))
561	}
562	if len(headers) > 0 {
563		opts = append(opts, openai.WithHeaders(headers))
564	}
565	if baseURL != "" {
566		opts = append(opts, openai.WithBaseURL(baseURL))
567	}
568	return openai.New(opts...)
569}
570
571func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
572	opts := []openrouter.Option{
573		openrouter.WithAPIKey(apiKey),
574	}
575	if c.cfg.Options.Debug {
576		httpClient := log.NewHTTPClient()
577		opts = append(opts, openrouter.WithHTTPClient(httpClient))
578	}
579	if len(headers) > 0 {
580		opts = append(opts, openrouter.WithHeaders(headers))
581	}
582	return openrouter.New(opts...)
583}
584
585func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
586	opts := []openaicompat.Option{
587		openaicompat.WithBaseURL(baseURL),
588		openaicompat.WithAPIKey(apiKey),
589	}
590	if c.cfg.Options.Debug {
591		httpClient := log.NewHTTPClient()
592		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
593	}
594	if len(headers) > 0 {
595		opts = append(opts, openaicompat.WithHeaders(headers))
596	}
597
598	for extraKey, extraValue := range extraBody {
599		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
600	}
601
602	return openaicompat.New(opts...)
603}
604
605func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
606	opts := []azure.Option{
607		azure.WithBaseURL(baseURL),
608		azure.WithAPIKey(apiKey),
609		azure.WithUseResponsesAPI(),
610	}
611	if c.cfg.Options.Debug {
612		httpClient := log.NewHTTPClient()
613		opts = append(opts, azure.WithHTTPClient(httpClient))
614	}
615	if options == nil {
616		options = make(map[string]string)
617	}
618	if apiVersion, ok := options["apiVersion"]; ok {
619		opts = append(opts, azure.WithAPIVersion(apiVersion))
620	}
621	if len(headers) > 0 {
622		opts = append(opts, azure.WithHeaders(headers))
623	}
624
625	return azure.New(opts...)
626}
627
628func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
629	var opts []bedrock.Option
630	if c.cfg.Options.Debug {
631		httpClient := log.NewHTTPClient()
632		opts = append(opts, bedrock.WithHTTPClient(httpClient))
633	}
634	if len(headers) > 0 {
635		opts = append(opts, bedrock.WithHeaders(headers))
636	}
637	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
638	if bearerToken != "" {
639		opts = append(opts, bedrock.WithAPIKey(bearerToken))
640	}
641	return bedrock.New(opts...)
642}
643
644func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
645	opts := []google.Option{
646		google.WithBaseURL(baseURL),
647		google.WithGeminiAPIKey(apiKey),
648	}
649	if c.cfg.Options.Debug {
650		httpClient := log.NewHTTPClient()
651		opts = append(opts, google.WithHTTPClient(httpClient))
652	}
653	if len(headers) > 0 {
654		opts = append(opts, google.WithHeaders(headers))
655	}
656	return google.New(opts...)
657}
658
659func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
660	opts := []google.Option{}
661	if c.cfg.Options.Debug {
662		httpClient := log.NewHTTPClient()
663		opts = append(opts, google.WithHTTPClient(httpClient))
664	}
665	if len(headers) > 0 {
666		opts = append(opts, google.WithHeaders(headers))
667	}
668
669	project := options["project"]
670	location := options["location"]
671
672	opts = append(opts, google.WithVertex(project, location))
673
674	return google.New(opts...)
675}
676
677func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
678	opts := []hyper.Option{
679		hyper.WithBaseURL(baseURL),
680		hyper.WithAPIKey(apiKey),
681	}
682	if c.cfg.Options.Debug {
683		httpClient := log.NewHTTPClient()
684		opts = append(opts, hyper.WithHTTPClient(httpClient))
685	}
686	return hyper.New(opts...)
687}
688
689func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
690	if model.Think {
691		return true
692	}
693
694	if model.ProviderOptions == nil {
695		return false
696	}
697
698	opts, err := anthropic.ParseOptions(model.ProviderOptions)
699	if err != nil {
700		return false
701	}
702	if opts.Thinking != nil {
703		return true
704	}
705	return false
706}
707
708func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
709	headers := maps.Clone(providerCfg.ExtraHeaders)
710	if headers == nil {
711		headers = make(map[string]string)
712	}
713
714	// handle special headers for anthropic
715	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
716		if v, ok := headers["anthropic-beta"]; ok {
717			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
718		} else {
719			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
720		}
721	}
722
723	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
724	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
725
726	switch providerCfg.Type {
727	case openai.Name:
728		return c.buildOpenaiProvider(baseURL, apiKey, headers)
729	case anthropic.Name:
730		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
731	case openrouter.Name:
732		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
733	case azure.Name:
734		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
735	case bedrock.Name:
736		return c.buildBedrockProvider(headers)
737	case google.Name:
738		return c.buildGoogleProvider(baseURL, apiKey, headers)
739	case "google-vertex":
740		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
741	case openaicompat.Name:
742		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
743			if providerCfg.ExtraBody == nil {
744				providerCfg.ExtraBody = map[string]any{}
745			}
746			providerCfg.ExtraBody["tool_stream"] = true
747		}
748		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
749	case hyper.Name:
750		return c.buildHyperProvider(baseURL, apiKey)
751	default:
752		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
753	}
754}
755
756func isExactoSupported(modelID string) bool {
757	supportedModels := []string{
758		"moonshotai/kimi-k2-0905",
759		"deepseek/deepseek-v3.1-terminus",
760		"z-ai/glm-4.6",
761		"openai/gpt-oss-120b",
762		"qwen/qwen3-coder",
763	}
764	return slices.Contains(supportedModels, modelID)
765}
766
767func (c *coordinator) Cancel(sessionID string) {
768	c.currentAgent.Cancel(sessionID)
769}
770
771func (c *coordinator) CancelAll() {
772	c.currentAgent.CancelAll()
773}
774
775func (c *coordinator) ClearQueue(sessionID string) {
776	c.currentAgent.ClearQueue(sessionID)
777}
778
779func (c *coordinator) IsBusy() bool {
780	return c.currentAgent.IsBusy()
781}
782
783func (c *coordinator) IsSessionBusy(sessionID string) bool {
784	return c.currentAgent.IsSessionBusy(sessionID)
785}
786
787func (c *coordinator) Model() Model {
788	return c.currentAgent.Model()
789}
790
791func (c *coordinator) UpdateModels(ctx context.Context) error {
792	// build the models again so we make sure we get the latest config
793	large, small, err := c.buildAgentModels(ctx)
794	if err != nil {
795		return err
796	}
797	c.currentAgent.SetModels(large, small)
798
799	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
800	if !ok {
801		return errors.New("coder agent not configured")
802	}
803
804	tools, err := c.buildTools(ctx, agentCfg)
805	if err != nil {
806		return err
807	}
808	c.currentAgent.SetTools(tools)
809	return nil
810}
811
812func (c *coordinator) QueuedPrompts(sessionID string) int {
813	return c.currentAgent.QueuedPrompts(sessionID)
814}
815
816func (c *coordinator) QueuedPromptsList(sessionID string) []string {
817	return c.currentAgent.QueuedPromptsList(sessionID)
818}
819
820func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
821	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
822	if !ok {
823		return errors.New("model provider not configured")
824	}
825	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
826}
827
828func (c *coordinator) isUnauthorized(err error) bool {
829	var providerErr *fantasy.ProviderError
830	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
831}
832
833func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
834	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
835		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
836		return err
837	}
838	if err := c.UpdateModels(ctx); err != nil {
839		return err
840	}
841	return nil
842}
843
844func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
845	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
846	if err != nil {
847		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
848		return err
849	}
850
851	providerCfg.APIKey = newAPIKey
852	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
853
854	if err := c.UpdateModels(ctx); err != nil {
855		return err
856	}
857	return nil
858}