mirror of
https://github.com/zeke-chin/cursor-api.git
synced 2025-09-27 03:55:58 +08:00
update
This commit is contained in:
26
.eslintrc.js
Normal file
26
.eslintrc.js
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
tests/
|
16
ecosystem.config.js
Normal file
16
ecosystem.config.js
Normal 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
25
package.json
Normal 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
45
readme.md
Normal 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
144
src/index.js
Normal 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
84
src/utils.js
Normal 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
|
||||
}
|
Reference in New Issue
Block a user