feat: 重构项目为标准 Go 结构

- 重构为标准 Go 项目结构 (cmd/server, internal/)
- 配置改为 YAML 格式
- 添加 Anthropic Messages API 支持
- 添加 OpenAI Chat API 支持
- 浏览器自动化处理人机验证
- 添加详细中文注释
- 添加免责声明
This commit is contained in:
江西小徐
2025-12-16 19:50:35 +08:00
commit 7b83db9dda
15 changed files with 1775 additions and 0 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

9
.idea/cursor2api.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/cursor2api.iml" filepath="$PROJECT_DIR$/.idea/cursor2api.iml" />
</modules>
</component>
</project>

140
README.md Normal file
View File

@@ -0,0 +1,140 @@
# Cursor2API
将 Cursor API 转换为 OpenAI/Anthropic 兼容格式的代理服务。
## 原理
本项目利用 [Cursor 文档页面](https://cursor.com/cn/docs) 提供的免费 AI 聊天功能。该页面内置了一个 AI 助手,通过 `https://cursor.com/api/chat` 接口与后端通信。
**关键特点:**
- **无需登录** - 文档页面的 AI 聊天功能对所有访问者开放
- **无需 API Key** - 不需要 Cursor 账号或付费订阅
- **支持多模型** - 可使用 Claude、GPT、Gemini 等模型
本项目通过浏览器自动化技术访问该页面,将请求转发到 Cursor API并将响应转换为标准的 OpenAI/Anthropic API 格式。
## 功能特性
- **Anthropic Messages API** - 完整支持 `/v1/messages` 接口
- **OpenAI Chat API** - 支持 `/v1/chat/completions` 接口
- **流式响应** - 支持 SSE 流式输出
- **浏览器自动化** - 自动处理人机验证
## 项目结构
```
cursor2api/
├── cmd/server/ # 程序入口
│ └── main.go
├── internal/ # 内部包
│ ├── browser/ # 浏览器自动化服务
│ ├── config/ # 配置管理
│ └── handler/ # HTTP 处理器
├── static/ # 静态文件
├── config.yaml # 配置文件
└── README.md
```
## 快速开始
```bash
# 安装依赖
go mod tidy
# 编译
go build -o cursor2api ./cmd/server
# 运行
./cursor2api
```
服务默认运行在 `http://localhost:3010`
## 配置
编辑 `config.yaml`
```yaml
# 服务端口
port: 3010
# 浏览器设置
browser:
headless: true
path: "/usr/bin/chromium"
```
支持环境变量 `PORT` 覆盖端口配置。
## API 接口
### Anthropic Messages API
```bash
curl http://localhost:3010/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: any" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'
```
### OpenAI Chat API
```bash
curl http://localhost:3010/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'
```
### 其他接口
- `GET /v1/models` - 获取模型列表
- `GET /health` - 健康检查
- `GET /browser/status` - 浏览器状态
## Claude Code 集成
```bash
# 设置 API 地址
export ANTHROPIC_BASE_URL=http://localhost:3010
# 运行 Claude Code
claude
```
## 支持的模型
| 请求模型 | 映射到 Cursor |
|---------|--------------|
| claude-* | anthropic/claude-sonnet-4.5 |
| gpt-* | openai/gpt-5-nano |
| gemini-* | google/gemini-2.5-flash |
## 依赖
- Go 1.21+
- Chromium 浏览器
## 免责声明
本项目仅供学习和研究目的使用。
- 本项目是一个非官方的第三方工具,与 Cursor 官方无任何关联
- 使用本项目可能违反 Cursor 的服务条款,请自行承担风险
- 本项目不提供任何形式的担保,包括但不限于适销性、特定用途适用性
- 作者不对使用本项目造成的任何直接或间接损失负责
- 请勿将本项目用于商业用途或任何违法活动
使用本项目即表示您已阅读并同意以上声明。
## 许可证
MIT

64
cmd/server/main.go Normal file
View File

@@ -0,0 +1,64 @@
// Cursor2API - 将 Cursor API 转换为 OpenAI/Anthropic 兼容格式
//
// 本项目通过浏览器自动化技术调用 Cursor 的 AI 接口,
// 并将其转换为标准的 OpenAI 和 Anthropic API 格式,
// 使得各种 AI 客户端可以直接使用 Cursor 的服务。
package main
import (
"log"
"cursor2api/internal/browser"
"cursor2api/internal/config"
"cursor2api/internal/handler"
"github.com/gin-gonic/gin"
)
func main() {
// 加载配置
cfg := config.Get()
// 初始化浏览器服务
log.Println("[启动] 正在初始化浏览器服务...")
browser.GetService()
// 创建 Gin 引擎
r := gin.Default()
// ==================== 路由配置 ====================
// OpenAI 兼容接口
r.GET("/v1/models", handler.ListModels)
r.POST("/v1/chat/completions", handler.ChatCompletions)
// Anthropic Messages API 兼容接口
r.POST("/v1/messages", handler.Messages)
r.POST("/messages", handler.Messages)
r.POST("/v1/messages/count_tokens", handler.CountTokens)
r.POST("/messages/count_tokens", handler.CountTokens)
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 浏览器状态
r.GET("/browser/status", func(c *gin.Context) {
svc := browser.GetService()
hasToken := svc.GetXIsHuman() != ""
c.JSON(200, gin.H{"hasToken": hasToken})
})
// 静态文件
r.Static("/static", "./static")
r.GET("/", func(c *gin.Context) {
c.File("./static/index.html")
})
// 启动服务
log.Printf("[启动] 服务运行在端口 %s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("[错误] 启动失败: %v", err)
}
}

12
config.yaml Normal file
View File

@@ -0,0 +1,12 @@
# Cursor2API 配置文件
# 将 Cursor API 转换为 OpenAI/Anthropic 兼容格式
# 服务端口
port: 3010
# 浏览器设置
browser:
# 是否使用无头模式
headless: true
# Chromium 可执行文件路径
path: "/usr/bin/chromium"

47
go.mod Normal file
View File

@@ -0,0 +1,47 @@
module cursor2api
go 1.24
toolchain go1.24.11
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-rod/rod v0.116.2
github.com/google/uuid v1.4.0
github.com/ysmood/gson v0.7.3
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)

109
go.sum Normal file
View File

@@ -0,0 +1,109 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

313
internal/browser/browser.go Normal file
View File

@@ -0,0 +1,313 @@
// Package browser 提供基于 Chromium 的浏览器自动化服务
// 用于绕过 Cursor API 的人机验证X-Is-Human token
package browser
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
"cursor2api/internal/config"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/ysmood/gson"
)
// Service 浏览器服务,管理浏览器实例和请求
type Service struct {
browser *rod.Browser // 浏览器实例
page *rod.Page // 当前页面
xIsHuman string // X-Is-Human token
mu sync.RWMutex // 读写锁
lastFetch time.Time // 上次获取 token 时间
}
var (
instance *Service
once sync.Once
)
// GetService 获取浏览器服务单例
func GetService() *Service {
once.Do(func() {
instance = &Service{}
instance.init()
})
return instance
}
// init 初始化浏览器实例
func (s *Service) init() {
cfg := config.Get()
// 创建临时用户数据目录
userDataDir := os.Getenv("BROWSER_USER_DATA_DIR")
if userDataDir == "" {
userDataDir = fmt.Sprintf("/tmp/cursor2api-browser-%d", time.Now().UnixNano())
}
// 配置浏览器启动参数
l := launcher.New().
Bin(cfg.Browser.Path).
Headless(cfg.Browser.Headless).
Set("disable-blink-features", "AutomationControlled"). // 隐藏自动化特征
Set("no-sandbox").
Set("disable-gpu").
Set("disable-dev-shm-usage").
Set("no-proxy-server"). // 浏览器不使用代理
UserDataDir(userDataDir)
u := l.MustLaunch()
s.browser = rod.New().ControlURL(u).MustConnect()
}
// RefreshToken 刷新 X-Is-Human token
func (s *Service) RefreshToken() error {
s.mu.Lock()
defer s.mu.Unlock()
// 关闭旧页面
if s.page != nil {
s.page.Close()
}
s.page = s.browser.MustPage()
// 设置 User-Agent
s.page.MustSetUserAgent(&proto.NetworkSetUserAgentOverride{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
})
// 隐藏 webdriver 特征
s.page.MustEvalOnNewDocument(`Object.defineProperty(navigator, 'webdriver', {get: () => false})`)
// 监听请求,捕获 token
var capturedToken string
router := s.page.HijackRequests()
router.MustAdd("*/api/chat*", func(ctx *rod.Hijack) {
headers := ctx.Request.Headers()
if token, ok := headers["x-is-human"]; ok {
capturedToken = token.String()
}
ctx.ContinueRequest(&proto.FetchContinueRequest{})
})
go router.Run()
// 访问 Cursor 文档页面
if err := s.page.Navigate("https://cursor.com/cn/docs"); err != nil {
router.Stop()
return fmt.Errorf("导航失败: %w", err)
}
s.page.MustWaitLoad()
time.Sleep(5 * time.Second)
// 尝试触发聊天请求
askBtn, err := s.page.Timeout(10 * time.Second).Element(`button:has-text("询问"), button:has-text("Ask"), [data-testid="ask-ai"]`)
if err != nil {
askBtn, err = s.page.Timeout(5 * time.Second).Element(`textarea, input[type="text"]`)
}
if askBtn != nil {
askBtn.Click(proto.InputMouseButtonLeft, 1)
time.Sleep(1 * time.Second)
askBtn.Input("hi")
time.Sleep(500 * time.Millisecond)
s.page.Keyboard.Press(13)
}
// 等待请求被捕获
time.Sleep(8 * time.Second)
router.Stop()
if capturedToken != "" {
s.xIsHuman = capturedToken
s.lastFetch = time.Now()
}
return nil
}
// GetXIsHuman 获取当前 X-Is-Human token
func (s *Service) GetXIsHuman() string {
s.mu.RLock()
defer s.mu.RUnlock()
// Token 过期检查30 分钟刷新)
if time.Since(s.lastFetch) > 30*time.Minute && s.xIsHuman != "" {
go s.RefreshToken()
}
return s.xIsHuman
}
// CursorChatRequest Cursor API 请求格式
type CursorChatRequest struct {
Context []CursorContext `json:"context"`
Model string `json:"model"`
ID string `json:"id"`
Messages []CursorMessage `json:"messages"`
Trigger string `json:"trigger"`
}
// CursorContext 上下文信息
type CursorContext struct {
Type string `json:"type"`
Content string `json:"content"`
FilePath string `json:"filePath"`
}
// CursorMessage 消息格式
type CursorMessage struct {
Parts []CursorPart `json:"parts"`
ID string `json:"id"`
Role string `json:"role"`
}
// CursorPart 消息内容
type CursorPart struct {
Type string `json:"type"`
Text string `json:"text"`
}
// SendRequest 发送聊天请求(非流式)
func (s *Service) SendRequest(req CursorChatRequest) (string, error) {
if s.browser == nil {
return "", fmt.Errorf("浏览器未初始化")
}
// 创建新页面
page := s.browser.MustPage()
defer page.Close()
// 设置浏览器特征
page.MustSetUserAgent(&proto.NetworkSetUserAgentOverride{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
})
page.MustEvalOnNewDocument(`Object.defineProperty(navigator, 'webdriver', {get: () => false})`)
// 导航到 Cursor
page.MustNavigate("https://cursor.com/cn/docs").MustWaitLoad()
reqJSON, _ := json.Marshal(req)
// 使用 JavaScript 发送请求
script := fmt.Sprintf(`() => {
return new Promise((resolve, reject) => {
fetch('https://cursor.com/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(%s)
})
.then(response => {
if (!response.ok) {
return response.text().then(text => reject(new Error(text)));
}
return response.text();
})
.then(text => resolve(text))
.catch(err => reject(err));
});
}`, string(reqJSON))
result, err := page.Timeout(90 * time.Second).Evaluate(rod.Eval(script).ByPromise())
if err != nil {
return "", fmt.Errorf("执行失败: %w", err)
}
return result.Value.String(), nil
}
// SendStreamRequest 发送流式聊天请求
func (s *Service) SendStreamRequest(req CursorChatRequest, onChunk func(chunk string)) error {
if s.browser == nil {
return fmt.Errorf("浏览器未初始化")
}
// 创建新页面
page := s.browser.MustPage()
defer page.Close()
// 设置浏览器特征
page.MustSetUserAgent(&proto.NetworkSetUserAgentOverride{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
})
page.MustEvalOnNewDocument(`Object.defineProperty(navigator, 'webdriver', {get: () => false})`)
// 暴露回调函数给 JavaScript
done := make(chan error, 1)
page.MustExpose("goStreamCallback", func(j gson.JSON) (interface{}, error) {
onChunk(j.String())
return "ok", nil
})
page.MustExpose("goStreamDone", func(j gson.JSON) (interface{}, error) {
errMsg := j.String()
if errMsg != "" {
done <- fmt.Errorf("%s", errMsg)
} else {
done <- nil
}
return "ok", nil
})
// 导航到 Cursor
page.MustNavigate("https://cursor.com/cn/docs").MustWaitLoad()
reqJSON, _ := json.Marshal(req)
// 使用 JavaScript 发送流式请求
script := fmt.Sprintf(`() => {
fetch('https://cursor.com/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(%s)
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
window.goStreamDone(text);
throw new Error(text);
});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({done, value}) => {
if (done) {
window.goStreamDone("");
return;
}
const chunk = decoder.decode(value, {stream: true});
window.goStreamCallback(chunk);
read();
}).catch(err => {
window.goStreamDone(err.message);
});
}
read();
})
.catch(err => {
window.goStreamDone(err.message);
});
}`, string(reqJSON))
if _, err := page.Evaluate(rod.Eval(script)); err != nil {
return fmt.Errorf("执行失败: %w", err)
}
// 等待流结束
select {
case err := <-done:
return err
case <-time.After(90 * time.Second):
return fmt.Errorf("请求超时")
}
}

69
internal/config/config.go Normal file
View File

@@ -0,0 +1,69 @@
// Package config 提供配置文件加载和管理功能
package config
import (
"log"
"os"
"sync"
"gopkg.in/yaml.v3"
)
// Config 应用配置结构
type Config struct {
// Port 服务监听端口
Port string `yaml:"port"`
// Browser 浏览器相关配置
Browser BrowserConfig `yaml:"browser"`
}
// BrowserConfig 浏览器配置
type BrowserConfig struct {
// Headless 是否使用无头模式
Headless bool `yaml:"headless"`
// Path Chromium 可执行文件路径
Path string `yaml:"path"`
}
var (
cfg *Config
once sync.Once
)
// Get 获取全局配置实例(单例模式)
func Get() *Config {
once.Do(func() {
cfg = &Config{
Port: "3010",
Browser: BrowserConfig{
Headless: true,
Path: "/usr/bin/chromium",
},
}
load(cfg)
})
return cfg
}
// load 从配置文件和环境变量加载配置
func load(c *Config) {
// 尝试读取 YAML 配置文件
data, err := os.ReadFile("config.yaml")
if err != nil {
log.Printf("[配置] 未找到 config.yaml使用默认配置")
} else {
if err := yaml.Unmarshal(data, c); err != nil {
log.Printf("[配置] 解析 config.yaml 失败: %v", err)
} else {
log.Printf("[配置] 已加载 config.yaml")
}
}
// 环境变量覆盖配置文件
if port := os.Getenv("PORT"); port != "" {
c.Port = port
}
// 输出最终配置
log.Printf("[配置] 端口: %s", c.Port)
}

View File

@@ -0,0 +1,323 @@
// Package handler 提供 HTTP 请求处理器
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"cursor2api/internal/browser"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ================== 请求/响应结构体 ==================
// MessagesRequest Anthropic Messages API 请求格式
type MessagesRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
System interface{} `json:"system,omitempty"` // 可以是 string 或 []ContentBlock
}
// Message 消息格式
type Message struct {
Role string `json:"role"`
Content interface{} `json:"content"` // 可以是 string 或 []ContentBlock
}
// MessagesResponse Anthropic Messages API 响应格式
type MessagesResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence"`
Usage Usage `json:"usage"`
}
// ContentBlock 内容块
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
// Usage token 使用统计
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// CursorSSEEvent Cursor SSE 事件格式
type CursorSSEEvent struct {
Type string `json:"type"`
Delta string `json:"delta,omitempty"`
}
// ================== 辅助函数 ==================
// generateID 生成唯一标识符
func generateID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")[:16]
}
// getTextContent 从 interface{} 提取文本内容
// 支持 string 和 []ContentBlock 两种格式
func getTextContent(content interface{}) string {
if content == nil {
return ""
}
switch v := content.(type) {
case string:
return v
case []interface{}:
var texts []string
for _, item := range v {
if block, ok := item.(map[string]interface{}); ok {
if block["type"] == "text" {
if text, ok := block["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
default:
return fmt.Sprintf("%v", v)
}
}
// mapModelName 将模型名称映射到 Cursor 支持的格式
func mapModelName(model string) string {
model = strings.ToLower(model)
// 已经是 Cursor 格式
if strings.Contains(model, "/") {
return model
}
// Claude 模型
if strings.Contains(model, "claude") {
return "anthropic/claude-sonnet-4.5"
}
// GPT 模型
if strings.Contains(model, "gpt") {
return "openai/gpt-5-nano"
}
// Gemini 模型
if strings.Contains(model, "gemini") {
return "google/gemini-2.5-flash"
}
// 默认使用 Claude
return "anthropic/claude-sonnet-4.5"
}
// ================== 处理器函数 ==================
// CountTokens 估算 token 数量
func CountTokens(c *gin.Context) {
var req MessagesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error()}})
return
}
// 简单估算:每 4 个字符约 1 个 token
totalChars := len(getTextContent(req.System))
for _, msg := range req.Messages {
totalChars += len(getTextContent(msg.Content))
}
tokens := totalChars / 4
if tokens < 1 {
tokens = 1
}
c.JSON(http.StatusOK, gin.H{"input_tokens": tokens})
}
// Messages 处理 Anthropic Messages API 请求
func Messages(c *gin.Context) {
var req MessagesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error()}})
return
}
// 转换为 Cursor 请求格式
cursorReq := convertToCursor(req)
if req.Stream {
handleStream(c, cursorReq, req.Model)
} else {
handleNonStream(c, cursorReq, req.Model)
}
}
// ================== 请求转换 ==================
// convertToCursor 将 Anthropic 请求转换为 Cursor 格式
func convertToCursor(req MessagesRequest) browser.CursorChatRequest {
messages := make([]browser.CursorMessage, 0, len(req.Messages)+1)
// 添加 system 消息
if sysText := getTextContent(req.System); sysText != "" {
messages = append(messages, browser.CursorMessage{
Parts: []browser.CursorPart{{Type: "text", Text: sysText}},
ID: generateID(),
Role: "system",
})
}
// 添加用户/助手消息
for _, msg := range req.Messages {
messages = append(messages, browser.CursorMessage{
Parts: []browser.CursorPart{{Type: "text", Text: getTextContent(msg.Content)}},
ID: generateID(),
Role: msg.Role,
})
}
return browser.CursorChatRequest{
Context: []browser.CursorContext{{
Type: "file",
Content: "",
FilePath: "/docs/",
}},
Model: mapModelName(req.Model),
ID: generateID(),
Messages: messages,
Trigger: "submit-message",
}
}
// ================== API 处理 ==================
// handleStream 处理流式请求
func handleStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, _ := c.Writer.(http.Flusher)
id := "msg_" + generateID()
// 发送 message_start
c.Writer.WriteString("event: message_start\n")
c.Writer.WriteString(fmt.Sprintf(`data: {"type":"message_start","message":{"id":"%s","type":"message","role":"assistant","content":[],"model":"%s","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":0}}}`+"\n\n", id, model))
flusher.Flush()
c.Writer.WriteString("event: content_block_start\n")
c.Writer.WriteString(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n")
flusher.Flush()
// 用于累积不完整的 SSE 行
var buffer strings.Builder
svc := browser.GetService()
err := svc.SendStreamRequest(cursorReq, func(chunk string) {
buffer.WriteString(chunk)
content := buffer.String()
lines := strings.Split(content, "\n")
// 保留最后一个可能不完整的行
if !strings.HasSuffix(content, "\n") && len(lines) > 0 {
buffer.Reset()
buffer.WriteString(lines[len(lines)-1])
lines = lines[:len(lines)-1]
} else {
buffer.Reset()
}
for _, line := range lines {
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "" {
continue
}
var event CursorSSEEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
if event.Type == "text-delta" && event.Delta != "" {
deltaJSON, _ := json.Marshal(event.Delta)
c.Writer.WriteString("event: content_block_delta\n")
c.Writer.WriteString(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":` + string(deltaJSON) + `}}` + "\n\n")
flusher.Flush()
}
}
})
if err != nil {
c.Writer.WriteString("event: error\n")
c.Writer.WriteString(`data: {"type":"error","error":{"message":"` + err.Error() + `"}}` + "\n\n")
flusher.Flush()
}
c.Writer.WriteString("event: content_block_stop\n")
c.Writer.WriteString(`data: {"type":"content_block_stop","index":0}` + "\n\n")
flusher.Flush()
c.Writer.WriteString("event: message_delta\n")
c.Writer.WriteString(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":100}}` + "\n\n")
flusher.Flush()
c.Writer.WriteString("event: message_stop\n")
c.Writer.WriteString(`data: {"type":"message_stop"}` + "\n\n")
flusher.Flush()
}
// handleNonStream 处理非流式请求
func handleNonStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
svc := browser.GetService()
result, err := svc.SendRequest(cursorReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
return
}
// 解析响应
var fullText strings.Builder
lines := strings.Split(result, "\n")
for _, line := range lines {
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "" {
continue
}
var event CursorSSEEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
if event.Type == "text-delta" && event.Delta != "" {
fullText.WriteString(event.Delta)
}
}
c.JSON(http.StatusOK, MessagesResponse{
ID: "msg_" + generateID(),
Type: "message",
Role: "assistant",
Content: []ContentBlock{{Type: "text", Text: fullText.String()}},
Model: model,
StopReason: "end_turn",
Usage: Usage{InputTokens: 100, OutputTokens: 100},
})
}

View File

@@ -0,0 +1,50 @@
// Package handler 提供 HTTP 请求处理器
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// SupportedModels 支持的模型列表
var SupportedModels = []string{
"anthropic/claude-sonnet-4.5",
"openai/gpt-5-nano",
"google/gemini-2.5-flash",
}
// Model 模型信息
type Model struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
// ModelsResponse 模型列表响应
type ModelsResponse struct {
Object string `json:"object"`
Data []Model `json:"data"`
}
// ListModels 返回支持的模型列表
func ListModels(c *gin.Context) {
models := make([]Model, len(SupportedModels))
now := time.Now().Unix()
for i, id := range SupportedModels {
models[i] = Model{
ID: id,
Object: "model",
Created: now,
OwnedBy: "cursor",
}
}
c.JSON(http.StatusOK, ModelsResponse{
Object: "list",
Data: models,
})
}

246
internal/handler/openai.go Normal file
View File

@@ -0,0 +1,246 @@
// Package handler 提供 HTTP 请求处理器
package handler
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"cursor2api/internal/browser"
"github.com/gin-gonic/gin"
)
// ================== OpenAI 兼容格式 ==================
// ChatCompletionRequest OpenAI Chat Completion 请求格式
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []OpenAIMessage `json:"messages"`
Stream bool `json:"stream"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
// OpenAIMessage OpenAI 消息格式
type OpenAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatCompletionResponse OpenAI Chat Completion 响应格式
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *OpenAIUsage `json:"usage,omitempty"`
}
// Choice 选项
type Choice struct {
Index int `json:"index"`
Message *OpenAIMessage `json:"message,omitempty"`
Delta *OpenAIMessage `json:"delta,omitempty"`
FinishReason *string `json:"finish_reason"`
}
// OpenAIUsage token 使用统计
type OpenAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ChatCompletionChunk 流式响应块
type ChatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
}
// ChunkChoice 流式选项
type ChunkChoice struct {
Index int `json:"index"`
Delta OpenAIMessage `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// ================== 处理器函数 ==================
// ChatCompletions 处理 OpenAI Chat Completions API 请求
func ChatCompletions(c *gin.Context) {
var req ChatCompletionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("[OpenAI] 请求: model=%s, messages=%d, stream=%v", req.Model, len(req.Messages), req.Stream)
// 转换为 Cursor 请求格式
cursorReq := convertOpenAIToCursor(req)
if req.Stream {
handleOpenAIStream(c, cursorReq, req.Model)
} else {
handleOpenAINonStream(c, cursorReq, req.Model)
}
}
// convertOpenAIToCursor 将 OpenAI 请求转换为 Cursor 格式
func convertOpenAIToCursor(req ChatCompletionRequest) browser.CursorChatRequest {
messages := make([]browser.CursorMessage, len(req.Messages))
for i, msg := range req.Messages {
messages[i] = browser.CursorMessage{
Parts: []browser.CursorPart{{Type: "text", Text: msg.Content}},
ID: generateID(),
Role: msg.Role,
}
}
return browser.CursorChatRequest{
Context: []browser.CursorContext{{
Type: "file",
Content: "",
FilePath: "/docs/",
}},
Model: mapModelName(req.Model),
ID: generateID(),
Messages: messages,
Trigger: "submit-message",
}
}
// handleOpenAIStream 处理 OpenAI 流式请求
func handleOpenAIStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
id := "chatcmpl-" + generateID()
created := time.Now().Unix()
flusher, _ := c.Writer.(http.Flusher)
var buffer strings.Builder
svc := browser.GetService()
_ = svc.SendStreamRequest(cursorReq, func(chunk string) {
buffer.WriteString(chunk)
content := buffer.String()
lines := strings.Split(content, "\n")
if !strings.HasSuffix(content, "\n") && len(lines) > 0 {
buffer.Reset()
buffer.WriteString(lines[len(lines)-1])
lines = lines[:len(lines)-1]
} else {
buffer.Reset()
}
for _, line := range lines {
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "" || data == "[DONE]" {
continue
}
var event CursorSSEEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
if event.Type == "text-delta" && event.Delta != "" {
chunk := ChatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []ChunkChoice{{
Index: 0,
Delta: OpenAIMessage{Content: event.Delta},
}},
}
chunkJSON, _ := json.Marshal(chunk)
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", chunkJSON)))
flusher.Flush()
}
}
})
// 发送结束标记
reason := "stop"
endChunk := ChatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []ChunkChoice{{
Index: 0,
Delta: OpenAIMessage{},
FinishReason: &reason,
}},
}
endJSON, _ := json.Marshal(endChunk)
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", endJSON)))
c.Writer.Write([]byte("data: [DONE]\n\n"))
flusher.Flush()
}
// handleOpenAINonStream 处理 OpenAI 非流式请求
func handleOpenAINonStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
svc := browser.GetService()
result, err := svc.SendRequest(cursorReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 解析响应
var fullContent strings.Builder
lines := strings.Split(result, "\n")
for _, line := range lines {
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "" || data == "[DONE]" {
continue
}
var event CursorSSEEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
if event.Type == "text-delta" {
fullContent.WriteString(event.Delta)
}
}
reason := "stop"
c.JSON(http.StatusOK, ChatCompletionResponse{
ID: "chatcmpl-" + generateID(),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: model,
Choices: []Choice{{
Index: 0,
Message: &OpenAIMessage{Role: "assistant", Content: fullContent.String()},
FinishReason: &reason,
}},
Usage: &OpenAIUsage{
PromptTokens: 100,
CompletionTokens: 100,
TotalTokens: 200,
},
})
}

364
static/index.html Normal file
View File

@@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cursor2API 测试</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #fef9e7 0%, #fdebd0 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1400px;
width: 95%;
margin: 30px auto;
padding: 24px;
flex: 1;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(224, 213, 192, 0.5);
}
.header h1 {
font-size: 22px;
color: #2c3e50;
font-weight: 600;
letter-spacing: -0.5px;
}
.status {
padding: 6px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
background: #e8e8e8;
color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.status.ready { background: linear-gradient(135deg, #d4edda, #c3e6cb); color: #155724; }
.status.loading { background: linear-gradient(135deg, #fff3cd, #ffeeba); color: #856404; }
.chat-area {
flex: 1;
background: linear-gradient(180deg, #fffdf8 0%, #fffbf0 100%);
border-radius: 16px;
border: 1px solid #e0d5c0;
box-shadow: 0 4px 20px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.8);
overflow-y: auto;
padding: 28px;
margin-bottom: 24px;
min-height: 500px;
}
.message {
margin-bottom: 20px;
padding: 16px 20px;
border-radius: 12px;
max-width: 80%;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.message:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.message.user {
background: linear-gradient(135deg, #f8f3e8 0%, #f5f0e5 100%);
margin-left: auto;
border: 1px solid #e0d5c0;
border-radius: 12px 12px 4px 12px;
}
.message.assistant {
background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%);
border: 1px solid #e8e0d0;
border-radius: 12px 12px 12px 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.message .role {
font-size: 11px;
color: #999;
margin-bottom: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message .content {
font-size: 15px;
line-height: 1.7;
color: #2c3e50;
white-space: pre-wrap;
}
.input-area {
background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%);
border-radius: 16px;
border: 1px solid #e0d5c0;
padding: 16px 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
}
.input-row {
display: flex;
gap: 12px;
}
textarea {
flex: 1;
border: none;
outline: none;
font-size: 15px;
resize: none;
min-height: 44px;
max-height: 180px;
font-family: inherit;
line-height: 1.5;
color: #2c3e50;
}
textarea::placeholder { color: #b0b0b0; }
.send-btn {
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #f5a623 0%, #e67e22 100%);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
box-shadow: 0 4px 12px rgba(245, 166, 35, 0.35);
}
.send-btn:hover {
transform: scale(1.08) translateY(-2px);
box-shadow: 0 6px 20px rgba(245, 166, 35, 0.45);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0e8d8;
}
.model-select {
display: flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #f8f4ec 0%, #f5f1e8 100%);
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
color: #666;
border: 1px solid rgba(224, 213, 192, 0.5);
transition: all 0.2s ease;
}
.model-select:hover {
background: linear-gradient(135deg, #f5f1e8 0%, #f0ece2 100%);
border-color: rgba(224, 213, 192, 0.8);
}
.model-select select {
border: none;
background: transparent;
font-size: 13px;
color: #2c3e50;
cursor: pointer;
outline: none;
font-weight: 500;
}
.agent-badge {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
padding: 6px 14px;
border-radius: 15px;
font-size: 12px;
color: #2e7d32;
font-weight: 600;
border: 1px solid rgba(46, 125, 50, 0.15);
}
.footer {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #a0a0a0;
padding: 16px 4px;
}
.footer span {
padding: 4px 0;
}
.typing {
display: inline-block;
}
.typing::after {
content: '...';
animation: dots 1.5s infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 Cursor2API 测试</h1>
<span id="status" class="status loading">检查中...</span>
</div>
<div class="chat-area" id="chatArea"></div>
<div class="input-area">
<div class="input-row">
<textarea id="input" placeholder="询问有关文档的问题" rows="1"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
</div>
<div class="controls">
<span class="agent-badge">∞ Agent</span>
<div class="model-select">
<select id="modelSelect">
<option value="anthropic/claude-sonnet-4.5">Sonnet 4.5</option>
<option value="openai/gpt-5-nano">GPT-5 Nano</option>
<option value="google/gemini-2.5-flash">Gemini 2.5</option>
</select>
</div>
</div>
</div>
<div class="footer">
<span>Tokenizer Off</span>
<span id="tokenCount">上下文: 0/200k (0%)</span>
</div>
</div>
<script>
const chatArea = document.getElementById('chatArea');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const statusEl = document.getElementById('status');
const modelSelect = document.getElementById('modelSelect');
let isLoading = false;
// 检查服务状态
async function checkStatus() {
try {
const res = await fetch('/health');
if (res.ok) {
statusEl.textContent = '就绪';
statusEl.className = 'status ready';
}
} catch (e) {
statusEl.textContent = '离线';
statusEl.className = 'status';
}
}
checkStatus();
function addMessage(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
div.innerHTML = `
<div class="role">${role === 'user' ? '你' : 'AI'}</div>
<div class="content">${content}</div>
`;
chatArea.appendChild(div);
chatArea.scrollTop = chatArea.scrollHeight;
return div;
}
async function sendMessage() {
const text = input.value.trim();
if (!text || isLoading) return;
isLoading = true;
sendBtn.disabled = true;
input.value = '';
addMessage('user', text);
const assistantMsg = addMessage('assistant', '<span class="typing">思考中</span>');
const contentEl = assistantMsg.querySelector('.content');
try {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelSelect.value,
messages: [{ role: 'user', content: text }],
stream: true
})
});
if (!res.ok) {
const err = await res.json();
contentEl.textContent = `错误: ${err.error || res.statusText}`;
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
fullContent += delta;
contentEl.textContent = fullContent || '...';
} catch (e) {}
}
}
}
if (!fullContent) {
contentEl.textContent = '(无响应)';
}
} catch (e) {
contentEl.textContent = `请求失败: ${e.message}`;
} finally {
isLoading = false;
sendBtn.disabled = false;
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 自动调整输入框高度
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
});
</script>
</body>
</html>