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