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