This commit is contained in:
左康生
2024-11-23 20:47:57 +08:00
commit e3d4b39899
8 changed files with 2351 additions and 0 deletions

26
.eslintrc.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: 'standard',
overrides: [
{
env: {
node: true
},
files: [
'.eslintrc.{js,cjs}'
],
parserOptions: {
sourceType: 'script'
}
}
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
}
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
tests/

16
ecosystem.config.js Normal file
View File

@@ -0,0 +1,16 @@
module.exports = {
apps: [{
name: 'cursor-api', // 应用程序名称
script: 'src/index.js', // 启动脚本路径
instances: 1, // 实例数量
autorestart: true, // 自动重启
watch: false, // 文件变化监控
max_memory_restart: '1G', // 内存限制重启
log_date_format: 'YYYY-MM-DD HH:mm:ss', // 日志时间格式
error_file: 'logs/error.log', // 错误日志路径
out_file: 'logs/out.log', // 输出日志路径
log_file: 'logs/combined.log', // 组合日志路径
merge_logs: true, // 合并集群模式的日志
rotate_interval: '1d' // 日志轮转间隔
}]
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "cursor-api",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"axios": "1.7.7",
"body-parser": "1.20.3",
"eventsource-parser": "3.0.0",
"express": "4.21.1",
"uuid": "11.0.3"
},
"scripts": {
"start": "node src/index.js",
"lint": "eslint src/**/*.js",
"dev": "node --tls-keylog=./sslkey.log --watch src/index.js"
},
"devDependencies": {
"eslint": "^8.0.1",
"eslint-config-standard": "17.1.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
"eslint-plugin-promise": "^6.0.0"
}
}

45
readme.md Normal file
View File

@@ -0,0 +1,45 @@
# cursor-api
将 Cursor 编辑器转换为 OpenAI 兼容的 API 接口服务。
## 项目简介
本项目提供了一个代理服务,可以将 Cursor 编辑器的 AI 能力转换为与 OpenAI API 兼容的接口,让您能够在其他应用中复用 Cursor 的 AI 能力。
## 使用前准备
1. 访问 [www.cursor.com](https://www.cursor.com) 并完成注册登录赠送500次快速响应可通过删除账号再注册重置
2. 在浏览器中打开开发者工具F12
3. 找到 应用-Cookies 中名为 `WorkosCursorSessionToken` 的值并保存(相当于openai的密钥)
## 接口说明
### 基础配置
- 接口地址:`http://localhost:3000/v1/chat/completions`
- 请求方法POST
- 认证方式Bearer Token使用 WorkosCursorSessionToken 的值支持英文逗号分隔的key入参
### 请求格式和响应格式参考openai
## 快速开始
1. 克隆项目
- git clone https://github.com/waitkafuka/cursor-api.git
- cd cursor-api
2. 安装依赖
- yarn
3. 启动服务
- yarn dev开发环境
- yarn start生产环境方式一
- pm2 start ecosystem.config.js生产环境方式二
## 注意事项
- 请妥善保管您的 WorkosCursorSessionToken不要泄露给他人
- 本项目仅供学习研究使用,请遵守 Cursor 的使用条款
## 许可证
MIT License

144
src/index.js Normal file
View File

@@ -0,0 +1,144 @@
const express = require('express')
const { v4: uuidv4 } = require('uuid')
const { stringToHex, chunkToUtf8String } = require('./utils.js')
const app = express()
// 中间件配置
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.post('/v1/chat/completions', async (req, res) => {
// o1开头的模型不支持流式输出
if (req.body.model.startsWith('o1-') && req.body.stream) {
return res.status(400).json({
error: 'Model not supported stream'
})
}
let currentKeyIndex = 0
try {
const { model, messages, stream = false } = req.body
let authToken = req.headers.authorization?.replace('Bearer ', '')
// 处理逗号分隔的密钥
const keys = authToken.split(',').map(key => key.trim())
if (keys.length > 0) {
// 确保 currentKeyIndex 不会越界
if (currentKeyIndex >= keys.length) {
currentKeyIndex = 0
}
// 使用当前索引获取密钥
authToken = keys[currentKeyIndex]
// 更新索引
currentKeyIndex = (currentKeyIndex + 1)
}
if (authToken && authToken.includes('%3A%3A')) {
authToken = authToken.split('%3A%3A')[1]
}
if (!messages || !Array.isArray(messages) || messages.length === 0 || !authToken) {
return res.status(400).json({
error: 'Invalid request. Messages should be a non-empty array and authorization is required'
})
}
const formattedMessages = messages.map(msg => `${msg.role}:${msg.content}`).join('\n')
const hexData = stringToHex(formattedMessages, model)
const response = await fetch('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat', {
method: 'POST',
headers: {
'Content-Type': 'application/connect+proto',
authorization: `Bearer ${authToken}`,
'connect-accept-encoding': 'gzip,br',
'connect-protocol-version': '1',
'user-agent': 'connect-es/1.4.0',
'x-amzn-trace-id': `Root=${uuidv4()}`,
'x-cursor-checksum': 'zo6Qjequ9b9734d1f13c3438ba25ea31ac93d9287248b9d30434934e9fcbfa6b3b22029e/7e4af391f67188693b722eff0090e8e6608bca8fa320ef20a0ccb5d7d62dfdef',
'x-cursor-client-version': '0.42.3',
'x-cursor-timezone': 'Asia/Shanghai',
'x-ghost-mode': 'false',
'x-request-id': uuidv4(),
Host: 'api2.cursor.sh'
},
body: hexData
})
if (stream) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
const responseId = `chatcmpl-${uuidv4()}`
// 使用封装的函数处理 chunk
for await (const chunk of response.body) {
let text = ''
text += chunkToUtf8String(chunk)
console.log('stream text:', text)
if (text.length > 0) {
res.write(`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [{
index: 0,
delta: {
content: text
}
}]
})}\n\n`)
}
}
res.write('data: [DONE]\n\n')
return res.end()
} else {
let text = ''
// 在非流模式下也使用封装的函数
for await (const chunk of response.body) {
text += chunkToUtf8String(chunk)
}
// 对解析后的字符串进行进一步处理
text = text.replace(/^.*<\|END_USER\|>/s, '')
text = text.replace(/^\n[a-zA-Z]?/, '').trim()
console.log(text)
return res.json({
id: `chatcmpl-${uuidv4()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model,
choices: [{
index: 0,
message: {
role: 'assistant',
content: text
},
finish_reason: 'stop'
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
})
}
} catch (error) {
console.error('Error:', error)
if (!res.headersSent) {
if (req.body.stream) {
res.write(`data: ${JSON.stringify({ error: 'Internal server error' })}\n\n`)
return res.end()
} else {
return res.status(500).json({ error: 'Internal server error' })
}
}
}
})
// 启动服务器
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT}`)
})

84
src/utils.js Normal file
View File

@@ -0,0 +1,84 @@
// Helper function to convert string to hex bytes
function stringToHex (str, modelName) {
const bytes = Buffer.from(str, 'utf-8')
const byteLength = bytes.length
// Calculate lengths and fields similar to Python version
const FIXED_HEADER = 2
const SEPARATOR = 1
const FIXED_SUFFIX_LENGTH = 0xA3 + modelName.length
// 计算文本长度字段 (类似 Python 中的 base_length1)
let textLengthField1, textLengthFieldSize1
if (byteLength < 128) {
textLengthField1 = byteLength.toString(16).padStart(2, '0')
textLengthFieldSize1 = 1
} else {
const lowByte1 = (byteLength & 0x7F) | 0x80
const highByte1 = (byteLength >> 7) & 0xFF
textLengthField1 = lowByte1.toString(16).padStart(2, '0') + highByte1.toString(16).padStart(2, '0')
textLengthFieldSize1 = 2
}
// 计算基础长度 (类似 Python 中的 base_length)
const baseLength = byteLength + 0x2A
let textLengthField, textLengthFieldSize
if (baseLength < 128) {
textLengthField = baseLength.toString(16).padStart(2, '0')
textLengthFieldSize = 1
} else {
const lowByte = (baseLength & 0x7F) | 0x80
const highByte = (baseLength >> 7) & 0xFF
textLengthField = lowByte.toString(16).padStart(2, '0') + highByte.toString(16).padStart(2, '0')
textLengthFieldSize = 2
}
// 计算总消息长度
const messageTotalLength = FIXED_HEADER + textLengthFieldSize + SEPARATOR +
textLengthFieldSize1 + byteLength + FIXED_SUFFIX_LENGTH
const messageLengthHex = messageTotalLength.toString(16).padStart(10, '0')
// 构造完整的十六进制字符串
const hexString = (
messageLengthHex +
'12' +
textLengthField +
'0A' +
textLengthField1 +
bytes.toString('hex') +
'10016A2432343163636435662D393162612D343131382D393239612D3936626330313631626432612' +
'2002A132F643A2F6964656150726F2F656475626F73733A1E0A' +
// 将模型名称长度转换为两位十六进制,并确保是大写
Buffer.from(modelName, 'utf-8').length.toString(16).padStart(2, '0').toUpperCase() +
Buffer.from(modelName, 'utf-8').toString('hex').toUpperCase() +
'22004A' +
'24' + '61383761396133342D323164642D343863372D623434662D616636633365636536663765' +
'680070007A2436393337376535612D386332642D343835342D623564392D653062623232336163303061' +
'800101B00100C00100E00100E80100'
).toUpperCase()
return Buffer.from(hexString, 'hex')
}
// 封装的函数,用于将 chunk 转换为 UTF-8 字符串
function chunkToUtf8String (chunk) {
if (chunk[0] === 0x01 || chunk[0] === 0x02) {
return ''
}
// 去掉 chunk 中 0x0a 以及之前的字符
chunk = chunk.slice(chunk.indexOf(0x0a) + 1)
let hexString = Buffer.from(chunk).toString('hex')
console.log('hexString:', hexString)
// 去除里面所有这样的字符0 跟着一个数字然后 0a去除掉换页符 0x0c
hexString = hexString.replace(/0\d0a/g, '').replace(/0c/g, '')
console.log('hexString2:', hexString)
const utf8String = Buffer.from(hexString, 'hex').toString('utf-8')
console.log('utf8String:', utf8String)
return utf8String
}
module.exports = {
stringToHex,
chunkToUtf8String
}

2009
yarn.lock Normal file

File diff suppressed because it is too large Load Diff