├── .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 |
--------------------------------------------------------------------------------