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/oauth/copilot"
 30	"github.com/charmbracelet/crush/internal/permission"
 31	"github.com/charmbracelet/crush/internal/session"
 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, isSubAgent)
321	if err != nil {
322		return nil, err
323	}
324
325	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
326	result := NewSessionAgent(SessionAgentOptions{
327		large,
328		small,
329		largeProviderCfg.SystemPromptPrefix,
330		"",
331		isSubAgent,
332		c.cfg.Options.DisableAutoSummarize,
333		c.permissions.SkipRequests(),
334		c.sessions,
335		c.messages,
336		nil,
337	})
338
339	c.readyWg.Go(func() error {
340		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
341		if err != nil {
342			return err
343		}
344		result.SetSystemPrompt(systemPrompt)
345		return nil
346	})
347
348	c.readyWg.Go(func() error {
349		tools, err := c.buildTools(ctx, agent)
350		if err != nil {
351			return err
352		}
353		result.SetTools(tools)
354		return nil
355	})
356
357	return result, nil
358}
359
360func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
361	var allTools []fantasy.AgentTool
362	if slices.Contains(agent.AllowedTools, AgentToolName) {
363		agentTool, err := c.agentTool(ctx)
364		if err != nil {
365			return nil, err
366		}
367		allTools = append(allTools, agentTool)
368	}
369
370	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
371		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
372		if err != nil {
373			return nil, err
374		}
375		allTools = append(allTools, agenticFetchTool)
376	}
377
378	// Get the model name for the agent
379	modelName := ""
380	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
381		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
382			modelName = model.Name
383		}
384	}
385
386	allTools = append(allTools,
387		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
388		tools.NewJobOutputTool(),
389		tools.NewJobKillTool(),
390		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
391		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
392		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
393		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
394		tools.NewGlobTool(c.cfg.WorkingDir()),
395		tools.NewGrepTool(c.cfg.WorkingDir()),
396		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
397		tools.NewSourcegraphTool(nil),
398		tools.NewTodosTool(c.sessions),
399		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
400		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
401	)
402
403	if len(c.cfg.LSP) > 0 {
404		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
405	}
406
407	var filteredTools []fantasy.AgentTool
408	for _, tool := range allTools {
409		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
410			filteredTools = append(filteredTools, tool)
411		}
412	}
413
414	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
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, isSubAgent bool) (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, isSubAgent)
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, true)
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) (fantasy.Provider, error) {
527	var opts []anthropic.Option
528
529	if strings.HasPrefix(apiKey, "Bearer ") {
530		// NOTE: Prevent the SDK from picking up the API key from env.
531		os.Setenv("ANTHROPIC_API_KEY", "")
532		headers["Authorization"] = 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, providerID string, isSubAgent bool) (fantasy.Provider, error) {
586	opts := []openaicompat.Option{
587		openaicompat.WithBaseURL(baseURL),
588		openaicompat.WithAPIKey(apiKey),
589	}
590
591	// Set HTTP client based on provider and debug mode.
592	var httpClient *http.Client
593	if providerID == string(catwalk.InferenceProviderCopilot) {
594		opts = append(opts, openaicompat.WithUseResponsesAPI())
595		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
596	} else if c.cfg.Options.Debug {
597		httpClient = log.NewHTTPClient()
598	}
599	if httpClient != nil {
600		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
601	}
602
603	if len(headers) > 0 {
604		opts = append(opts, openaicompat.WithHeaders(headers))
605	}
606
607	for extraKey, extraValue := range extraBody {
608		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
609	}
610
611	return openaicompat.New(opts...)
612}
613
614func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
615	opts := []azure.Option{
616		azure.WithBaseURL(baseURL),
617		azure.WithAPIKey(apiKey),
618		azure.WithUseResponsesAPI(),
619	}
620	if c.cfg.Options.Debug {
621		httpClient := log.NewHTTPClient()
622		opts = append(opts, azure.WithHTTPClient(httpClient))
623	}
624	if options == nil {
625		options = make(map[string]string)
626	}
627	if apiVersion, ok := options["apiVersion"]; ok {
628		opts = append(opts, azure.WithAPIVersion(apiVersion))
629	}
630	if len(headers) > 0 {
631		opts = append(opts, azure.WithHeaders(headers))
632	}
633
634	return azure.New(opts...)
635}
636
637func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
638	var opts []bedrock.Option
639	if c.cfg.Options.Debug {
640		httpClient := log.NewHTTPClient()
641		opts = append(opts, bedrock.WithHTTPClient(httpClient))
642	}
643	if len(headers) > 0 {
644		opts = append(opts, bedrock.WithHeaders(headers))
645	}
646	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
647	if bearerToken != "" {
648		opts = append(opts, bedrock.WithAPIKey(bearerToken))
649	}
650	return bedrock.New(opts...)
651}
652
653func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
654	opts := []google.Option{
655		google.WithBaseURL(baseURL),
656		google.WithGeminiAPIKey(apiKey),
657	}
658	if c.cfg.Options.Debug {
659		httpClient := log.NewHTTPClient()
660		opts = append(opts, google.WithHTTPClient(httpClient))
661	}
662	if len(headers) > 0 {
663		opts = append(opts, google.WithHeaders(headers))
664	}
665	return google.New(opts...)
666}
667
668func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
669	opts := []google.Option{}
670	if c.cfg.Options.Debug {
671		httpClient := log.NewHTTPClient()
672		opts = append(opts, google.WithHTTPClient(httpClient))
673	}
674	if len(headers) > 0 {
675		opts = append(opts, google.WithHeaders(headers))
676	}
677
678	project := options["project"]
679	location := options["location"]
680
681	opts = append(opts, google.WithVertex(project, location))
682
683	return google.New(opts...)
684}
685
686func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
687	opts := []hyper.Option{
688		hyper.WithBaseURL(baseURL),
689		hyper.WithAPIKey(apiKey),
690	}
691	if c.cfg.Options.Debug {
692		httpClient := log.NewHTTPClient()
693		opts = append(opts, hyper.WithHTTPClient(httpClient))
694	}
695	return hyper.New(opts...)
696}
697
698func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
699	if model.Think {
700		return true
701	}
702
703	if model.ProviderOptions == nil {
704		return false
705	}
706
707	opts, err := anthropic.ParseOptions(model.ProviderOptions)
708	if err != nil {
709		return false
710	}
711	if opts.Thinking != nil {
712		return true
713	}
714	return false
715}
716
717func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
718	headers := maps.Clone(providerCfg.ExtraHeaders)
719	if headers == nil {
720		headers = make(map[string]string)
721	}
722
723	// handle special headers for anthropic
724	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
725		if v, ok := headers["anthropic-beta"]; ok {
726			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
727		} else {
728			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
729		}
730	}
731
732	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
733	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
734
735	switch providerCfg.Type {
736	case openai.Name:
737		return c.buildOpenaiProvider(baseURL, apiKey, headers)
738	case anthropic.Name:
739		return c.buildAnthropicProvider(baseURL, apiKey, headers)
740	case openrouter.Name:
741		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
742	case azure.Name:
743		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
744	case bedrock.Name:
745		return c.buildBedrockProvider(headers)
746	case google.Name:
747		return c.buildGoogleProvider(baseURL, apiKey, headers)
748	case "google-vertex":
749		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
750	case openaicompat.Name:
751		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
752			if providerCfg.ExtraBody == nil {
753				providerCfg.ExtraBody = map[string]any{}
754			}
755			providerCfg.ExtraBody["tool_stream"] = true
756		}
757		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
758	case hyper.Name:
759		return c.buildHyperProvider(baseURL, apiKey)
760	default:
761		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
762	}
763}
764
765func isExactoSupported(modelID string) bool {
766	supportedModels := []string{
767		"moonshotai/kimi-k2-0905",
768		"deepseek/deepseek-v3.1-terminus",
769		"z-ai/glm-4.6",
770		"openai/gpt-oss-120b",
771		"qwen/qwen3-coder",
772	}
773	return slices.Contains(supportedModels, modelID)
774}
775
776func (c *coordinator) Cancel(sessionID string) {
777	c.currentAgent.Cancel(sessionID)
778}
779
780func (c *coordinator) CancelAll() {
781	c.currentAgent.CancelAll()
782}
783
784func (c *coordinator) ClearQueue(sessionID string) {
785	c.currentAgent.ClearQueue(sessionID)
786}
787
788func (c *coordinator) IsBusy() bool {
789	return c.currentAgent.IsBusy()
790}
791
792func (c *coordinator) IsSessionBusy(sessionID string) bool {
793	return c.currentAgent.IsSessionBusy(sessionID)
794}
795
796func (c *coordinator) Model() Model {
797	return c.currentAgent.Model()
798}
799
800func (c *coordinator) UpdateModels(ctx context.Context) error {
801	// build the models again so we make sure we get the latest config
802	large, small, err := c.buildAgentModels(ctx, false)
803	if err != nil {
804		return err
805	}
806	c.currentAgent.SetModels(large, small)
807
808	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
809	if !ok {
810		return errors.New("coder agent not configured")
811	}
812
813	tools, err := c.buildTools(ctx, agentCfg)
814	if err != nil {
815		return err
816	}
817	c.currentAgent.SetTools(tools)
818	return nil
819}
820
821func (c *coordinator) QueuedPrompts(sessionID string) int {
822	return c.currentAgent.QueuedPrompts(sessionID)
823}
824
825func (c *coordinator) QueuedPromptsList(sessionID string) []string {
826	return c.currentAgent.QueuedPromptsList(sessionID)
827}
828
829func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
830	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
831	if !ok {
832		return errors.New("model provider not configured")
833	}
834	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
835}
836
837func (c *coordinator) isUnauthorized(err error) bool {
838	var providerErr *fantasy.ProviderError
839	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
840}
841
842func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
843	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
844		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
845		return err
846	}
847	if err := c.UpdateModels(ctx); err != nil {
848		return err
849	}
850	return nil
851}
852
853func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
854	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
855	if err != nil {
856		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
857		return err
858	}
859
860	providerCfg.APIKey = newAPIKey
861	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
862
863	if err := c.UpdateModels(ctx); err != nil {
864		return err
865	}
866	return nil
867}