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