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