diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 30dd7fbcb0..2c5dcf0952 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -28,6 +28,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" + cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" gitlabauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" @@ -3473,3 +3474,78 @@ func (h *Handler) RequestKiloToken(c *gin.Context) { "verification_uri": resp.VerificationURL, }) } + +// RequestCursorToken initiates the Cursor authentication flow via cursor-agent login. +func (h *Handler) RequestCursorToken(c *gin.Context) { + state := fmt.Sprintf("cursor-%d", time.Now().UnixNano()) + RegisterOAuthSession(state, "cursor") + + oauth := cursorauth.NewCursorOAuth(h.cfg) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + defer cancel() + + tokenData, err := oauth.Login(ctx) + if err != nil { + log.Errorf("Cursor authentication failed: %v", err) + SetOAuthSessionError(state, fmt.Sprintf("Authentication failed: %v", err)) + return + } + + tokenStorage := &cursorauth.CursorTokenStorage{ + TokenData: *tokenData, + } + + metadata, errMeta := cursorTokenMetadata(tokenStorage) + if errMeta != nil { + log.Errorf("Failed to build cursor token metadata: %v", errMeta) + SetOAuthSessionError(state, "Failed to build token metadata") + return + } + + fileName := "cursor.json" + record := &coreauth.Auth{ + ID: fileName, + Provider: "cursor", + Label: "cursor", + FileName: fileName, + Storage: tokenStorage, + Metadata: metadata, + } + + savedPath, errSave := h.saveTokenRecord(ctx, record) + if errSave != nil { + log.Errorf("Failed to save cursor authentication tokens: %v", errSave) + SetOAuthSessionError(state, "Failed to save authentication tokens") + return + } + + fmt.Printf("Cursor authentication successful! Token saved to %s\n", savedPath) + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("cursor") + }() + + c.JSON(200, gin.H{ + "status": "ok", + "state": state, + "url": "cursor-agent-login", + }) +} + +func cursorTokenMetadata(storage *cursorauth.CursorTokenStorage) (map[string]any, error) { + if storage == nil { + return nil, fmt.Errorf("token storage is nil") + } + payload, errMarshal := json.Marshal(storage.TokenData) + if errMarshal != nil { + return nil, errMarshal + } + var metadata map[string]any + if errUnmarshal := json.Unmarshal(payload, &metadata); errUnmarshal != nil { + return nil, errUnmarshal + } + metadata["type"] = "cursor" + metadata["timestamp"] = time.Now().UnixMilli() + return metadata, nil +} diff --git a/internal/api/server.go b/internal/api/server.go index 2a63c97cee..0841f20a18 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -683,6 +683,7 @@ func (s *Server) registerManagementRoutes() { mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken) mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken) + mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } diff --git a/internal/auth/cursor/oauth.go b/internal/auth/cursor/oauth.go new file mode 100644 index 0000000000..66954a54ca --- /dev/null +++ b/internal/auth/cursor/oauth.go @@ -0,0 +1,251 @@ +// Package cursor provides OAuth authentication for Cursor IDE using cursor-agent login. +package cursor + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + log "github.com/sirupsen/logrus" +) + +const ( + authPollInterval = 2 * time.Second + authPollTimeout = 5 * time.Minute + urlExtractTimeout = 10 * time.Second +) + +// ansiPattern matches ANSI escape codes. +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +// loginURLPattern matches the Cursor login URL. +var loginURLPattern = regexp.MustCompile(`https://cursor\.com/loginDeepControl[^\s]*`) + +// CursorOAuth handles the OAuth flow for Cursor authentication. +type CursorOAuth struct { + cfg *config.Config +} + +// NewCursorOAuth creates a new Cursor OAuth handler. +func NewCursorOAuth(cfg *config.Config) *CursorOAuth { + return &CursorOAuth{cfg: cfg} +} + +// cursorAgentBinary returns the cursor-agent binary path. +func (c *CursorOAuth) cursorAgentBinary() string { + if c.cfg != nil && c.cfg.CursorAgentPath != "" { + return c.cfg.CursorAgentPath + } + return "cursor-agent" +} + +// Login initiates the cursor-agent login flow and waits for authentication. +func (c *CursorOAuth) Login(ctx context.Context) (*CursorTokenData, error) { + binary := c.cursorAgentBinary() + + // Spawn cursor-agent login + cmd := exec.CommandContext(ctx, binary, "login") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("cursor: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("cursor: stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("cursor: start login: %w", err) + } + + // Read stdout in background to extract login URL + stdoutCh := make(chan string, 1) + go func() { + buf := make([]byte, 8192) + var output strings.Builder + for { + n, readErr := stdout.Read(buf) + if n > 0 { + output.Write(buf[:n]) + } + if readErr != nil { + break + } + } + stdoutCh <- output.String() + }() + + // Read stderr in background + go func() { + buf := make([]byte, 4096) + for { + _, readErr := stderr.Read(buf) + if readErr != nil { + break + } + } + }() + + // Try to extract URL with polling + var loginURL string + extractStart := time.Now() + for loginURL == "" && time.Since(extractStart) < urlExtractTimeout { + select { + case rawOutput := <-stdoutCh: + loginURL = extractLoginURL(rawOutput) + if loginURL == "" { + // Put it back for later + go func() { stdoutCh <- rawOutput }() + } + case <-time.After(100 * time.Millisecond): + // Keep waiting + case <-ctx.Done(): + _ = cmd.Process.Kill() + return nil, ctx.Err() + } + } + + if loginURL != "" { + log.Infof("cursor: login URL extracted, opening browser") + fmt.Printf("Please visit: %s\n", loginURL) + if browser.IsAvailable() { + if errOpen := browser.OpenURL(loginURL); errOpen != nil { + log.Warnf("cursor: failed to open browser: %v", errOpen) + } + } + } else { + log.Warn("cursor: could not extract login URL from cursor-agent output") + } + + // Wait for cursor-agent to exit + go func() { + _ = cmd.Wait() + }() + + // Poll for auth file + log.Info("cursor: waiting for authentication...") + tokenData, err := c.pollForAuthFile(ctx) + if err != nil { + return nil, fmt.Errorf("cursor: auth polling: %w", err) + } + + return tokenData, nil +} + +// extractLoginURL strips ANSI codes, removes whitespace, and extracts the login URL. +func extractLoginURL(output string) string { + // Strip ANSI codes + clean := ansiPattern.ReplaceAllString(output, "") + // Remove all whitespace (URL may be split across lines) + clean = strings.Join(strings.Fields(clean), "") + // Extract URL + match := loginURLPattern.FindString(clean) + return match +} + +// pollForAuthFile polls for the cursor auth file and reads token data. +func (c *CursorOAuth) pollForAuthFile(ctx context.Context) (*CursorTokenData, error) { + deadline := time.Now().Add(authPollTimeout) + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + authPath, err := c.FindAuthFile() + if err == nil && authPath != "" { + tokenData, readErr := c.ReadAuthFile(authPath) + if readErr == nil { + log.Infof("cursor: auth file found at %s", authPath) + return tokenData, nil + } + log.Debugf("cursor: auth file found but unreadable: %v", readErr) + } + + time.Sleep(authPollInterval) + } + + return nil, fmt.Errorf("authentication timed out after %v", authPollTimeout) +} + +// GetPossibleAuthPaths returns all possible cursor auth file paths in priority order. +func (c *CursorOAuth) GetPossibleAuthPaths() []string { + home := os.Getenv("CURSOR_ACP_HOME_DIR") + if home == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil + } + home = homeDir + } + + authFiles := []string{"cli-config.json", "auth.json"} + var paths []string + + if runtime.GOOS == "darwin" { + for _, f := range authFiles { + paths = append(paths, filepath.Join(home, ".cursor", f)) + } + for _, f := range authFiles { + paths = append(paths, filepath.Join(home, ".config", "cursor", f)) + } + } else { + // Linux + for _, f := range authFiles { + paths = append(paths, filepath.Join(home, ".config", "cursor", f)) + } + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig != "" && xdgConfig != filepath.Join(home, ".config") { + for _, f := range authFiles { + paths = append(paths, filepath.Join(xdgConfig, "cursor", f)) + } + } + for _, f := range authFiles { + paths = append(paths, filepath.Join(home, ".cursor", f)) + } + } + + return paths +} + +// FindAuthFile returns the first existing auth file path. +func (c *CursorOAuth) FindAuthFile() (string, error) { + paths := c.GetPossibleAuthPaths() + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("no cursor auth file found") +} + +// ReadAuthFile reads and parses a cursor auth file. +func (c *CursorOAuth) ReadAuthFile(path string) (*CursorTokenData, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read auth file: %w", err) + } + + var tokenData CursorTokenData + if err := json.Unmarshal(data, &tokenData); err != nil { + return nil, fmt.Errorf("parse auth file: %w", err) + } + + if tokenData.AccessToken == "" { + return nil, fmt.Errorf("no access token in auth file") + } + + tokenData.AuthFile = path + return &tokenData, nil +} diff --git a/internal/auth/cursor/oauth_web_templates.go b/internal/auth/cursor/oauth_web_templates.go new file mode 100644 index 0000000000..3317995d5d --- /dev/null +++ b/internal/auth/cursor/oauth_web_templates.go @@ -0,0 +1,23 @@ +// Package cursor provides HTML templates for Cursor OAuth status pages. +package cursor + +// SuccessHTML is displayed after successful Cursor authentication. +const SuccessHTML = ` + +
You can close this window and return to the terminal.
+ +` + +// FailureHTML is displayed when Cursor authentication fails. +const FailureHTML = ` + +%s
+Please try again.
+ +` diff --git a/internal/auth/cursor/token.go b/internal/auth/cursor/token.go new file mode 100644 index 0000000000..e7edb1e74b --- /dev/null +++ b/internal/auth/cursor/token.go @@ -0,0 +1,35 @@ +// Package cursor provides authentication support for Cursor IDE via cursor-agent. +package cursor + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// CursorTokenData holds the parsed token from cursor auth files. +type CursorTokenData struct { + AccessToken string `json:"accessToken"` + AuthFile string `json:"-"` // path to the auth file (not serialized) +} + +// CursorTokenStorage implements TokenStorage for persisting cursor auth data. +type CursorTokenStorage struct { + TokenData CursorTokenData `json:"token_data"` +} + +// SaveTokenToFile writes the cursor token data to a JSON file. +func (s *CursorTokenStorage) SaveTokenToFile(authFilePath string) error { + if s == nil { + return fmt.Errorf("cursor token storage is nil") + } + if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { + return fmt.Errorf("cursor: create directory: %w", err) + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("cursor: marshal token: %w", err) + } + return os.WriteFile(authFilePath, data, 0600) +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index ea7a05321f..407fde95a5 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -24,6 +24,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewGitHubCopilotAuthenticator(), sdkAuth.NewKiloAuthenticator(), sdkAuth.NewGitLabAuthenticator(), + sdkAuth.NewCursorAuthenticator(), ) return manager } diff --git a/internal/config/config.go b/internal/config/config.go index f0d73c738a..d1ccc5e1aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -123,12 +123,12 @@ type Config struct { AmpCode AmpCode `yaml:"ampcode" json:"ampcode"` // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries. - // Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot. + // Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, cursor. OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"` // OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels. // These aliases affect both model listing and model routing for supported channels: - // gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot. + // gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, cursor. // // NOTE: This does not apply to existing per-credential model alias features under: // gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode. @@ -142,6 +142,10 @@ type Config struct { // from your current session. Default: false. IncognitoBrowser bool `yaml:"incognito-browser" json:"incognito-browser"` + // CursorAgentPath overrides the default cursor-agent binary path. + // If empty, the system PATH is used to locate the cursor-agent binary. + CursorAgentPath string `yaml:"cursor-agent-path,omitempty" json:"cursor-agent-path,omitempty"` + legacyMigrationPending bool `yaml:"-" json:"-"` } diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 9b7d31aab6..fd4e080d47 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -30,4 +30,7 @@ const ( // Kilo represents the Kilo AI provider identifier. Kilo = "kilo" + + // Cursor represents the Cursor IDE provider identifier. + Cursor = "cursor" ) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 8896a9dfdb..871f8aeabc 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -148,6 +148,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetAmazonQModels() case "antigravity": return GetAntigravityModels() + case "cursor": + return GetCursorModels() default: return nil } @@ -861,3 +863,29 @@ func GetAmazonQModels() []*ModelInfo { }, } } + +// GetCursorModels returns static fallback model definitions for Cursor. +// These are used when cursor-agent is not available for dynamic discovery. +func GetCursorModels() []*ModelInfo { + now := int64(1732752000) + thinkingLevels := &ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + } + return []*ModelInfo{ + {ID: "auto", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Auto", Description: "Automatic model selection"}, + {ID: "composer-1.5", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Composer 1.5", Description: "Cursor Composer 1.5"}, + {ID: "opus-4.6-thinking", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4.6 Opus (Thinking)", Description: "Claude 4.6 Opus with thinking via Cursor", Thinking: thinkingLevels}, + {ID: "opus-4.6", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4.6 Opus", Description: "Claude 4.6 Opus via Cursor"}, + {ID: "sonnet-4.6", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4.6 Sonnet", Description: "Claude 4.6 Sonnet via Cursor"}, + {ID: "sonnet-4.6-thinking", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4.6 Sonnet (Thinking)", Description: "Claude 4.6 Sonnet with thinking via Cursor", Thinking: thinkingLevels}, + {ID: "opus-4.5", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4.5 Opus", Description: "Claude 4.5 Opus via Cursor"}, + {ID: "sonnet-4.5", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4.5 Sonnet", Description: "Claude 4.5 Sonnet via Cursor"}, + {ID: "gpt-5.4-high", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "GPT-5.4 High", Description: "GPT-5.4 High via Cursor"}, + {ID: "gpt-5.3-codex", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "GPT-5.3 Codex", Description: "GPT-5.3 Codex via Cursor"}, + {ID: "gpt-5.2", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "GPT-5.2", Description: "GPT-5.2 via Cursor"}, + {ID: "gemini-3.1-pro", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Gemini 3.1 Pro", Description: "Gemini 3.1 Pro via Cursor"}, + {ID: "gemini-3-pro", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Gemini 3 Pro", Description: "Gemini 3 Pro via Cursor"}, + {ID: "grok", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Grok", Description: "Grok via Cursor"}, + {ID: "kimi-k2.5", Object: "model", Created: now, OwnedBy: "cursor", Type: "cursor", DisplayName: "Kimi K2.5", Description: "Kimi K2.5 via Cursor"}, + } +} diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go new file mode 100644 index 0000000000..08bcad9bd1 --- /dev/null +++ b/internal/runtime/executor/cursor_executor.go @@ -0,0 +1,684 @@ +// Package executor provides the Cursor executor which spawns cursor-agent +// for chat completions using NDJSON streaming. +package executor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + cursorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/cursor/common" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" +) + +const ( + cursorProviderID = "cursor" + cursorFirstTokenTimeout = 15 * time.Second + cursorStreamReadTimeout = 300 * time.Second + cursorProcessSpawnTimeout = 10 * time.Second +) + +// CursorExecutor handles requests by spawning cursor-agent processes. +type CursorExecutor struct { + cfg *config.Config +} + +// NewCursorExecutor constructs a new Cursor executor instance. +func NewCursorExecutor(cfg *config.Config) *CursorExecutor { + return &CursorExecutor{cfg: cfg} +} + +// Identifier implements ProviderExecutor. +func (e *CursorExecutor) Identifier() string { return cursorProviderID } + +// cursorAgentBinary returns the path to the cursor-agent binary. +func (e *CursorExecutor) cursorAgentBinary() string { + if e.cfg != nil && e.cfg.CursorAgentPath != "" { + return e.cfg.CursorAgentPath + } + return "cursor-agent" +} + +// HttpRequest is not directly supported for cursor-agent subprocess execution. +func (e *CursorExecutor) HttpRequest(_ context.Context, _ *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("cursor executor: request is nil") + } + return nil, statusErr{code: http.StatusNotImplemented, msg: "cursor executor does not support direct HTTP requests"} +} + +// CountTokens is not supported for Cursor. +func (e *CursorExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for cursor"} +} + +// Refresh validates cursor auth is still available by checking auth file existence. +func (e *CursorExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"} + } + // Cursor auth is file-based; nothing to refresh actively. + return auth, nil +} + +// Execute handles non-streaming requests to Cursor via cursor-agent. +func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) + defer reporter.trackFailure(ctx, &err) + + prompt, err := e.buildPrompt(req, opts) + if err != nil { + return resp, fmt.Errorf("cursor executor: build prompt: %w", err) + } + + // Spawn cursor-agent and collect all output + stdout, stderr, err := e.runCursorAgent(ctx, req.Model, prompt) + if err != nil { + if stderr != "" { + log.Debugf("cursor executor: stderr: %s", stderr) + } + return resp, fmt.Errorf("cursor executor: %w", err) + } + + // Parse NDJSON lines and accumulate text content + var content strings.Builder + var reasoningContent strings.Builder + var toolCalls []map[string]interface{} + + scanner := bufio.NewScanner(bytes.NewReader(stdout)) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 10MB max line + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + + eventType := gjson.GetBytes(line, "type").String() + switch eventType { + case "assistant": + // Extract text and thinking content from content array + contentArr := gjson.GetBytes(line, "message.content") + if contentArr.IsArray() { + for _, item := range contentArr.Array() { + itemType := item.Get("type").String() + switch itemType { + case "text": + content.WriteString(item.Get("text").String()) + case "thinking": + reasoningContent.WriteString(item.Get("thinking").String()) + } + } + } + case "tool_call": + tc := e.parseToolCallEvent(line) + if tc != nil { + toolCalls = append(toolCalls, tc) + } + case "result": + // Check for errors + if gjson.GetBytes(line, "is_error").Bool() || gjson.GetBytes(line, "subtype").String() == "error" { + errMsg := gjson.GetBytes(line, "error.message").String() + if errMsg == "" { + errMsg = "cursor-agent returned an error" + } + return resp, fmt.Errorf("cursor executor: %s", errMsg) + } + } + } + + // Build OpenAI-format response + response := buildCursorNonStreamResponse(req.Model, content.String(), reasoningContent.String(), toolCalls) + responseBytes, _ := json.Marshal(response) + + // Estimate usage from content lengths + inputChars := len(prompt) + outputChars := content.Len() + reasoningContent.Len() + detail := estimateCursorUsage(inputChars, outputChars) + if detail.TotalTokens > 0 { + reporter.publish(ctx, detail) + } + + resp = cliproxyexecutor.Response{Payload: responseBytes} + reporter.ensurePublished(ctx) + return resp, nil +} + +// ExecuteStream handles streaming requests to Cursor via cursor-agent. +func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) + defer reporter.trackFailure(ctx, &err) + + prompt, err := e.buildPrompt(req, opts) + if err != nil { + return nil, fmt.Errorf("cursor executor: build prompt: %w", err) + } + + // Start cursor-agent process + cmd, stdoutPipe, stderrPipe, err := e.startCursorAgent(ctx, req.Model, prompt) + if err != nil { + return nil, fmt.Errorf("cursor executor: start agent: %w", err) + } + + out := make(chan cliproxyexecutor.StreamChunk, 64) + + go func() { + defer close(out) + defer func() { + _ = cmd.Wait() + }() + + // Capture stderr in background + var stderrBuf strings.Builder + go func() { + _, _ = io.Copy(&stderrBuf, stderrPipe) + }() + + // State for building SSE chunks + responseID := "chatcmpl-" + uuid.New().String()[:24] + created := time.Now().Unix() + chunkIndex := 0 + toolCallIndex := 0 + hasSentFirstChunk := false + var totalOutputChars int + + scanner := bufio.NewScanner(stdoutPipe) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 10MB max line + + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + + eventType := gjson.GetBytes(line, "type").String() + + switch eventType { + case "assistant": + contentArr := gjson.GetBytes(line, "message.content") + if !contentArr.IsArray() { + continue + } + for _, item := range contentArr.Array() { + itemType := item.Get("type").String() + switch itemType { + case "text": + text := item.Get("text").String() + if text == "" { + continue + } + totalOutputChars += len(text) + chunk := buildSSETextDelta(responseID, created, req.Model, text, &hasSentFirstChunk, &chunkIndex) + out <- cliproxyexecutor.StreamChunk{Payload: chunk} + + case "thinking": + thinkText := item.Get("thinking").String() + if thinkText == "" { + continue + } + chunk := buildSSEReasoningDelta(responseID, created, req.Model, thinkText, &hasSentFirstChunk, &chunkIndex) + out <- cliproxyexecutor.StreamChunk{Payload: chunk} + } + } + + case "thinking": + subtype := gjson.GetBytes(line, "subtype").String() + if subtype == "delta" { + text := gjson.GetBytes(line, "text").String() + if text != "" { + chunk := buildSSEReasoningDelta(responseID, created, req.Model, text, &hasSentFirstChunk, &chunkIndex) + out <- cliproxyexecutor.StreamChunk{Payload: chunk} + } + } + // "completed" subtype: no-op + + case "tool_call": + tc := e.parseToolCallEvent(line) + if tc != nil { + name, _ := tc["name"].(string) + args, _ := tc["arguments"].(string) + callID, _ := tc["id"].(string) + + // Emit tool call start + startChunk := buildSSEToolCallStart(responseID, created, req.Model, callID, name, toolCallIndex, &hasSentFirstChunk, &chunkIndex) + out <- cliproxyexecutor.StreamChunk{Payload: startChunk} + + // Emit tool call arguments + if args != "" { + argsChunk := buildSSEToolCallArgs(responseID, created, req.Model, args, toolCallIndex, &chunkIndex) + out <- cliproxyexecutor.StreamChunk{Payload: argsChunk} + } + toolCallIndex++ + } + + case "result": + finishReason := "stop" + if gjson.GetBytes(line, "is_error").Bool() || gjson.GetBytes(line, "subtype").String() == "error" { + finishReason = "stop" // Still stop; error is in content + } + chunk := buildSSEFinish(responseID, created, req.Model, finishReason, &chunkIndex) + out <- cliproxyexecutor.StreamChunk{Payload: chunk} + } + } + + if scanErr := scanner.Err(); scanErr != nil { + log.Debugf("cursor executor: scanner error: %v", scanErr) + out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("cursor executor: stream read error: %w", scanErr)} + } + + // Estimate and report usage + inputChars := len(prompt) + detail := estimateCursorUsage(inputChars, totalOutputChars) + if detail.TotalTokens > 0 { + reporter.publish(ctx, detail) + } + reporter.ensurePublished(ctx) + }() + + return &cliproxyexecutor.StreamResult{Chunks: out}, nil +} + +// buildPrompt builds the text prompt from the executor request. +func (e *CursorExecutor) buildPrompt(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) { + from := opts.SourceFormat + to := sdktranslator.FromString("cursor") + + payload := bytes.Clone(req.Payload) + + // Apply thinking configuration + body, err := thinking.ApplyThinking(payload, req.Model, from.String(), "cursor", e.Identifier()) + if err != nil { + return nil, err + } + + // Translate request from source format to cursor prompt format + prompt := sdktranslator.TranslateRequest(from, to, req.Model, body, opts.Stream) + if len(prompt) == 0 { + // If no translator registered, use raw payload as prompt + prompt = body + } + + return prompt, nil +} + +// runCursorAgent runs cursor-agent synchronously and returns stdout/stderr. +func (e *CursorExecutor) runCursorAgent(ctx context.Context, model string, prompt []byte) ([]byte, string, error) { + binary := e.cursorAgentBinary() + args := []string{"--output-format", "stream-json", "--model", model} + + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Stdin = bytes.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, stderr.String(), fmt.Errorf("cursor-agent failed: %w", err) + } + + return stdout.Bytes(), stderr.String(), nil +} + +// startCursorAgent starts cursor-agent as a background process for streaming. +func (e *CursorExecutor) startCursorAgent(ctx context.Context, model string, prompt []byte) (*exec.Cmd, io.ReadCloser, io.ReadCloser, error) { + binary := e.cursorAgentBinary() + args := []string{"--output-format", "stream-json", "--model", model} + + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Stdin = bytes.NewReader(prompt) + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, nil, nil, fmt.Errorf("start cursor-agent: %w", err) + } + + return cmd, stdoutPipe, stderrPipe, nil +} + +// parseToolCallEvent extracts tool call info from a Cursor NDJSON tool_call event. +func (e *CursorExecutor) parseToolCallEvent(line []byte) map[string]interface{} { + toolCallObj := gjson.GetBytes(line, "tool_call") + if !toolCallObj.IsObject() { + return nil + } + + // The tool call is a dict with tool name as key: {"bashToolCall": {"args": {...}}} + var toolName string + var argsJSON string + toolCallObj.ForEach(func(key, value gjson.Result) bool { + rawName := key.String() + toolName = cursorcommon.ResolveToolName(rawName) + + // Extract arguments + args := value.Get("args") + if args.Exists() && args.IsObject() { + argsJSON = args.Raw + } else if value.IsObject() { + // Flat payload without args wrapper + argsJSON = value.Raw + } + return false // only process first key + }) + + if toolName == "" { + return nil + } + + callID := gjson.GetBytes(line, "call_id").String() + if callID == "" { + callID = "call_" + uuid.New().String()[:8] + } + + return map[string]interface{}{ + "id": callID, + "name": toolName, + "arguments": argsJSON, + } +} + +// FetchCursorModels retrieves available models from cursor-agent. +func FetchCursorModels(_ context.Context, _ *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + binary := "cursor-agent" + if cfg != nil && cfg.CursorAgentPath != "" { + binary = cfg.CursorAgentPath + } + + // Try cursor-agent models --json + models := tryCursorModelsJSON(binary) + if models != nil { + return models + } + + // Try cursor-agent --help + models = tryCursorModelsHelp(binary) + if models != nil { + return models + } + + // Return default fallback models + return defaultCursorModels() +} + +func tryCursorModelsJSON(binary string) []*registry.ModelInfo { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary, "models", "--json") + output, err := cmd.Output() + if err != nil { + return nil + } + + var modelIDs []string + if err := json.Unmarshal(output, &modelIDs); err != nil { + return nil + } + + var models []*registry.ModelInfo + for _, id := range modelIDs { + info := ®istry.ModelInfo{ + ID: id, + Type: cursorProviderID, + } + if strings.HasSuffix(id, "-thinking") { + info.Thinking = ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + } + } + models = append(models, info) + } + return models +} + +func tryCursorModelsHelp(binary string) []*registry.ModelInfo { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary, "--help") + output, err := cmd.Output() + if err != nil { + return nil + } + + // Parse "Available models:" section from help text + text := string(output) + idx := strings.Index(strings.ToLower(text), "available models") + if idx == -1 { + return nil + } + + remainder := text[idx:] + // Find model IDs after the label + parts := strings.SplitN(remainder, ":", 2) + if len(parts) < 2 { + return nil + } + + modelStr := strings.TrimSpace(parts[1]) + // Take first line only + if nlIdx := strings.IndexAny(modelStr, "\n\r"); nlIdx != -1 { + modelStr = modelStr[:nlIdx] + } + + ids := strings.Split(modelStr, ",") + var models []*registry.ModelInfo + for _, raw := range ids { + id := strings.TrimSpace(raw) + if id == "" { + continue + } + info := ®istry.ModelInfo{ + ID: id, + Type: cursorProviderID, + } + if strings.HasSuffix(id, "-thinking") { + info.Thinking = ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + } + } + models = append(models, info) + } + if len(models) == 0 { + return nil + } + return models +} + +func defaultCursorModels() []*registry.ModelInfo { + type modelDef struct { + id string + thinking bool + } + defs := []modelDef{ + {"auto", false}, + {"composer-1.5", false}, + {"opus-4.6-thinking", true}, + {"opus-4.6", false}, + {"sonnet-4.6", false}, + {"sonnet-4.6-thinking", true}, + {"opus-4.5", false}, + {"sonnet-4.5", false}, + {"gpt-5.4-high", false}, + {"gpt-5.3-codex", false}, + {"gpt-5.2", false}, + {"gemini-3.1-pro", false}, + {"gemini-3-pro", false}, + {"grok", false}, + {"kimi-k2.5", false}, + } + + var models []*registry.ModelInfo + for _, d := range defs { + info := ®istry.ModelInfo{ + ID: d.id, + Type: cursorProviderID, + } + if d.thinking { + info.Thinking = ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + } + } + models = append(models, info) + } + return models +} + +// --- SSE chunk builders --- + +func buildSSETextDelta(responseID string, created int64, model, text string, hasSentFirst *bool, chunkIndex *int) []byte { + delta := map[string]interface{}{"content": text} + if !*hasSentFirst { + delta["role"] = "assistant" + *hasSentFirst = true + } + chunk := buildCursorBaseChunk(responseID, created, model, delta, nil) + result, _ := json.Marshal(chunk) + *chunkIndex++ + return result +} + +func buildSSEReasoningDelta(responseID string, created int64, model, text string, hasSentFirst *bool, chunkIndex *int) []byte { + delta := map[string]interface{}{"reasoning_content": text} + if !*hasSentFirst { + delta["role"] = "assistant" + *hasSentFirst = true + } + chunk := buildCursorBaseChunk(responseID, created, model, delta, nil) + result, _ := json.Marshal(chunk) + *chunkIndex++ + return result +} + +func buildSSEToolCallStart(responseID string, created int64, model, callID, name string, toolIndex int, hasSentFirst *bool, chunkIndex *int) []byte { + tc := map[string]interface{}{ + "index": toolIndex, + "id": callID, + "type": "function", + "function": map[string]interface{}{ + "name": name, + "arguments": "", + }, + } + delta := map[string]interface{}{ + "tool_calls": []map[string]interface{}{tc}, + } + if !*hasSentFirst { + delta["role"] = "assistant" + *hasSentFirst = true + } + chunk := buildCursorBaseChunk(responseID, created, model, delta, nil) + result, _ := json.Marshal(chunk) + *chunkIndex++ + return result +} + +func buildSSEToolCallArgs(responseID string, created int64, model, args string, toolIndex int, chunkIndex *int) []byte { + tc := map[string]interface{}{ + "index": toolIndex, + "function": map[string]interface{}{ + "arguments": args, + }, + } + delta := map[string]interface{}{ + "tool_calls": []map[string]interface{}{tc}, + } + chunk := buildCursorBaseChunk(responseID, created, model, delta, nil) + result, _ := json.Marshal(chunk) + *chunkIndex++ + return result +} + +func buildSSEFinish(responseID string, created int64, model, finishReason string, chunkIndex *int) []byte { + chunk := buildCursorBaseChunk(responseID, created, model, map[string]interface{}{}, &finishReason) + result, _ := json.Marshal(chunk) + *chunkIndex++ + return result +} + +func buildCursorBaseChunk(responseID string, created int64, model string, delta map[string]interface{}, finishReason *string) map[string]interface{} { + choice := map[string]interface{}{ + "index": 0, + "delta": delta, + } + if finishReason != nil { + choice["finish_reason"] = *finishReason + } else { + choice["finish_reason"] = nil + } + return map[string]interface{}{ + "id": responseID, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": []map[string]interface{}{choice}, + } +} + +func buildCursorNonStreamResponse(model, content, reasoning string, toolCalls []map[string]interface{}) map[string]interface{} { + message := map[string]interface{}{ + "role": "assistant", + "content": content, + } + if reasoning != "" { + message["reasoning_content"] = reasoning + } + if len(toolCalls) > 0 { + message["tool_calls"] = toolCalls + } + + return map[string]interface{}{ + "id": "chatcmpl-" + uuid.New().String()[:24], + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "message": message, + "finish_reason": "stop", + }, + }, + "usage": map[string]interface{}{ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + } +} + +// estimateCursorUsage provides rough token count estimates from character counts. +// Uses a ~4 chars per token heuristic since cursor-agent doesn't report token counts. +func estimateCursorUsage(inputChars, outputChars int) usage.Detail { + inputTokens := int64(inputChars / 4) + outputTokens := int64(outputChars / 4) + return usage.Detail{ + InputTokens: inputTokens, + OutputTokens: outputTokens, + TotalTokens: inputTokens + outputTokens, + } +} diff --git a/internal/runtime/executor/thinking_providers.go b/internal/runtime/executor/thinking_providers.go index b961db9035..11660ad91f 100644 --- a/internal/runtime/executor/thinking_providers.go +++ b/internal/runtime/executor/thinking_providers.go @@ -4,6 +4,7 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/cursor" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow" diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index c79ecd8ee1..e11a9d15f0 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -19,6 +19,7 @@ var providerAppliers = map[string]ProviderApplier{ "iflow": nil, "antigravity": nil, "kimi": nil, + "cursor": nil, } // GetProviderApplier returns the ProviderApplier for the given provider name. @@ -336,6 +337,9 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig { case "kimi": // Kimi uses OpenAI-compatible reasoning_effort format return extractOpenAIConfig(body) + case "cursor": + // Cursor uses OpenAI-compatible reasoning_effort format + return extractOpenAIConfig(body) default: return ThinkingConfig{} } diff --git a/internal/thinking/provider/cursor/apply.go b/internal/thinking/provider/cursor/apply.go new file mode 100644 index 0000000000..63ed9ded7c --- /dev/null +++ b/internal/thinking/provider/cursor/apply.go @@ -0,0 +1,113 @@ +// Package cursor implements thinking configuration for Cursor IDE models. +// +// Cursor models use the reasoning_effort format with discrete levels +// (low/medium/high), following the same pattern as OpenAI. +package cursor + +import ( + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Applier implements thinking.ProviderApplier for Cursor models. +// +// Cursor-specific behavior: +// - Output format: reasoning_effort (string: low/medium/high) +// - Level-only mode: no numeric budget support +type Applier struct{} + +var _ thinking.ProviderApplier = (*Applier)(nil) + +// NewApplier creates a new Cursor thinking applier. +func NewApplier() *Applier { + return &Applier{} +} + +func init() { + thinking.RegisterProvider("cursor", NewApplier()) +} + +// Apply applies thinking configuration to Cursor request body. +// +// Expected output format: +// +// { +// "reasoning_effort": "high" +// } +func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { + if thinking.IsUserDefinedModel(modelInfo) { + return applyCompatibleCursor(body, config) + } + if modelInfo.Thinking == nil { + return body, nil + } + + // Only handle ModeLevel and ModeNone; other modes pass through unchanged. + if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone { + return body, nil + } + + if len(body) == 0 || !gjson.ValidBytes(body) { + body = []byte(`{}`) + } + + if config.Mode == thinking.ModeLevel { + result, _ := sjson.SetBytes(body, "reasoning_effort", string(config.Level)) + return result, nil + } + + effort := "" + support := modelInfo.Thinking + if config.Budget == 0 { + if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) { + effort = string(thinking.LevelNone) + } + } + if effort == "" && config.Level != "" { + effort = string(config.Level) + } + if effort == "" && len(support.Levels) > 0 { + effort = support.Levels[0] + } + if effort == "" { + return body, nil + } + + result, _ := sjson.SetBytes(body, "reasoning_effort", effort) + return result, nil +} + +func applyCompatibleCursor(body []byte, config thinking.ThinkingConfig) ([]byte, error) { + if len(body) == 0 || !gjson.ValidBytes(body) { + body = []byte(`{}`) + } + + var effort string + switch config.Mode { + case thinking.ModeLevel: + if config.Level == "" { + return body, nil + } + effort = string(config.Level) + case thinking.ModeNone: + effort = string(thinking.LevelNone) + if config.Level != "" { + effort = string(config.Level) + } + case thinking.ModeAuto: + effort = string(thinking.LevelAuto) + case thinking.ModeBudget: + level, ok := thinking.ConvertBudgetToLevel(config.Budget) + if !ok { + return body, nil + } + effort = level + default: + return body, nil + } + + result, _ := sjson.SetBytes(body, "reasoning_effort", effort) + return result, nil +} diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 85498c010c..2bb9c66201 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -44,6 +44,8 @@ func StripThinkingConfig(body []byte, provider string) []byte { } case "codex": paths = []string{"reasoning.effort"} + case "cursor": + paths = []string{"reasoning_effort"} case "iflow": paths = []string{ "chat_template_kwargs.enable_thinking", diff --git a/internal/translator/cursor/common/types.go b/internal/translator/cursor/common/types.go new file mode 100644 index 0000000000..308e3aa840 --- /dev/null +++ b/internal/translator/cursor/common/types.go @@ -0,0 +1,216 @@ +// Package common provides shared types for Cursor's NDJSON streaming format. +package common + +import ( + "encoding/json" + "strings" +) + +// ContentItem represents a content element in assistant/user messages. +type ContentItem struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` +} + +// EventMessage wraps role and content for assistant/user events. +type EventMessage struct { + Role string `json:"role"` + Content []ContentItem `json:"content"` +} + +// ToolDef represents a tool definition in system events. +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// ToolCallPayload holds the arguments or result of a tool call. +type ToolCallPayload struct { + Args map[string]interface{} `json:"args,omitempty"` + Result map[string]interface{} `json:"result,omitempty"` +} + +// ErrorDetail holds error information from result events. +type ErrorDetail struct { + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Details string `json:"details,omitempty"` +} + +// SystemEvent represents a cursor-agent system event. +type SystemEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + SessionID string `json:"session_id,omitempty"` + CWD string `json:"cwd,omitempty"` + Model string `json:"model,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + Tools []ToolDef `json:"tools,omitempty"` + Message string `json:"message,omitempty"` +} + +// UserEvent represents a user message echo event. +type UserEvent struct { + Type string `json:"type"` + Timestamp int64 `json:"timestamp,omitempty"` + SessionID string `json:"session_id,omitempty"` + Message EventMessage `json:"message"` +} + +// AssistantEvent represents an assistant response event. +type AssistantEvent struct { + Type string `json:"type"` + Timestamp int64 `json:"timestamp,omitempty"` + TimestampMs int64 `json:"timestamp_ms,omitempty"` + SessionID string `json:"session_id,omitempty"` + Message EventMessage `json:"message"` +} + +// ThinkingEvent represents a thinking/reasoning event. +type ThinkingEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Text string `json:"text,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + TimestampMs int64 `json:"timestamp_ms,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// ToolCallEvent represents a tool invocation event. +type ToolCallEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + SessionID string `json:"session_id,omitempty"` + CallID string `json:"call_id,omitempty"` + ToolCall map[string]ToolCallPayload `json:"tool_call"` +} + +// ResultEvent represents a completion/error result event. +type ResultEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + SessionID string `json:"session_id,omitempty"` + IsError bool `json:"is_error,omitempty"` + Error *ErrorDetail `json:"error,omitempty"` +} + +// ParseEventType extracts the "type" field from a JSON line without full parsing. +func ParseEventType(line []byte) string { + var raw struct { + Type string `json:"type"` + } + if err := json.Unmarshal(line, &raw); err != nil { + return "" + } + return raw.Type +} + +// ResolveToolName normalizes a cursor tool name by stripping the "ToolCall" suffix +// and resolving known aliases to canonical tool names. +func ResolveToolName(rawName string) string { + // Strip "ToolCall" suffix: "bashToolCall" -> "bash" + name := rawName + if strings.HasSuffix(name, "ToolCall") { + name = name[:len(name)-len("ToolCall")] + } + + // Lowercase for alias lookup + lower := strings.ToLower(name) + + if canonical, ok := toolNameAliases[lower]; ok { + return canonical + } + return name +} + +// toolNameAliases maps variant tool names to canonical names. +var toolNameAliases = map[string]string{ + // bash aliases + "runcommand": "bash", + "executecommand": "bash", + "runterminalcommand": "bash", + "terminalcommand": "bash", + "shellcommand": "bash", + "shell": "bash", + "terminal": "bash", + "bashcommand": "bash", + "runbash": "bash", + "executebash": "bash", + // glob aliases + "findfiles": "glob", + "searchfiles": "glob", + "globfiles": "glob", + "fileglob": "glob", + "matchfiles": "glob", + // mkdir aliases + "createdirectory": "mkdir", + "makedirectory": "mkdir", + "mkdirp": "mkdir", + "createdir": "mkdir", + "makefolder": "mkdir", + // rm aliases + "delete": "rm", + "deletefile": "rm", + "deletepath": "rm", + "deletedirectory": "rm", + "remove": "rm", + "removefile": "rm", + "removepath": "rm", + "unlink": "rm", + "rmdir": "rm", + // stat aliases + "getfileinfo": "stat", + "fileinfo": "stat", + "filestat": "stat", + "pathinfo": "stat", + // ls aliases + "listdirectory": "ls", + "listfiles": "ls", + "listdir": "ls", + "readdir": "ls", + // todowrite aliases + "updatetodos": "todowrite", + "updatetodostoolcall": "todowrite", + "todowritetoolcall": "todowrite", + "writetodos": "todowrite", + "todowritefn": "todowrite", + // todoread aliases + "readtodos": "todoread", + // write aliases + "writefile": "write", + "createfile": "write", + "writetofile": "write", + "savefile": "write", + // edit aliases + "editfile": "edit", + "modifyfile": "edit", + "updatefile": "edit", + "patchfile": "edit", + "replaceinfile": "edit", + // read aliases + "readfile": "read", + "getfile": "read", + "viewfile": "read", + "catfile": "read", + "openfile": "read", + // grep aliases + "search": "grep", + "searchcode": "grep", + "searchtext": "grep", + "searchcontent": "grep", + "ripgrep": "grep", + "rg": "grep", + // task/subagent aliases + "delegatetask": "task", + "subagent": "task", + "createtask": "task", + "launchtask": "task", + "starttask": "task", + // skill aliases + "skillmcp": "skill", + "skill_mcp": "skill", +} diff --git a/internal/translator/cursor/openai/cursor_openai_request.go b/internal/translator/cursor/openai/cursor_openai_request.go new file mode 100644 index 0000000000..0bb4135eb7 --- /dev/null +++ b/internal/translator/cursor/openai/cursor_openai_request.go @@ -0,0 +1,133 @@ +// Package openai provides request translation from OpenAI Chat Completions to Cursor prompt format. +package openai + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" +) + +// ConvertOpenAIRequestToCursor converts an OpenAI chat completion request to a text prompt +// suitable for cursor-agent stdin input. +func ConvertOpenAIRequestToCursor(model string, rawJSON []byte, stream bool) []byte { + if len(rawJSON) == 0 { + return rawJSON + } + + parsed := gjson.ParseBytes(rawJSON) + if !parsed.Exists() { + return rawJSON + } + + var lines []string + + // 1. Inject tool definitions at the top + tools := parsed.Get("tools") + if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 { + var toolDescs []string + for _, t := range tools.Array() { + fn := t.Get("function") + if !fn.Exists() { + fn = t + } + name := fn.Get("name").String() + desc := fn.Get("description").String() + params := fn.Get("parameters").Raw + if params == "" { + params = "{}" + } + if name == "" { + continue + } + toolDescs = append(toolDescs, fmt.Sprintf("- %s: %s\n Parameters: %s", name, desc, params)) + } + if len(toolDescs) > 0 { + lines = append(lines, + "SYSTEM: You have access to the following tools. When you need to use one, respond with a tool_call in the standard OpenAI format.\n"+ + "Tool guidance: prefer write/edit for file changes; use bash mainly to run commands/tests.\n\nAvailable tools:\n"+ + strings.Join(toolDescs, "\n")) + } + } + + // 2. Process messages + hasToolResults := false + messages := parsed.Get("messages") + if messages.Exists() && messages.IsArray() { + for _, msg := range messages.Array() { + role := msg.Get("role").String() + if role == "" { + role = "user" + } + + // Tool result messages + if role == "tool" { + hasToolResults = true + callID := msg.Get("tool_call_id").String() + if callID == "" { + callID = "unknown" + } + content := msg.Get("content").String() + lines = append(lines, fmt.Sprintf("TOOL_RESULT (call_id: %s): %s", callID, content)) + continue + } + + // Assistant messages with tool_calls + if role == "assistant" { + toolCalls := msg.Get("tool_calls") + if toolCalls.Exists() && toolCalls.IsArray() && len(toolCalls.Array()) > 0 { + var tcTexts []string + for _, tc := range toolCalls.Array() { + fn := tc.Get("function") + tcID := tc.Get("id").String() + if tcID == "" { + tcID = "?" + } + fnName := fn.Get("name").String() + if fnName == "" { + fnName = "?" + } + fnArgs := fn.Get("arguments").String() + if fnArgs == "" { + fnArgs = "{}" + } + tcTexts = append(tcTexts, fmt.Sprintf("tool_call(id: %s, name: %s, args: %s)", tcID, fnName, fnArgs)) + } + text := msg.Get("content").String() + prefix := "" + if text != "" { + prefix = text + "\n" + } + lines = append(lines, fmt.Sprintf("ASSISTANT: %s%s", prefix, strings.Join(tcTexts, "\n"))) + continue + } + } + + // Standard text messages + content := msg.Get("content") + if content.Type == gjson.String { + lines = append(lines, fmt.Sprintf("%s: %s", strings.ToUpper(role), content.String())) + } else if content.IsArray() { + var textParts []string + for _, part := range content.Array() { + if part.Get("type").String() == "text" { + text := part.Get("text").String() + if text != "" { + textParts = append(textParts, text) + } + } + } + if len(textParts) > 0 { + lines = append(lines, fmt.Sprintf("%s: %s", strings.ToUpper(role), strings.Join(textParts, "\n"))) + } + } + } + } + + // 3. Add continuation signal after tool results + if hasToolResults { + lines = append(lines, "The above tool calls have been executed. Continue your response based on these results.") + } + + return []byte(strings.Join(lines, "\n\n")) +} diff --git a/internal/translator/cursor/openai/cursor_openai_response.go b/internal/translator/cursor/openai/cursor_openai_response.go new file mode 100644 index 0000000000..9b44e2da72 --- /dev/null +++ b/internal/translator/cursor/openai/cursor_openai_response.go @@ -0,0 +1,257 @@ +// Package openai provides response translation from Cursor NDJSON to OpenAI SSE format. +package openai + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + cursorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/cursor/common" + "github.com/tidwall/gjson" +) + +// CursorStreamState tracks the state of streaming response conversion. +type CursorStreamState struct { + ChunkIndex int + ToolCallIndex int + HasSentFirstChunk bool + Model string + ResponseID string + Created int64 +} + +// NewCursorStreamState creates a new stream state for tracking. +func NewCursorStreamState(model string) *CursorStreamState { + return &CursorStreamState{ + Model: model, + ResponseID: "chatcmpl-" + uuid.New().String()[:24], + Created: time.Now().Unix(), + } +} + +// ConvertCursorStreamToOpenAI converts a Cursor NDJSON line to OpenAI SSE format chunks. +func ConvertCursorStreamToOpenAI(_ context.Context, model string, _, _, rawJSON []byte, param *any) []string { + if len(rawJSON) == 0 { + return nil + } + + // Initialize state on first call + if *param == nil { + *param = NewCursorStreamState(model) + } + state := (*param).(*CursorStreamState) + + line := rawJSON + eventType := gjson.GetBytes(line, "type").String() + + var results []string + + switch eventType { + case "assistant": + contentArr := gjson.GetBytes(line, "message.content") + if !contentArr.IsArray() { + return nil + } + for _, item := range contentArr.Array() { + itemType := item.Get("type").String() + switch itemType { + case "text": + text := item.Get("text").String() + if text == "" { + continue + } + chunk := buildTextDelta(state, text) + results = append(results, chunk) + case "thinking": + thinkText := item.Get("thinking").String() + if thinkText == "" { + continue + } + chunk := buildReasoningDelta(state, thinkText) + results = append(results, chunk) + } + } + + case "thinking": + subtype := gjson.GetBytes(line, "subtype").String() + if subtype == "delta" { + text := gjson.GetBytes(line, "text").String() + if text != "" { + chunk := buildReasoningDelta(state, text) + results = append(results, chunk) + } + } + // "completed" subtype: no-op + + case "tool_call": + toolCallObj := gjson.GetBytes(line, "tool_call") + if !toolCallObj.IsObject() { + return nil + } + + callID := gjson.GetBytes(line, "call_id").String() + if callID == "" { + callID = "call_" + uuid.New().String()[:8] + } + + toolCallObj.ForEach(func(key, value gjson.Result) bool { + rawName := key.String() + toolName := cursorcommon.ResolveToolName(rawName) + + // Extract arguments + var argsJSON string + args := value.Get("args") + if args.Exists() && args.IsObject() { + argsJSON = args.Raw + } else if value.IsObject() { + argsJSON = value.Raw + } + + // Emit tool call start + startChunk := buildToolCallStart(state, callID, toolName) + results = append(results, startChunk) + + // Emit tool call arguments + if argsJSON != "" { + argsChunk := buildToolCallArgsDelta(state, argsJSON) + results = append(results, argsChunk) + } + + state.ToolCallIndex++ + return false // only process first key + }) + + case "result": + finishReason := "stop" + chunk := buildFinish(state, finishReason) + results = append(results, chunk) + + // system, user events: skip + } + + return results +} + +// ConvertCursorNonStreamToOpenAI converts accumulated Cursor responses to a non-streaming OpenAI response. +func ConvertCursorNonStreamToOpenAI(_ context.Context, model string, _, _, rawJSON []byte, _ *any) string { + // For non-streaming, rawJSON is the full accumulated response + // Build a standard chat.completion response + content := string(rawJSON) + + response := map[string]interface{}{ + "id": "chatcmpl-" + uuid.New().String()[:24], + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": content, + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]interface{}{ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + } + + result, _ := json.Marshal(response) + return string(result) +} + +// --- SSE chunk builders --- + +func buildTextDelta(state *CursorStreamState, text string) string { + delta := map[string]interface{}{"content": text} + if !state.HasSentFirstChunk { + delta["role"] = "assistant" + state.HasSentFirstChunk = true + } + chunk := buildBaseChunk(state, delta, nil) + result, _ := json.Marshal(chunk) + state.ChunkIndex++ + return string(result) +} + +func buildReasoningDelta(state *CursorStreamState, text string) string { + delta := map[string]interface{}{"reasoning_content": text} + if !state.HasSentFirstChunk { + delta["role"] = "assistant" + state.HasSentFirstChunk = true + } + chunk := buildBaseChunk(state, delta, nil) + result, _ := json.Marshal(chunk) + state.ChunkIndex++ + return string(result) +} + +func buildToolCallStart(state *CursorStreamState, callID, name string) string { + tc := map[string]interface{}{ + "index": state.ToolCallIndex, + "id": callID, + "type": "function", + "function": map[string]interface{}{ + "name": name, + "arguments": "", + }, + } + delta := map[string]interface{}{ + "tool_calls": []map[string]interface{}{tc}, + } + if !state.HasSentFirstChunk { + delta["role"] = "assistant" + state.HasSentFirstChunk = true + } + chunk := buildBaseChunk(state, delta, nil) + result, _ := json.Marshal(chunk) + state.ChunkIndex++ + return string(result) +} + +func buildToolCallArgsDelta(state *CursorStreamState, args string) string { + tc := map[string]interface{}{ + "index": state.ToolCallIndex, + "function": map[string]interface{}{ + "arguments": args, + }, + } + delta := map[string]interface{}{ + "tool_calls": []map[string]interface{}{tc}, + } + chunk := buildBaseChunk(state, delta, nil) + result, _ := json.Marshal(chunk) + state.ChunkIndex++ + return string(result) +} + +func buildFinish(state *CursorStreamState, finishReason string) string { + chunk := buildBaseChunk(state, map[string]interface{}{}, &finishReason) + result, _ := json.Marshal(chunk) + state.ChunkIndex++ + return string(result) +} + +func buildBaseChunk(state *CursorStreamState, delta map[string]interface{}, finishReason *string) map[string]interface{} { + choice := map[string]interface{}{ + "index": 0, + "delta": delta, + } + if finishReason != nil { + choice["finish_reason"] = *finishReason + } else { + choice["finish_reason"] = nil + } + return map[string]interface{}{ + "id": state.ResponseID, + "object": "chat.completion.chunk", + "created": state.Created, + "model": state.Model, + "choices": []map[string]interface{}{choice}, + } +} diff --git a/internal/translator/cursor/openai/init.go b/internal/translator/cursor/openai/init.go new file mode 100644 index 0000000000..6ae2878f68 --- /dev/null +++ b/internal/translator/cursor/openai/init.go @@ -0,0 +1,20 @@ +// Package openai provides translation between OpenAI Chat Completions and Cursor formats. +package openai + +import ( + . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" +) + +func init() { + translator.Register( + OpenAI, + Cursor, + ConvertOpenAIRequestToCursor, + interfaces.TranslateResponse{ + Stream: ConvertCursorStreamToOpenAI, + NonStream: ConvertCursorNonStreamToOpenAI, + }, + ) +} diff --git a/internal/translator/init.go b/internal/translator/init.go index 0754db03b4..dd25a130c7 100644 --- a/internal/translator/init.go +++ b/internal/translator/init.go @@ -36,4 +36,6 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/claude" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/openai" + + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/cursor/openai" ) diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go index 3989e3d861..10956a1f4e 100644 --- a/internal/tui/oauth_tab.go +++ b/internal/tui/oauth_tab.go @@ -26,6 +26,7 @@ var oauthProviders = []oauthProvider{ {"Qwen", "qwen-auth-url", "🟨"}, {"Kimi", "kimi-auth-url", "🟫"}, {"IFlow", "iflow-auth-url", "⬜"}, + {"Cursor", "cursor-auth-url", "🔵"}, } // oauthTabModel handles OAuth login flows. diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go new file mode 100644 index 0000000000..6eb383628d --- /dev/null +++ b/sdk/auth/cursor.go @@ -0,0 +1,72 @@ +package auth + +import ( + "context" + "fmt" + "time" + + cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +// CursorAuthenticator implements the Authenticator interface for Cursor IDE. +type CursorAuthenticator struct{} + +// NewCursorAuthenticator constructs a new Cursor authenticator. +func NewCursorAuthenticator() Authenticator { + return &CursorAuthenticator{} +} + +// Provider returns the provider key for cursor. +func (CursorAuthenticator) Provider() string { + return "cursor" +} + +// RefreshLead returns nil since cursor tokens don't have a known refresh mechanism. +func (CursorAuthenticator) RefreshLead() *time.Duration { + return nil +} + +// Login initiates the Cursor authentication flow via cursor-agent login. +func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("cliproxy auth: configuration is required") + } + if opts == nil { + opts = &LoginOptions{} + } + + oauth := cursorauth.NewCursorOAuth(cfg) + + fmt.Println("Starting Cursor authentication...") + + tokenData, err := oauth.Login(ctx) + if err != nil { + return nil, fmt.Errorf("cursor: authentication failed: %w", err) + } + + tokenStorage := &cursorauth.CursorTokenStorage{ + TokenData: *tokenData, + } + + metadata := map[string]any{ + "type": "cursor", + "access_token": tokenData.AccessToken, + "auth_file": tokenData.AuthFile, + "timestamp": time.Now().UnixMilli(), + } + + fileName := "cursor.json" + + fmt.Println("Cursor authentication successful!") + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Label: "cursor", + Storage: tokenStorage, + Metadata: metadata, + }, nil +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index 411950aefd..6102aabeb8 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -18,6 +18,7 @@ func init() { registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() }) registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() }) registerRefreshLead("gitlab", func() Authenticator { return NewGitLabAuthenticator() }) + registerRefreshLead("cursor", func() Authenticator { return NewCursorAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index c560f71505..30fb7d3669 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -445,6 +445,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) case "gitlab": s.coreManager.RegisterExecutor(executor.NewGitLabExecutor(s.cfg)) + case "cursor": + s.coreManager.RegisterExecutor(executor.NewCursorExecutor(s.cfg)) default: providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) if providerKey == "" { @@ -954,6 +956,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "gitlab": models = executor.GitLabModelsFromAuth(a) models = applyExcludedModels(models, excluded) + case "cursor": + models = executor.FetchCursorModels(context.Background(), a, s.cfg) + models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil {