├── .dockerignore ├── .gitignore ├── src ├── constants.js ├── dev │ ├── requestLogger.js │ └── responseLogger.js ├── utils.js └── index.js ├── .envexample ├── docker-compose.yml ├── Dockerfile ├── .eslintrc.js ├── ecosystem.config.js ├── package.json ├── readme.md ├── test ├── utils.test.js └── fixtures │ ├── responses.js │ └── real-responses.js └── scripts ├── generate-test-data.js └── generate-mock-responses.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .env 4 | .dockerignore 5 | Dockerfile 6 | docker-compose.yml 7 | README.md 8 | tests 9 | *.log -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | tests/ 3 | # sslkey.log 4 | node_modules/* 5 | *.iml 6 | *.tmp 7 | *.temp 8 | *.swp 9 | *.swo 10 | *.log 11 | .idea/ -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | CACHE_TTL: 5 * 60 * 1000, // 5 minutes 3 | MAX_RETRIES: 3, 4 | RETRY_DELAY: 1000, 5 | DEFAULT_PORT: 3000, 6 | } -------------------------------------------------------------------------------- /.envexample: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | # CURSOR_CHECKSUM=youer 3 | # Development settings 4 | NODE_ENV=development 5 | LOG_RESPONSES=false 6 | 7 | # Telemetry settings (optional) 8 | MAC_MACHINE_ID= 9 | MACHINE_ID= 10 | DEVICE_ID= 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | cursor-api: 4 | image: waitkafuka/cursor-api:latest 5 | ports: 6 | - "3000:3000" 7 | environment: 8 | - NODE_ENV=production 9 | - PORT=3000 10 | - CURSOR_CHECKSUM=${CURSOR_CHECKSUM} 11 | volumes: 12 | - ./logs:/app/logs 13 | restart: always -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | # 设置工作目录 4 | WORKDIR /app 5 | 6 | # 复制package.json和package-lock.json 7 | COPY package*.json ./ 8 | 9 | # 设置环境变量 10 | ENV NODE_ENV=production 11 | ENV PORT=3000 12 | 13 | # 安装依赖 14 | RUN npm install --production 15 | 16 | # 复制源代码 17 | COPY . . 18 | 19 | # 设置适当的权限 20 | RUN chown -R node:node /app 21 | 22 | # 切换到非root用户 23 | USER node 24 | 25 | # 暴露端口 26 | EXPOSE 3000 27 | 28 | # 启动命令 29 | CMD ["node", "src/index.js"] -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true 5 | }, 6 | extends: 'standard', 7 | overrides: [ 8 | { 9 | env: { 10 | node: true 11 | }, 12 | files: [ 13 | '.eslintrc.{js,cjs}' 14 | ], 15 | parserOptions: { 16 | sourceType: 'script' 17 | } 18 | } 19 | ], 20 | parserOptions: { 21 | ecmaVersion: 'latest', 22 | sourceType: 'module' 23 | }, 24 | rules: { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [{ 3 | name: 'cursor-api', // 应用程序名称 4 | script: 'src/index.js', // 启动脚本路径 5 | instances: 1, // 实例数量 6 | autorestart: true, // 自动重启 7 | watch: false, // 文件变化监控 8 | max_memory_restart: '1G', // 内存限制重启 9 | log_date_format: 'YYYY-MM-DD HH:mm:ss', // 日志时间格式 10 | error_file: 'logs/error.log', // 错误日志路径 11 | out_file: 'logs/out.log', // 输出日志路径 12 | log_file: 'logs/combined.log', // 组合日志路径 13 | merge_logs: true, // 合并集群模式的日志 14 | rotate_interval: '1d' // 日志轮转间隔 15 | }] 16 | } 17 | -------------------------------------------------------------------------------- /src/dev/requestLogger.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const path = require('path') 3 | 4 | async function logRequest(req, timestamp = new Date().toISOString()) { 5 | const logDir = path.join(__dirname, '../../logs') 6 | const logFile = path.join(logDir, `requests-${timestamp.split('T')[0]}.log`) 7 | 8 | const logEntry = { 9 | timestamp, 10 | method: req.method, 11 | url: req.url, 12 | headers: req.headers, 13 | body: req.body, 14 | } 15 | 16 | try { 17 | await fs.mkdir(logDir, { recursive: true }) 18 | await fs.appendFile( 19 | logFile, 20 | JSON.stringify(logEntry, null, 2) + '\n---\n', 21 | 'utf8' 22 | ) 23 | } catch (err) { 24 | console.error('Error logging request:', err) 25 | } 26 | } 27 | 28 | module.exports = { logRequest } -------------------------------------------------------------------------------- /src/dev/responseLogger.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const path = require('path') 3 | 4 | async function logResponse(response) { 5 | const logDir = path.join(__dirname, '../../logs') 6 | const logFile = path.join(logDir, `responses-${new Date().toISOString().split('T')[0]}.log`) 7 | 8 | const logEntry = { 9 | timestamp: new Date().toISOString(), 10 | status: response.status, 11 | statusText: response.statusText, 12 | headers: Object.fromEntries(response.headers), 13 | ok: response.ok 14 | } 15 | 16 | try { 17 | await fs.mkdir(logDir, { recursive: true }) 18 | await fs.appendFile( 19 | logFile, 20 | JSON.stringify(logEntry, null, 2) + '\n---\n', 21 | 'utf8' 22 | ) 23 | } catch (err) { 24 | console.error('Error logging response:', err) 25 | } 26 | } 27 | 28 | module.exports = { logResponse } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cursor-api", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "axios": "1.7.7", 8 | "body-parser": "1.20.3", 9 | "compression": "^1.7.4", 10 | "dotenv": "16.4.5", 11 | "express": "4.21.1", 12 | "express-rate-limit": "^7.1.5", 13 | "uuid": "11.0.3" 14 | }, 15 | "scripts": { 16 | "start": "node src/index.js", 17 | "deploy": "git pull && yarn && pm2 restart cursor-api", 18 | "lint": "eslint src/**/*.js", 19 | "dev": "node --watch src/index.js", 20 | "test": "mocha test/**/*.test.js", 21 | "test:watch": "mocha test/**/*.test.js --watch" 22 | }, 23 | "devDependencies": { 24 | "chai": "^4.5.0", 25 | "eslint": "^8.0.1", 26 | "eslint-config-standard": "17.1.0", 27 | "eslint-plugin-import": "^2.25.2", 28 | "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", 29 | "eslint-plugin-promise": "^6.0.0", 30 | "mocha": "^10.8.2" 31 | }, 32 | "engines": { 33 | "node": ">=16.14.2" 34 | }, 35 | "engineStrict": false 36 | } 37 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # cursor-api 2 | 3 | 将 Cursor 编辑器转换为 OpenAI 兼容的 API 接口服务。 4 | 5 | ## 项目简介 6 | 7 | 本项目提供了一个代理服务,可以将 Cursor 编辑器的 AI 能力转换为与 OpenAI API 兼容的接口,让您能够在其他应用中复用 Cursor 的 AI 能力。 8 | 9 | ## 使用前准备 10 | 11 | 1. 访问 [www.cursor.com](https://www.cursor.com) 并完成注册登录(赠送 500 次快速响应,可通过删除账号再注册重置) 12 | 2. 在浏览器中打开开发者工具(F12) 13 | 3. 找到 应用-Cookies 中名为 `WorkosCursorSessionToken` 的值并保存(相当于 openai 的密钥) 14 | 15 | ## 接口说明 16 | 17 | ### 基础配置 18 | 19 | - 接口地址:`http://localhost:3000/v1/chat/completions` 20 | - 请求方法:POST 21 | - 认证方式:Bearer Token(使用 WorkosCursorSessionToken 的值,支持英文逗号分隔的 key 入参) 22 | 23 | ### 请求格式和响应格式参考 openai 24 | 25 | ## 生产环境部署 26 | 27 | ### 方式一:docker-compose 部署(推荐) 28 | 29 | ```bash 30 | docker compose up -d 31 | ``` 32 | 33 | ### 方式二:docker 部署 34 | 35 | ```bash 36 | docker run -d --name cursor-api -p 3000:3000 waitkafuka/cursor-api:latest 37 | ``` 38 | 39 | ### 方式三:pm2 部署 40 | 41 | ```bash 42 | cd cursor-api 43 | npm install 44 | pm2 start ecosystem.config.js 45 | ``` 46 | 47 | ## 本地开发 48 | 49 | ```bash 50 | cd cursor-api 51 | npm install 52 | npm run dev 53 | ``` 54 | 55 | ## 注意事项 56 | 57 | - 请妥善保管您的 WorkosCursorSessionToken,不要泄露给他人 58 | - 本项目仅供学习研究使用,请遵守 Cursor 的使用条款 59 | 60 | ## 原始项目 61 | 62 | - 本项目基于 [cursorToApi](https://github.com/luolazyandlazy/cursorToApi) 项目进行优化,感谢原作者的贡献 63 | 64 | ## 许可证 65 | 66 | MIT License 67 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai') 2 | const { chunkToUtf8String } = require('../src/utils') 3 | const responses = require('./fixtures/real-responses') 4 | 5 | describe('Utils Tests', () => { 6 | describe('chunkToUtf8String', () => { 7 | it('should handle resource exhausted error', () => { 8 | const result = chunkToUtf8String(responses.response1.buffer) 9 | expect(result).to.include('Error:') 10 | expect(result).to.include('Too many free trials') 11 | }) 12 | 13 | it('should handle another resource exhausted error', () => { 14 | const result = chunkToUtf8String(responses.response2.buffer) 15 | expect(result).to.include('Error:') 16 | expect(result).to.include('Too many free trials') 17 | }) 18 | 19 | it('should handle unauthenticated error', () => { 20 | const result = chunkToUtf8String(responses.response3.buffer) 21 | expect(result).to.include('Error:') 22 | expect(result).to.include('Not logged in') 23 | }) 24 | 25 | it('should handle null or empty chunk', () => { 26 | expect(chunkToUtf8String(null)).to.equal('') 27 | expect(chunkToUtf8String(Buffer.from([]))).to.equal('') 28 | }) 29 | 30 | it('should handle non-0x00-0x00 chunks', () => { 31 | const chunk = Buffer.from([0x01, 0x02, 0x03]) 32 | expect(chunkToUtf8String(chunk)).to.equal('') 33 | }) 34 | 35 | it('should handle invalid JSON in chunk', () => { 36 | const chunk = Buffer.from([0x00, 0x00, 0x7B, 0x7D]) // "{}" 37 | expect(chunkToUtf8String(chunk)).to.equal('') 38 | }) 39 | }) 40 | }) -------------------------------------------------------------------------------- /scripts/generate-test-data.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | function generateTestData() { 5 | const logDir = path.join(__dirname, '../logs/responses') 6 | const outputFile = path.join(__dirname, '../test/fixtures/real-responses.js') 7 | 8 | // 读取所有日志文件 9 | const files = fs.readdirSync(logDir).filter(f => f.endsWith('.log')) 10 | const responses = [] 11 | 12 | for (const file of files) { 13 | const content = fs.readFileSync(path.join(logDir, file), 'utf8') 14 | try { 15 | const logData = JSON.parse(content) 16 | responses.push({ 17 | type: getResponseType(logData), 18 | buffer: logData.buffer, 19 | text: logData.text, 20 | status: logData.status 21 | }) 22 | } catch (e) { 23 | console.error(`Error processing ${file}:`, e) 24 | } 25 | } 26 | 27 | // 生成测试数据文件 28 | const testData = `// 自动生成的测试数据 29 | module.exports = { 30 | ${responses.map((resp, index) => ` 31 | // ${resp.type} response 32 | response${index + 1}: { 33 | type: '${resp.type}', 34 | buffer: Buffer.from('${resp.buffer}', 'hex'), 35 | status: ${resp.status}, 36 | text: ${JSON.stringify(resp.text)} 37 | }`).join(',\n')} 38 | } 39 | ` 40 | fs.writeFileSync(outputFile, testData) 41 | console.log(`Generated test data in ${outputFile}`) 42 | } 43 | 44 | function getResponseType(logData) { 45 | const { status, text } = logData 46 | 47 | if (status !== 200) return 'error' 48 | if (text.includes('{"error":')) return 'jsonError' 49 | if (text.includes('')) return 'htmlError' 50 | if (text.includes('"choices":')) return 'normal' 51 | return 'unknown' 52 | } 53 | 54 | generateTestData() -------------------------------------------------------------------------------- /test/fixtures/responses.js: -------------------------------------------------------------------------------- 1 | // 测试数据 2 | module.exports = { 3 | // 正常响应场景 4 | normalResponse: Buffer.from([ 5 | 0x00, 0x00, 0x00, 0x00, 0x0A, // 头部 6 | // 正常的 markdown 文本响应 7 | 0x43, 0x61, 0x74, 0x47, 0x50, 0x54, 0x20, 0x77, 0x6F, 0x72, 0x6B, 0x73, 0x20, 0x6C, 0x69, 0x6B, 0x65, 0x20, 0x74, 0x68, 0x69, 0x73, 0x3A, 0x0A, 0x0A, 8 | // ### 基本步骤 9 | 0x23, 0x23, 0x23, 0x20, 0xE5, 0x9F, 0xBA, 0xE6, 0x9C, 0xAC, 0xE6, 0xAD, 0xA5, 0xE9, 0xAA, 0xA4, 0x0A, 0x0A, 10 | // 1. **预训练**: 11 | 0x31, 0x2E, 0x20, 0x2A, 0x2A, 0xE9, 0xA2, 0x84, 0xE8, 0xAE, 0xAD, 0xE7, 0xBB, 0x83, 0x2A, 0x2A, 0x3A, 0x0A 12 | ]), 13 | 14 | // JSON 格式的正常响应 15 | jsonResponse: Buffer.from(JSON.stringify({ 16 | id: "chatcmpl-123", 17 | object: "chat.completion", 18 | created: 1677858242, 19 | model: "gpt-3.5-turbo-0613", 20 | choices: [{ 21 | index: 0, 22 | message: { 23 | role: "assistant", 24 | content: "Here's how markdown works:\n\n### Headers\n\n# H1\n## H2\n### H3\n\n### Lists\n\n- Item 1\n- Item 2" 25 | }, 26 | finish_reason: "stop" 27 | }] 28 | })), 29 | 30 | // 错误响应场景 31 | errorResponse: Buffer.from(JSON.stringify({ 32 | error: { 33 | message: "Invalid API key", 34 | type: "invalid_request_error", 35 | code: "invalid_api_key" 36 | } 37 | })), 38 | 39 | // HTML 错误响应 40 | htmlErrorResponse: Buffer.from(` 41 | 42 | 502 Bad Gateway 43 | 44 |

502 Bad Gateway

45 | 46 | 47 | `), 48 | 49 | // 包含特殊字符的响应 50 | specialCharsResponse: Buffer.from([ 51 | 0x00, 0x00, // 头部 52 | // 包含特殊字符和控制字符的文本 53 | 0x46, 0x44, 0x39, 0x78, 0x2E, 0x24, 0x68, 0x51, // FD9x.$hQ 54 | // 正常的 markdown 文本 55 | 0x23, 0x23, 0x23, 0x20, 0xE6, 0xB5, 0x8B, 0xE8, 0xAF, 0x95 // ### 测试 56 | ]) 57 | } -------------------------------------------------------------------------------- /scripts/generate-mock-responses.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | // 创建日志目录 5 | const logDir = path.join(__dirname, '../logs/responses') 6 | if (!fs.existsSync(logDir)) { 7 | fs.mkdirSync(logDir, { recursive: true }) 8 | } 9 | 10 | // 模拟响应数据 11 | const mockResponses = [ 12 | // 正常响应 13 | { 14 | status: 200, 15 | headers: { 16 | 'content-type': 'application/json' 17 | }, 18 | buffer: Buffer.from([ 19 | 0x00, 0x00, 0x00, 0x00, 0x0A, // 头部 20 | // 正常的 markdown 文本 21 | ...Buffer.from(` 22 | ### 基本概念 23 | 24 | 1. **预训练**: 25 | - 使用大规模数据 26 | - 学习语言模型 27 | - 掌握基础知识 28 | 29 | 2. **微调**: 30 | - 特定任务优化 31 | - 提升表现效果 32 | `).values() 33 | ]).toString('hex'), 34 | text: '### 基本概念\n\n1. **预训练**:\n- 使用大规模数据\n- 学习语言模型\n- 掌握基础知识\n\n2. **微调**:\n- 特定任务优化\n- 提升表现效果' 35 | }, 36 | 37 | // JSON 格式响应 38 | { 39 | status: 200, 40 | headers: { 41 | 'content-type': 'application/json' 42 | }, 43 | text: JSON.stringify({ 44 | id: 'chatcmpl-123', 45 | object: 'chat.completion', 46 | created: Date.now(), 47 | choices: [{ 48 | message: { 49 | role: 'assistant', 50 | content: '这是一个测试响应' 51 | } 52 | }] 53 | }), 54 | buffer: Buffer.from('{"choices":[{"message":{"content":"这是一个测试响应"}}]}').toString('hex') 55 | }, 56 | 57 | // 错误响应 58 | { 59 | status: 401, 60 | headers: { 61 | 'content-type': 'application/json' 62 | }, 63 | text: JSON.stringify({ 64 | error: { 65 | message: 'Invalid API key', 66 | type: 'invalid_request_error' 67 | } 68 | }), 69 | buffer: Buffer.from('{"error":{"message":"Invalid API key"}}').toString('hex') 70 | }, 71 | 72 | // HTML 错误响应 73 | { 74 | status: 502, 75 | headers: { 76 | 'content-type': 'text/html' 77 | }, 78 | text: ` 79 | 80 | 502 Bad Gateway 81 | 82 |

502 Bad Gateway

83 | 84 | 85 | `, 86 | buffer: Buffer.from('502 Bad Gateway').toString('hex') 87 | }, 88 | 89 | // 特殊字符响应 90 | { 91 | status: 200, 92 | headers: { 93 | 'content-type': 'text/plain' 94 | }, 95 | buffer: Buffer.from([ 96 | 0x00, 0x00, // 头部 97 | ...Buffer.from('FD9x.$hQ ### 测试\n\n- 项目1\n- 项目2').values() 98 | ]).toString('hex'), 99 | text: 'FD9x.$hQ ### 测试\n\n- 项目1\n- 项目2' 100 | } 101 | ] 102 | 103 | // 生成模拟日志文件 104 | mockResponses.forEach((response, index) => { 105 | const timestamp = new Date(Date.now() - index * 1000).toISOString().replace(/[:.]/g, '-') 106 | const filename = path.join(logDir, `response-${timestamp}.log`) 107 | 108 | const logData = { 109 | timestamp, 110 | ...response 111 | } 112 | 113 | fs.writeFileSync(filename, JSON.stringify(logData, null, 2)) 114 | console.log(`Generated mock response: ${filename}`) 115 | }) -------------------------------------------------------------------------------- /test/fixtures/real-responses.js: -------------------------------------------------------------------------------- 1 | // 自动生成的测试数据 2 | module.exports = { 3 | 4 | // unknown response 5 | response1: { 6 | type: 'unknown', 7 | buffer: Buffer.from('02000002f47b226572726f72223a7b22636f6465223a227265736f757263655f657868617573746564222c226d657373616765223a224572726f72222c2264657461696c73223a5b7b2274797065223a2261697365727665722e76312e4572726f7244657461696c73222c226465627567223a7b226572726f72223a224552524f525f435553544f4d5f4d455353414745222c2264657461696c73223a7b227469746c65223a22546f6f206d616e79206672656520747269616c732e222c2264657461696c223a22546f6f206d616e79206672656520747269616c206163636f756e74732075736564206f6e2074686973206d616368696e652e20506c65617365205b7570677261646520746f2070726f5d2868747470733a2f2f7777772e637572736f722e636f6d2f70726963696e67292e20576520686176652074686973206c696d697420696e20706c61636520746f2070726576656e742061627573652e20506c65617365206c6574207573206b6e6f7720696620796f752062656c6965766520746869732069732061206d697374616b652e227d2c2269734578706563746564223a747275657d2c2276616c7565223a22434230533641454b46565276627942745957353549475a795a57556764484a705957787a4c684c4f41565276627942745957353549475a795a57556764484a705957776759574e6a6233567564484d6764584e6c5a4342766269423061476c7a4947316859326870626d5575494642735a57467a5a5342626458426e636d466b5a53423062794277636d39644b4768306448427a4f693876643364334c6d4e31636e4e766369356a6232307663484a7059326c755a796b754946646c49476868646d556764476870637942736157317064434270626942776247466a5a53423062794277636d56325a5735304947466964584e6c4c6942516247566863325567624756304948567a4947747562336367615759676557393149474a6c62476c6c646d5567644768706379427063794268494731706333526861325575474145227d5d7d7d', 'hex'), 8 | status: 200, 9 | text: "Unable to parse response text" 10 | }, 11 | 12 | // unknown response 13 | response2: { 14 | type: 'unknown', 15 | buffer: Buffer.from('02000002f47b226572726f72223a7b22636f6465223a227265736f757263655f657868617573746564222c226d657373616765223a224572726f72222c2264657461696c73223a5b7b2274797065223a2261697365727665722e76312e4572726f7244657461696c73222c226465627567223a7b226572726f72223a224552524f525f435553544f4d5f4d455353414745222c2264657461696c73223a7b227469746c65223a22546f6f206d616e79206672656520747269616c732e222c2264657461696c223a22546f6f206d616e79206672656520747269616c206163636f756e74732075736564206f6e2074686973206d616368696e652e20506c65617365205b7570677261646520746f2070726f5d2868747470733a2f2f7777772e637572736f722e636f6d2f70726963696e67292e20576520686176652074686973206c696d697420696e20706c61636520746f2070726576656e742061627573652e20506c65617365206c6574207573206b6e6f7720696620796f752062656c6965766520746869732069732061206d697374616b652e227d2c2269734578706563746564223a747275657d2c2276616c7565223a22434230533641454b46565276627942745957353549475a795a57556764484a705957787a4c684c4f41565276627942745957353549475a795a57556764484a705957776759574e6a6233567564484d6764584e6c5a4342766269423061476c7a4947316859326870626d5575494642735a57467a5a5342626458426e636d466b5a53423062794277636d39644b4768306448427a4f693876643364334c6d4e31636e4e766369356a6232307663484a7059326c755a796b754946646c49476868646d556764476870637942736157317064434270626942776247466a5a53423062794277636d56325a5735304947466964584e6c4c6942516247566863325567624756304948567a4947747562336367615759676557393149474a6c62476c6c646d5567644768706379427063794268494731706333526861325575474145227d5d7d7d', 'hex'), 16 | status: 200, 17 | text: "Unable to parse response text" 18 | }, 19 | 20 | // unknown response 21 | response3: { 22 | type: 'unknown', 23 | buffer: Buffer.from('02000001ab7b226572726f72223a7b22636f6465223a22756e61757468656e74696361746564222c226d657373616765223a224572726f72222c2264657461696c73223a5b7b2274797065223a2261697365727665722e76312e4572726f7244657461696c73222c226465627567223a7b226572726f72223a224552524f525f4e4f545f4c4f474745445f494e222c2264657461696c73223a7b227469746c65223a224e6f74206c6f6767656420696e2e222c2264657461696c223a224e6f74206c6f6767656420696e2e20496620796f7520617265206c6f6767656420696e2c20747279206c6f6767696e67206f757420616e64206261636b20696e2e222c226973526574727961626c65223a66616c73657d2c2269734578706563746564223a747275657d2c2276616c7565223a224341495356516f4f546d3930494778765a32646c5a4342706269345351553576644342736232646e5a5751676157347549456c6d49486c7664534268636d55676247396e5a32566b49476c754c434230636e6b676247396e5a326c755a794276645851675957356b49474a685932736761573475494141594151227d5d7d7d', 'hex'), 24 | status: 200, 25 | text: "Unable to parse response text" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // 添加 uuid 引入 2 | const { v4: uuidv4 } = require('uuid') 3 | 4 | // 优化的工具函数 5 | const FIXED_SUFFIX = Buffer.from( 6 | '10016A2432343163636435662D393162612D343131382D393239612D3936626330313631626432612' + 7 | '2002A132F643A2F6964656150726F2F656475626F73733A1E0A', 8 | 'hex' 9 | ) 10 | 11 | function calculateMessageLength(byteLength, modelNameLength) { 12 | const FIXED_HEADER = 2 13 | const SEPARATOR = 1 14 | const FIXED_SUFFIX_LENGTH = 0xA3 + modelNameLength 15 | 16 | // 计算长度字段大小 17 | const textLengthFieldSize1 = byteLength < 128 ? 1 : 2 18 | const baseLength = byteLength + 0x2A 19 | const textLengthFieldSize = baseLength < 128 ? 1 : 2 20 | 21 | return FIXED_HEADER + textLengthFieldSize + SEPARATOR + 22 | textLengthFieldSize1 + byteLength + FIXED_SUFFIX_LENGTH 23 | } 24 | 25 | function stringToHex(str, modelName) { 26 | console.log('Converting string to hex. Input length:', str.length, 'Model:', modelName) 27 | 28 | const bytes = Buffer.from(str, 'utf-8') 29 | const byteLength = bytes.length 30 | const modelNameBuffer = Buffer.from(modelName, 'utf-8') 31 | const modelNameLength = modelNameBuffer.length 32 | 33 | console.log('Calculated lengths:', { 34 | byteLength, 35 | modelNameLength 36 | }) 37 | 38 | // 预计算总长度 39 | const messageTotalLength = calculateMessageLength(byteLength, modelNameLength) 40 | console.log('Total message length:', messageTotalLength) 41 | 42 | // 预分配buffer 43 | const result = Buffer.alloc(messageTotalLength * 2) 44 | let offset = 0 45 | 46 | // 写入消息长度 47 | offset += result.write(messageTotalLength.toString(16).padStart(10, '0'), offset, 'hex') 48 | 49 | // 写入固定头部 50 | offset += result.write('12', offset, 'hex') 51 | 52 | // 写入文本长度字段 53 | const baseLength = byteLength + 0x2A 54 | if (baseLength < 128) { 55 | offset += result.write(baseLength.toString(16).padStart(2, '0'), offset, 'hex') 56 | } else { 57 | const lowByte = (baseLength & 0x7F) | 0x80 58 | const highByte = (baseLength >> 7) & 0xFF 59 | offset += result.write( 60 | lowByte.toString(16).padStart(2, '0') + 61 | highByte.toString(16).padStart(2, '0'), 62 | offset, 63 | 'hex' 64 | ) 65 | } 66 | 67 | // 写入分隔符和文本内容 68 | offset += result.write('0A', offset, 'hex') 69 | if (byteLength < 128) { 70 | offset += result.write(byteLength.toString(16).padStart(2, '0'), offset, 'hex') 71 | } else { 72 | const lowByte = (byteLength & 0x7F) | 0x80 73 | const highByte = (byteLength >> 7) & 0xFF 74 | offset += result.write( 75 | lowByte.toString(16).padStart(2, '0') + 76 | highByte.toString(16).padStart(2, '0'), 77 | offset, 78 | 'hex' 79 | ) 80 | } 81 | 82 | // 写入消息内容 83 | offset += bytes.copy(result, offset) 84 | 85 | // 写入固定后缀 86 | offset += FIXED_SUFFIX.copy(result, offset) 87 | 88 | // 写入模型名称 89 | offset += result.write( 90 | modelNameLength.toString(16).padStart(2, '0').toUpperCase() + 91 | modelNameBuffer.toString('hex').toUpperCase(), 92 | offset, 93 | 'hex' 94 | ) 95 | 96 | // 写入剩余固定内容 97 | offset += result.write( 98 | '22004A24' + 99 | '61383761396133342D323164642D343863372D623434662D616636633365636536663765' + 100 | '680070007A2436393337376535612D386332642D343835342D623564392D653062623232336163303061' + 101 | '800101B00100C00100E00100E80100', 102 | offset, 103 | 'hex' 104 | ) 105 | 106 | return result 107 | } 108 | 109 | function chunkToUtf8String(chunk) { 110 | if (!chunk?.length) { 111 | return '' 112 | } 113 | 114 | // 只处理以 0x00 0x00 开头的 chunk,其他不处理,不然会有乱码 115 | if (!(chunk[0] === 0x00 && chunk[1] === 0x00)) { 116 | try { 117 | const rawText = Buffer.from(chunk).toString('utf-8') 118 | 119 | // 检查是否是流结束的错误消息 120 | if (rawText.includes('protocol error: received extra input message for server-streaming method')) { 121 | return '' // 忽略流结束的错误消息 122 | } 123 | 124 | // 尝试解析JSON响应 125 | const jsonMatch = rawText.match(/\{[\s\S]*\}/m) 126 | if (jsonMatch) { 127 | try { 128 | const jsonStr = jsonMatch[0].trim() 129 | const jsonData = JSON.parse(jsonStr) 130 | if (jsonData.error) { 131 | const details = jsonData.error.details?.[0]?.debug?.details 132 | if (details) { 133 | return `Error: ${details.title || details.detail || jsonData.error.message}` 134 | } 135 | return `Error: ${jsonData.error.message || jsonData.error.code || 'Unknown error'}` 136 | } 137 | } catch (e) { 138 | console.debug('JSON parse error:', e.message) 139 | } 140 | } 141 | return '' 142 | } catch (e) { 143 | console.error('Error parsing response:', e) 144 | return 'Error: Failed to parse response' 145 | } 146 | } 147 | 148 | console.debug('chunk hex:', Buffer.from(chunk).toString('hex')) 149 | console.debug('chunk string:', Buffer.from(chunk).toString('utf-8')) 150 | 151 | // 去掉 chunk 中 0x0A 以及之前的字符 152 | chunk = chunk.slice(chunk.indexOf(0x0A) + 1) 153 | 154 | let filteredChunk = [] 155 | let i = 0 156 | while (i < chunk.length) { 157 | // 新的条件��滤:如果遇到连续4个0x00,则移除其之后所有的以 0 开头的字节(0x00 到 0x0F) 158 | if (chunk.slice(i, i + 4).every(byte => byte === 0x00)) { 159 | i += 4 // 跳过这4个0x00 160 | while (i < chunk.length && chunk[i] >= 0x00 && chunk[i] <= 0x0F) { 161 | i++ // 跳过所有以 0 开头的字节 162 | } 163 | continue 164 | } 165 | 166 | if (chunk[i] === 0x0C) { 167 | // 遇到 0x0C 时,跳过 0x0C 以及后续的所有连续的 0x0A 168 | i++ // 跳过 0x0C 169 | while (i < chunk.length && chunk[i] === 0x0A) { 170 | i++ // 跳过所有连续的 0x0A 171 | } 172 | } else if ( 173 | i > 0 && 174 | chunk[i] === 0x0A && 175 | chunk[i - 1] >= 0x00 && 176 | chunk[i - 1] <= 0x09 177 | ) { 178 | // 如果当前字节是 0x0A,且前一个字节在 0x00 至 0x09 之间,跳过前一个字节和当前字节 179 | filteredChunk.pop() // 移除已添加的前一个字节 180 | i++ // 跳过当前的 0x0A 181 | } else { 182 | filteredChunk.push(chunk[i]) 183 | i++ 184 | } 185 | } 186 | 187 | // 第二步:去除所有的 0x00 和 0x0C 188 | filteredChunk = filteredChunk.filter((byte) => byte !== 0x00 && byte !== 0x0C) 189 | 190 | // 去除小于 0x0A 的字节 191 | filteredChunk = filteredChunk.filter((byte) => byte >= 0x0A) 192 | 193 | const result = Buffer.from(filteredChunk) 194 | console.debug('hex result:', result.toString('hex')) 195 | console.debug('utf8 result:', result.toString('utf-8')) 196 | 197 | return result.toString('utf-8') 198 | } 199 | 200 | function generateRandomString(length) { 201 | const chars = 'abcdef0123456789' 202 | const result = new Array(length) 203 | for (let i = 0; i < length; i++) { 204 | result[i] = chars.charAt(Math.floor(Math.random() * chars.length)) 205 | } 206 | return result.join('') 207 | } 208 | 209 | function generateChecksum() { 210 | const prefix = 'zo' 211 | const firstPart = generateRandomString(70) 212 | const separator = '/' 213 | const secondPart = generateRandomString(64) 214 | return `${prefix}${firstPart}${separator}${secondPart}` 215 | } 216 | 217 | function generateHex64() { 218 | const chars = '0123456789abcdef' 219 | let result = '' 220 | for (let i = 0; i < 64; i++) { 221 | result += chars[Math.floor(Math.random() * chars.length)] 222 | } 223 | return result 224 | } 225 | 226 | function generateDeviceId() { 227 | const uuid = uuidv4() 228 | return uuid.toLowerCase() // 确保是小写 229 | } 230 | 231 | function generateTelemetryIds() { 232 | return { 233 | macMachineId: generateHex64(), 234 | machineId: generateHex64(), 235 | deviceId: generateDeviceId() 236 | } 237 | } 238 | 239 | module.exports = { 240 | stringToHex, 241 | chunkToUtf8String, 242 | generateChecksum, 243 | calculateMessageLength, 244 | generateTelemetryIds, 245 | } 246 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const { v4: uuidv4 } = require('uuid') 3 | const { stringToHex, chunkToUtf8String, generateChecksum, generateTelemetryIds } = require('./utils.js') 4 | require('dotenv').config() 5 | const compression = require('compression') 6 | const rateLimit = require('express-rate-limit') 7 | const { logRequest } = require('./dev/requestLogger') 8 | const { CACHE_TTL } = require('./constants') 9 | 10 | // 仅在开发环境引入 11 | const responseLogger = process.env.NODE_ENV === 'development' ? 12 | require('./dev/responseLogger') : 13 | { logResponse: response => response } 14 | 15 | const app = express() 16 | 17 | // 中间件配置 18 | app.use(express.json({ limit: '1mb' })) 19 | app.use(express.urlencoded({ extended: true, limit: '1mb' })) 20 | app.use(compression({ 21 | level: 6, 22 | threshold: 1024 23 | })) 24 | 25 | // 错误处理中间件 26 | app.use((err, req, res, next) => { 27 | console.error('Error:', err) 28 | res.status(500).json({ error: 'Internal server error' }) 29 | }) 30 | 31 | // 配置常量 32 | const PORT = process.env.PORT || 3000 33 | const MAX_RETRIES = 3 34 | const RETRY_DELAY = 1000 35 | const CURSOR_CHECKSUM = process.env.CURSOR_CHECKSUM || generateChecksum() 36 | const TELEMETRY = { 37 | macMachineId: process.env.MAC_MACHINE_ID || generateTelemetryIds().macMachineId, 38 | machineId: process.env.MACHINE_ID || generateTelemetryIds().machineId, 39 | deviceId: process.env.DEVICE_ID || generateTelemetryIds().deviceId 40 | } 41 | 42 | // 添加用户信息遥测参数 43 | const USER_AGENT = 'connect-es/1.4.0' 44 | const CLIENT_VERSION = '0.42.3' 45 | const TIMEZONE = 'Asia/Shanghai' 46 | const GHOST_MODE = 'false' 47 | 48 | // 请求缓存 49 | const responseCache = new Map() 50 | 51 | // 性能指标 52 | const metrics = { 53 | requestCount: 0, 54 | errorCount: 0, 55 | avgResponseTime: 0, 56 | lastRequestTime: 0 57 | } 58 | 59 | // 速率限制 60 | const limiter = rateLimit({ 61 | windowMs: 15 * 60 * 1000, 62 | max: 100 63 | }) 64 | 65 | // 路由处理 66 | app.get('/health', (req, res) => { 67 | res.status(200).json({ status: 'ok' }) 68 | }) 69 | 70 | app.get('/metrics', (req, res) => { 71 | res.json(metrics) 72 | }) 73 | 74 | app.use('/v1/chat/completions', limiter) 75 | 76 | app.post('/v1/chat/completions', async (req, res) => { 77 | await logRequest(req) 78 | const startTime = Date.now() 79 | metrics.requestCount++ 80 | 81 | try { 82 | const response = await handleChatRequest(req, res) 83 | updateMetrics(startTime) 84 | return response 85 | } catch (error) { 86 | metrics.errorCount++ 87 | handleError(error, req, res) 88 | } 89 | }) 90 | 91 | // 核心请求处理函数 92 | async function handleChatRequest(req, res) { 93 | console.log('Handling chat request:', { 94 | model: req.body.model, 95 | messageCount: req.body.messages?.length, 96 | stream: req.body.stream 97 | }) 98 | 99 | const { model, messages, stream, authToken } = validateRequest(req) 100 | console.log('Request validated, auth token:', authToken.substring(0, 20) + '...') 101 | 102 | if (!stream) { 103 | const cached = checkCache(getCacheKey(req)) 104 | if (cached) { 105 | console.log('Cache hit, returning cached response') 106 | return res.json(cached) 107 | } 108 | } 109 | 110 | console.log('Making API request...') 111 | const response = await makeRequest(req, authToken) 112 | console.log('API response received:', { 113 | status: response.status, 114 | ok: response.ok, 115 | headers: Object.fromEntries(response.headers) 116 | }) 117 | 118 | return stream ? 119 | handleStreamResponse(response, req, res) : 120 | handleNormalResponse(response, req, res) 121 | } 122 | 123 | // 请求验证 124 | function validateRequest(req) { 125 | const { model, messages, stream = false } = req.body 126 | let authToken = req.headers.authorization?.replace('Bearer ', '') 127 | 128 | if (model.startsWith('o1-') && stream) { 129 | console.log('Model not supported stream:', model) 130 | throw new Error('Model not supported stream') 131 | } 132 | 133 | // 处理逗号分隔的密钥 134 | const keys = authToken ? authToken.split(',').map(key => key.trim()) : [] 135 | console.log('Available keys count:', keys.length) 136 | 137 | if (keys.length > 0) { 138 | authToken = keys[0] 139 | console.log('Using key:', authToken.substring(0, 10) + '...') 140 | } 141 | 142 | if (authToken && authToken.includes('%3A%3A')) { 143 | authToken = authToken.split('%3A%3A')[1] 144 | console.log('Token contains separator, using second part') 145 | } 146 | 147 | if (!messages || !Array.isArray(messages) || messages.length === 0 || !authToken) { 148 | console.error('Validation failed:', { 149 | hasMessages: !!messages, 150 | isArray: Array.isArray(messages), 151 | messagesLength: messages?.length, 152 | hasToken: !!authToken 153 | }) 154 | throw new Error('Invalid request. Messages should be a non-empty array and authorization is required') 155 | } 156 | 157 | return { model, messages, stream, authToken } 158 | } 159 | 160 | // API请求函数 161 | async function makeRequest(req, authToken) { 162 | console.log('Preparing request...') 163 | const { model, messages } = req.body 164 | 165 | console.log('Formatting messages...') 166 | const formattedMessages = messages.map(msg => `${msg.role}:${msg.content}`).join('\n') 167 | console.log('Formatted messages:', formattedMessages) 168 | 169 | console.log('Converting to hex...') 170 | const hexData = stringToHex(formattedMessages, model) 171 | console.log('Hex data length:', hexData.length) 172 | 173 | // 构建请求头 174 | const headers = { 175 | 'Content-Type': 'application/connect+proto', 176 | 'authorization': `Bearer ${authToken}`, 177 | 'connect-accept-encoding': 'gzip,br', 178 | 'connect-protocol-version': '1', 179 | 'user-agent': USER_AGENT, 180 | 'x-amzn-trace-id': `Root=${uuidv4()}`, 181 | 'x-cursor-checksum': CURSOR_CHECKSUM, 182 | 'x-cursor-client-version': CLIENT_VERSION, 183 | 'x-cursor-timezone': TIMEZONE, 184 | 'x-ghost-mode': GHOST_MODE, 185 | 'x-request-id': uuidv4(), 186 | 'x-mac-machine-id': TELEMETRY.macMachineId, 187 | 'x-machine-id': TELEMETRY.machineId, 188 | 'x-device-id': TELEMETRY.deviceId, 189 | 'Host': 'api2.cursor.sh', 190 | 'Content-Length': hexData.length.toString() 191 | } 192 | 193 | console.log('Request headers:', { 194 | ...headers, 195 | authorization: headers.authorization.substring(0, 20) + '...' 196 | }) 197 | 198 | console.log('Sending request to API...') 199 | return fetchWithRetry('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat', { 200 | method: 'POST', 201 | headers, 202 | body: hexData 203 | }) 204 | } 205 | 206 | // 重试机制 207 | async function fetchWithRetry(url, options, retries = MAX_RETRIES) { 208 | try { 209 | const response = await fetch(url, options) 210 | 211 | // 使用日志记录器,它会自动处理是否记录 212 | await responseLogger.logResponse(response) 213 | 214 | // 检查响应状态 215 | if (!response.ok) { 216 | const errorText = await response.text() 217 | console.error('Cursor API error response:', { 218 | status: response.status, 219 | statusText: response.statusText, 220 | errorText 221 | }) 222 | throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`) 223 | } 224 | 225 | return response 226 | } catch (error) { 227 | if (retries > 0) { 228 | console.log(`Retrying request (${retries} attempts remaining). Error:`, error.message) 229 | await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)) 230 | return fetchWithRetry(url, options, retries - 1) 231 | } 232 | throw error 233 | } 234 | } 235 | 236 | // 流式响应处理 237 | async function handleStreamResponse(response, req, res) { 238 | if (!response.body) throw new Error('Empty response body') 239 | 240 | res.setHeader('Content-Type', 'text/event-stream') 241 | res.setHeader('Cache-Control', 'no-cache') 242 | res.setHeader('Connection', 'keep-alive') 243 | 244 | const responseId = `chatcmpl-${uuidv4()}` 245 | 246 | try { 247 | for await (const chunk of response.body) { 248 | const text = chunkToUtf8String(chunk) 249 | if (text.length > 0) { 250 | const streamResponse = formatStreamResponse(text, responseId, req.body.model) 251 | res.write(`data: ${JSON.stringify(streamResponse)}\n\n`) 252 | } 253 | } 254 | res.write('data: [DONE]\n\n') 255 | res.end() 256 | } catch (error) { 257 | console.error('Stream error:', error) 258 | throw error 259 | } 260 | } 261 | 262 | // 普通响应处理 263 | async function handleNormalResponse(response, req, res) { 264 | let text = '' 265 | 266 | try { 267 | for await (const chunk of response.body) { 268 | const chunkText = chunkToUtf8String(chunk) 269 | if (chunkText.length > 0) text += chunkText 270 | } 271 | 272 | if (!text) throw new Error('Empty response') 273 | 274 | text = text.replace(/^.*<\|END_USER\|>/s, '').replace(/^\n[a-zA-Z]?/, '').trim() 275 | 276 | const result = formatNormalResponse(text, req.body.model) 277 | cacheResponse(getCacheKey(req), result) 278 | return res.json(result) 279 | } catch (error) { 280 | console.error('Response error:', error) 281 | throw error 282 | } 283 | } 284 | 285 | // 响应格式化 286 | function formatStreamResponse(text, responseId, model) { 287 | return { 288 | id: responseId, 289 | object: 'chat.completion.chunk', 290 | created: Math.floor(Date.now() / 1000), 291 | model, 292 | choices: [{ 293 | index: 0, 294 | delta: { content: text } 295 | }] 296 | } 297 | } 298 | 299 | function formatNormalResponse(text, model) { 300 | return { 301 | id: `chatcmpl-${uuidv4()}`, 302 | object: 'chat.completion', 303 | created: Math.floor(Date.now() / 1000), 304 | model, 305 | choices: [{ 306 | index: 0, 307 | message: { 308 | role: 'assistant', 309 | content: text 310 | }, 311 | finish_reason: 'stop' 312 | }], 313 | usage: { 314 | prompt_tokens: 0, 315 | completion_tokens: 0, 316 | total_tokens: 0 317 | } 318 | } 319 | } 320 | 321 | // 缓存相关 322 | function getCacheKey(req) { 323 | return `${req.body.model}-${JSON.stringify(req.body.messages)}` 324 | } 325 | 326 | function checkCache(key) { 327 | const cached = responseCache.get(key) 328 | if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 329 | return cached.data 330 | } 331 | return null 332 | } 333 | 334 | function cacheResponse(key, data) { 335 | responseCache.set(key, { 336 | timestamp: Date.now(), 337 | data 338 | }) 339 | } 340 | 341 | // 定期清理过期缓存 342 | setInterval(() => { 343 | const now = Date.now() 344 | for (const [key, value] of responseCache) { 345 | if (now - value.timestamp > CACHE_TTL) { 346 | responseCache.delete(key) 347 | } 348 | } 349 | }, 60000) 350 | 351 | // 错误处理 352 | function handleError(error, req, res) { 353 | console.error('Request failed:', { 354 | error: error.message, 355 | stack: error.stack, 356 | body: req.body, 357 | headers: req.headers, 358 | checksum: CURSOR_CHECKSUM, 359 | clientVersion: CLIENT_VERSION, 360 | timezone: TIMEZONE, 361 | telemetry: TELEMETRY 362 | }) 363 | 364 | let errorMessage = error.message 365 | if (error.message.includes('Not logged in')) { 366 | errorMessage = 'Authentication failed. Please check your token.' 367 | } 368 | 369 | const errorResponse = { 370 | error: { 371 | message: errorMessage, 372 | type: 'cursor_api_error' 373 | } 374 | } 375 | 376 | if (req.body.stream) { 377 | res.write(`data: ${JSON.stringify(errorResponse)}\n\n`) 378 | res.end() 379 | } else { 380 | res.status(401).json(errorResponse) 381 | } 382 | } 383 | 384 | function handleFatalError(err) { 385 | console.error('Fatal error:', { 386 | message: err.message, 387 | stack: err.stack, 388 | name: err.name 389 | }) 390 | process.exit(1) 391 | } 392 | 393 | // 性能指标更新 394 | function updateMetrics(startTime) { 395 | const requestTime = Date.now() - startTime 396 | metrics.avgResponseTime = (metrics.avgResponseTime * (metrics.requestCount - 1) + requestTime) / metrics.requestCount 397 | metrics.lastRequestTime = requestTime 398 | } 399 | 400 | // 优雅关闭 401 | async function gracefulShutdown() { 402 | console.log('Shutting down gracefully...') 403 | // 清理资源、关闭连接等 404 | process.exit(0) 405 | } 406 | 407 | // 启动服务器 408 | if (!module.parent) { 409 | app.listen(PORT, '0.0.0.0', (err) => { 410 | if (err) { 411 | console.error('Error starting server:', err) 412 | process.exit(1) 413 | } 414 | console.log(`Server running on port ${PORT}`) 415 | console.log('Using checksum:', CURSOR_CHECKSUM.substring(0, 10) + '...') 416 | console.log('Using telemetry IDs:', { 417 | macMachineId: TELEMETRY.macMachineId.substring(0, 10) + '...', 418 | machineId: TELEMETRY.machineId.substring(0, 10) + '...', 419 | deviceId: TELEMETRY.deviceId 420 | }) 421 | }) 422 | } 423 | 424 | module.exports = app 425 | --------------------------------------------------------------------------------