commit 7b83db9ddaf4c10bda6ac097d5b64e0771cdc98f Author: 江西小徐 <7836246@qq.com> Date: Tue Dec 16 19:50:35 2025 +0800 feat: 重构项目为标准 Go 结构 - 重构为标准 Go 项目结构 (cmd/server, internal/) - 配置改为 YAML 格式 - 添加 Anthropic Messages API 支持 - 添加 OpenAI Chat API 支持 - 浏览器自动化处理人机验证 - 添加详细中文注释 - 添加免责声明 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/cursor2api.iml b/.idea/cursor2api.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/cursor2api.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..701e0e7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..484e374 --- /dev/null +++ b/README.md @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..cd54593 --- /dev/null +++ b/cmd/server/main.go @@ -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) + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..212b1a9 --- /dev/null +++ b/config.yaml @@ -0,0 +1,12 @@ +# Cursor2API 配置文件 +# 将 Cursor API 转换为 OpenAI/Anthropic 兼容格式 + +# 服务端口 +port: 3010 + +# 浏览器设置 +browser: + # 是否使用无头模式 + headless: true + # Chromium 可执行文件路径 + path: "/usr/bin/chromium" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..037d218 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be12fac --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..2366f18 --- /dev/null +++ b/internal/browser/browser.go @@ -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("请求超时") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..12bff48 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/handler/anthropic.go b/internal/handler/anthropic.go new file mode 100644 index 0000000..d961fb6 --- /dev/null +++ b/internal/handler/anthropic.go @@ -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}, + }) +} diff --git a/internal/handler/models.go b/internal/handler/models.go new file mode 100644 index 0000000..639ff97 --- /dev/null +++ b/internal/handler/models.go @@ -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, + }) +} diff --git a/internal/handler/openai.go b/internal/handler/openai.go new file mode 100644 index 0000000..8e22ef9 --- /dev/null +++ b/internal/handler/openai.go @@ -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, + }, + }) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..074625d --- /dev/null +++ b/static/index.html @@ -0,0 +1,364 @@ + + + + + + Cursor2API 测试 + + + +
+
+

🚀 Cursor2API 测试

+ 检查中... +
+ +
+ +
+
+ + +
+
+ ∞ Agent +
+ +
+
+
+ + +
+ + + +