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 测试
+
+
+
+
+
+
+
+