david-baxter commited on
Commit
9a81be0
·
verified ·
1 Parent(s): 91feb7c

Upload 38 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docs/images/home.png filter=lfs diff=lfs merge=lfs -text
37
+ docs/images/play1.png filter=lfs diff=lfs merge=lfs -text
38
+ docs/images/play2.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 构建阶段
2
+ FROM golang:1.24-alpine AS builder
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 安装必要的包
8
+ RUN apk add --no-cache git ca-certificates
9
+
10
+ # 复制go mod文件
11
+ COPY go.mod go.sum ./
12
+
13
+ # 下载依赖
14
+ RUN go mod download
15
+
16
+ # 复制源码
17
+ COPY . .
18
+
19
+ # 构建应用
20
+ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o cursor2api-go .
21
+
22
+ # 运行阶段
23
+ FROM alpine:latest
24
+
25
+ # 安装 ca-certificates 和 nodejs(用于 JavaScript 执行)
26
+ RUN apk --no-cache add ca-certificates nodejs npm
27
+
28
+ # 创建非 root 用户
29
+ RUN adduser -D -g '' appuser
30
+
31
+ WORKDIR /root/
32
+
33
+ # 从构建阶段复制二进制文件
34
+ COPY --from=builder /app/cursor2api-go .
35
+
36
+ # 复制静态文件和 JS 代码(需要用于 JavaScript 执行)
37
+ COPY --from=builder /app/static ./static
38
+ COPY --from=builder /app/jscode ./jscode
39
+
40
+ # 更改所有者
41
+ RUN chown -R appuser:appuser /root/
42
+
43
+ # 切换到非root用户
44
+ USER appuser
45
+
46
+ # 暴露端口
47
+ EXPOSE 8002
48
+
49
+ # 健康检查
50
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
51
+ CMD node -e "require('http').get('http://localhost:8002/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" || exit 1
52
+
53
+ # 启动应用
54
+ CMD ["./cursor2api-go"]
LICENSE ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PolyForm Noncommercial License Notice
2
+
3
+ This project is licensed under the PolyForm Noncommercial License 1.0.0.
4
+
5
+ Official license text:
6
+ https://polyformproject.org/licenses/noncommercial/1.0.0/
7
+
8
+ SPDX identifier:
9
+ PolyForm-Noncommercial-1.0.0
10
+
11
+ Required Notice: Copyright (c) 2025-2026 libaxuan
12
+
13
+ Summary:
14
+ - Noncommercial use is permitted.
15
+ - Commercial use is not permitted under this license.
16
+ - See the official license text at the URL above for the complete terms.
README_EN.md ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cursor2API
2
+
3
+ English | [简体中文](README.md)
4
+
5
+ A Go service that converts Cursor Web into an OpenAI `chat/completions` compatible API for local deployment.
6
+
7
+ [![Go Version](https://img.shields.io/badge/Go-1.24+-blue.svg)](https://golang.org)
8
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-orange.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)
9
+
10
+ ## ✨ Features
11
+
12
+ - ✅ Compatible with OpenAI `chat/completions`
13
+ - ✅ Supports streaming and non-streaming responses
14
+ - ✅ High-performance Go implementation
15
+ - ✅ Automatic Cursor Web authentication
16
+ - ✅ Clean web interface
17
+ - ✅ Supports `tools`, `tool_choice`, and `tool_calls`
18
+ - ✅ Automatically derives `-thinking` public models
19
+ - ❌ Does not yet support Anthropic `/v1/messages` or MCP
20
+
21
+ ## 🖼️ Screenshots
22
+
23
+ Drop images into `docs/images/` and the README will render them.
24
+
25
+ ![Home preview](docs/images/home.png)
26
+ ![Tool calls preview 1](docs/images/play1.png)
27
+ ![Tool calls preview 2](docs/images/play2.png)
28
+
29
+ ## 🤖 Supported Models
30
+
31
+ - **Anthropic Claude**: `claude-sonnet-4.6`
32
+ - **Derived thinking model**: `claude-sonnet-4.6-thinking`
33
+
34
+ ## 🚀 Quick Start
35
+
36
+ ### Requirements
37
+
38
+ - Go 1.24+
39
+ - Node.js 18+ (for JavaScript execution)
40
+
41
+ ### Local Running Methods
42
+
43
+ #### Method 1: Direct Run (Recommended for development)
44
+
45
+ **Linux/macOS**:
46
+ ```bash
47
+ git clone https://github.com/libaxuan/cursor2api-go.git
48
+ cd cursor2api-go
49
+ chmod +x start.sh
50
+ ./start.sh
51
+ ```
52
+
53
+ **Windows**:
54
+ ```batch
55
+ # Double-click or run in cmd
56
+ start-go.bat
57
+
58
+ # Or in Git Bash / Windows Terminal
59
+ ./start-go-utf8.bat
60
+ ```
61
+
62
+ #### Method 2: Manual Compile and Run
63
+
64
+ ```bash
65
+ # Clone the project
66
+ git clone https://github.com/libaxuan/cursor2api-go.git
67
+ cd cursor2api-go
68
+
69
+ # Download dependencies
70
+ go mod tidy
71
+
72
+ # Build
73
+ go build -o cursor2api-go
74
+
75
+ # Run
76
+ ./cursor2api-go
77
+ ```
78
+
79
+ #### Method 3: Using go run
80
+
81
+ ```bash
82
+ git clone https://github.com/libaxuan/cursor2api-go.git
83
+ cd cursor2api-go
84
+ go run main.go
85
+ ```
86
+
87
+ The service will start at `http://localhost:8002`
88
+
89
+ ## 🚀 Server Deployment Methods
90
+
91
+ ### Docker Deployment
92
+
93
+ 1. **Build Image**:
94
+ ```bash
95
+ # Build image
96
+ docker build -t cursor2api-go .
97
+ ```
98
+
99
+ 2. **Run Container**:
100
+ ```bash
101
+ # Run container (recommended)
102
+ docker run -d \
103
+ --name cursor2api-go \
104
+ --restart unless-stopped \
105
+ -p 8002:8002 \
106
+ -e API_KEY=your-secret-key \
107
+ -e DEBUG=false \
108
+ cursor2api-go
109
+
110
+ # Or run with default configuration
111
+ docker run -d --name cursor2api-go --restart unless-stopped -p 8002:8002 cursor2api-go
112
+ ```
113
+
114
+ ### Docker Compose Deployment (Recommended for production)
115
+
116
+ 1. **Using docker-compose.yml**:
117
+ ```bash
118
+ # Start service
119
+ docker-compose up -d
120
+
121
+ # Stop service
122
+ docker-compose down
123
+
124
+ # View logs
125
+ docker-compose logs -f
126
+ ```
127
+
128
+ 2. **Custom Configuration**:
129
+ Modify the environment variables in the `docker-compose.yml` file to meet your needs:
130
+ - Change `API_KEY` to a secure key
131
+ - Adjust `MODELS`, `TIMEOUT`, and other configurations as needed
132
+ - Change the exposed port
133
+
134
+ ### System Service Deployment (Linux)
135
+
136
+ 1. **Compile and Move Binary**:
137
+ ```bash
138
+ go build -o cursor2api-go
139
+ sudo mv cursor2api-go /usr/local/bin/
140
+ sudo chmod +x /usr/local/bin/cursor2api-go
141
+ ```
142
+
143
+ 2. **Create System Service File** `/etc/systemd/system/cursor2api-go.service`:
144
+ ```ini
145
+ [Unit]
146
+ Description=Cursor2API Service
147
+ After=network.target
148
+
149
+ [Service]
150
+ Type=simple
151
+ User=your-user
152
+ WorkingDirectory=/home/your-user/cursor2api-go
153
+ ExecStart=/usr/local/bin/cursor2api-go
154
+ Restart=always
155
+ Environment=API_KEY=your-secret-key
156
+ Environment=PORT=8002
157
+
158
+ [Install]
159
+ WantedBy=multi-user.target
160
+ ```
161
+
162
+ 3. **Start Service**:
163
+ ```bash
164
+ # Reload systemd configuration
165
+ sudo systemctl daemon-reload
166
+
167
+ # Enable auto-start on boot
168
+ sudo systemctl enable cursor2api-go
169
+
170
+ # Start service
171
+ sudo systemctl start cursor2api-go
172
+
173
+ # Check status
174
+ sudo systemctl status cursor2api-go
175
+ ```
176
+
177
+ ## 📡 API Usage
178
+
179
+ ### List Models
180
+
181
+ ```bash
182
+ curl -H "Authorization: Bearer 0000" http://localhost:8002/v1/models
183
+ ```
184
+
185
+ ### Non-Streaming Chat
186
+
187
+ ```bash
188
+ curl -X POST http://localhost:8002/v1/chat/completions \
189
+ -H "Content-Type: application/json" \
190
+ -H "Authorization: Bearer 0000" \
191
+ -d '{
192
+ "model": "claude-sonnet-4.6",
193
+ "messages": [{"role": "user", "content": "Hello!"}],
194
+ "stream": false
195
+ }'
196
+ ```
197
+
198
+ ### Streaming Chat
199
+
200
+ ```bash
201
+ curl -X POST http://localhost:8002/v1/chat/completions \
202
+ -H "Content-Type: application/json" \
203
+ -H "Authorization: Bearer 0000" \
204
+ -d '{
205
+ "model": "claude-sonnet-4.6",
206
+ "messages": [{"role": "user", "content": "Hello!"}],
207
+ "stream": true
208
+ }'
209
+ ```
210
+
211
+ ### Tool Request
212
+
213
+ ```bash
214
+ curl -X POST http://localhost:8002/v1/chat/completions \
215
+ -H "Content-Type: application/json" \
216
+ -H "Authorization: Bearer 0000" \
217
+ -d '{
218
+ "model": "claude-sonnet-4.6",
219
+ "messages": [{"role": "user", "content": "Check the weather in Beijing"}],
220
+ "tools": [
221
+ {
222
+ "type": "function",
223
+ "function": {
224
+ "name": "get_weather",
225
+ "description": "Get current weather",
226
+ "parameters": {
227
+ "type": "object",
228
+ "properties": {
229
+ "city": {"type": "string"}
230
+ },
231
+ "required": ["city"]
232
+ }
233
+ }
234
+ }
235
+ ]
236
+ }'
237
+ ```
238
+
239
+ ### `-thinking` Model
240
+
241
+ ```bash
242
+ curl -X POST http://localhost:8002/v1/chat/completions \
243
+ -H "Content-Type: application/json" \
244
+ -H "Authorization: Bearer 0000" \
245
+ -d '{
246
+ "model": "claude-sonnet-4.6-thinking",
247
+ "messages": [{"role": "user", "content": "Think first, then decide whether a tool is needed"}],
248
+ "tools": [
249
+ {
250
+ "type": "function",
251
+ "function": {
252
+ "name": "lookup",
253
+ "parameters": {
254
+ "type": "object",
255
+ "properties": {
256
+ "q": {"type": "string"}
257
+ },
258
+ "required": ["q"]
259
+ }
260
+ }
261
+ }
262
+ ],
263
+ "stream": true
264
+ }'
265
+ ```
266
+
267
+ ### Use in Third-Party Apps
268
+
269
+ In any app that supports custom OpenAI API (e.g., ChatGPT Next Web, Lobe Chat):
270
+
271
+ 1. **API URL**: `http://localhost:8002`
272
+ 2. **API Key**: `0000` (or custom)
273
+ 3. **Model**: Choose a supported base model or its automatically derived `-thinking` variant
274
+
275
+ ## ⚙️ Configuration
276
+
277
+ ### Environment Variables
278
+
279
+ | Variable | Default | Description |
280
+ |----------|---------|-------------|
281
+ | `PORT` | `8002` | Server port |
282
+ | `DEBUG` | `false` | Debug mode (shows detailed logs and route info when enabled) |
283
+ | `API_KEY` | `0000` | API authentication key |
284
+ | `MODELS` | `claude-sonnet-4.6` | Base model list (comma-separated); the service automatically exposes matching `-thinking` public models |
285
+ | `TIMEOUT` | `60` | Request timeout (seconds) |
286
+ | `KILO_TOOL_STRICT` | `false` | Kilo Code compatibility: if `tools` are provided and `tool_choice=auto`, treat it as “tool use required” |
287
+
288
+ ### Debug Mode
289
+
290
+ By default, the service runs in clean mode. To enable detailed logging:
291
+
292
+ **Option 1**: Modify `.env` file
293
+ ```bash
294
+ DEBUG=true
295
+ ```
296
+
297
+ **Option 2**: Use environment variable
298
+ ```bash
299
+ DEBUG=true ./cursor2api-go
300
+ ```
301
+
302
+ Debug mode displays:
303
+ - Detailed GIN route information
304
+ - Verbose request logs
305
+ - x-is-human token details
306
+ - Browser fingerprint configuration
307
+
308
+ ### Troubleshooting
309
+
310
+ Having issues? Check the **[Troubleshooting Guide](TROUBLESHOOTING.md)** for solutions to common problems, including:
311
+ - 403 Access Denied errors
312
+ - Token fetch failures
313
+ - Connection timeouts
314
+ - Cloudflare blocking
315
+
316
+ ## 🧩 Kilo Code / Agent Orchestrator Compatibility
317
+
318
+ Some orchestrators enforce “must use tools” and may throw errors like `MODEL_NO_TOOLS_USED` when a response contains no tool call.
319
+
320
+ - **Recommended**: set `KILO_TOOL_STRICT=true` in `.env`
321
+ - **Non-stream safety net**: if tools are provided and tool use is required (`tool_choice=required/function`, or `KILO_TOOL_STRICT`), but the first attempt produces no `tool_calls`, the server automatically retries once (non-stream only)
322
+
323
+
324
+ ### Windows Startup Scripts
325
+
326
+ Two Windows startup scripts are provided:
327
+
328
+ - **`start-go.bat`** (Recommended): GBK encoding, perfect compatibility with Windows cmd.exe
329
+ - **`start-go-utf8.bat`**: UTF-8 encoding, for Git Bash, PowerShell, Windows Terminal
330
+
331
+ Both scripts have identical functionality, only display styles differ. Use `start-go.bat` if you encounter encoding issues.
332
+
333
+ ## 🧪 Development
334
+
335
+ ### Running Tests
336
+
337
+ ```bash
338
+ # Run existing tests
339
+ go test ./...
340
+ ```
341
+
342
+ ### Building
343
+
344
+ ```bash
345
+ # Build executable
346
+ go build -o cursor2api-go
347
+
348
+ # Cross-compile (e.g., for Linux)
349
+ GOOS=linux GOARCH=amd64 go build -o cursor2api-go-linux
350
+ ```
351
+
352
+ ## 📁 Project Structure
353
+
354
+ ```
355
+ cursor2api-go/
356
+ ├── main.go # Main entry point (Go version)
357
+ ├── config/ # Configuration management (Go version)
358
+ ├── handlers/ # HTTP handlers (Go version)
359
+ ├── services/ # Business service layer (Go version)
360
+ ├── models/ # Data models (Go version)
361
+ ├── utils/ # Utility functions (Go version)
362
+ ├── middleware/ # Middleware (Go version)
363
+ ├── jscode/ # JavaScript code (Go version)
364
+ ├── static/ # Static files (Go version)
365
+ ├── start.sh # Linux/macOS startup script
366
+ ├── start-go.bat # Windows startup script (GBK)
367
+ ├── start-go-utf8.bat # Windows startup script (UTF-8)
368
+
369
+ └── README.md # Project documentation
370
+ ```
371
+
372
+ ## 🤝 Contributing
373
+
374
+ Contributions are welcome! Please follow these steps:
375
+
376
+ 1. Fork the repository
377
+ 2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
378
+ 3. Commit your changes (`git commit -m 'feat: Add some AmazingFeature'`)
379
+ 4. Push to the branch (`git push origin feature/AmazingFeature`)
380
+ 5. Open a Pull Request
381
+
382
+ ### Code Standards
383
+
384
+ - Follow [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments)
385
+ - Format code with `gofmt`
386
+ - Check code with `go vet`
387
+ - Follow [Conventional Commits](https://conventionalcommits.org/) for commit messages
388
+
389
+ ## 📄 License
390
+
391
+ This project is licensed under [PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/).
392
+ Commercial use is not permitted. See the [LICENSE](LICENSE) file for details.
393
+
394
+ ## ⚠️ Disclaimer
395
+
396
+ Please comply with the terms of service of related services when using this project.
397
+
398
+ ---
399
+
400
+ ⭐ If this project helps you, please give us a Star!
TROUBLESHOOTING.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 故障排除指南
2
+
3
+ ## 403 Access Denied 错误
4
+
5
+ ### 问题描述
6
+ 在使用一段时间后,服务突然开始返回 `403 Access Denied` 错误:
7
+ ```
8
+ ERRO[0131] Cursor API returned non-OK status status_code=403
9
+ ERRO[0131] Failed to create chat completion error="{\"error\":\"Access denied\"}"
10
+ ```
11
+
12
+ ### 原因分析
13
+ 1. **Token 过期**: `x-is-human` token 缓存时间过长,导致 token 失效
14
+ 2. **频率限制**: 短时间内发送过多请求触发了 Cursor API 的速率限制
15
+ 3. **重复 Token**: 使用相同的 token 进行多次请求被识别为异常行为
16
+
17
+ ### 解决方案
18
+
19
+ #### 1. 已实施的自动修复
20
+ 最新版本已经包含以下改进:
21
+
22
+ - **动态浏览器指纹**: 每次请求使用真实且随机的浏览器指纹信息
23
+ - 根据操作系统自动选择合适的平台配置 (Windows/macOS/Linux)
24
+ - 随机 Chrome 版本 (120-130)
25
+ - 随机语言设置和 Referer
26
+ - 真实的 User-Agent 和 sec-ch-ua headers
27
+ - **缩短缓存时间**: 将 `x-is-human` token 缓存时间从 30 分钟缩短到 1 分钟
28
+ - **自动重试机制**: 遇到 403 错误时自动清除缓存并重试(最多 2 次)
29
+ - **指纹刷新**: 403 错误时自动刷新浏览器指纹配置
30
+ - **错误恢复**: 失败时自动清除缓存,确保下次请求使用新 token
31
+ - **指数退避**: 重试时使用递增的等待时间
32
+
33
+ #### 2. 手动解决步骤
34
+ 如果问题持续存在:
35
+
36
+ 1. **重启服务**:
37
+ ```bash
38
+ # 停止当前服务 (Ctrl+C)
39
+ # 重新启动
40
+ ./cursor2api-go
41
+ ```
42
+
43
+ 2. **检查日志**:
44
+ 查看是否有以下日志:
45
+ - `Received 403 Access Denied, clearing token cache and retrying...` - 自动重试
46
+ - `Failed to fetch x-is-human token` - Token 获取失败
47
+ - `Fetched x-is-human token` - Token 获取成功
48
+
49
+ 3. **等待冷却期**:
50
+ 如果频繁遇到 403 错误,建议等待 5-10 分钟后再使用
51
+
52
+ 4. **检查网络**:
53
+ 确保能够访问 `https://cursor.com`
54
+
55
+ #### 3. 预防措施
56
+
57
+ 1. **控制请求频率**: 避免在短时间内发送大量请求
58
+ 2. **监控日志**: 注意 `x-is-human token` 的获取频率
59
+ 3. **合理配置超时**: 在 `.env` 文件中设置合理的超时时间
60
+
61
+ ### 配置建议
62
+
63
+ 在 `.env` 文件中:
64
+ ```bash
65
+ TIMEOUT=120 # 增加超时时间,避免频繁重试
66
+ MAX_INPUT_LENGTH=100000 # 限制输入长度,减少请求大小
67
+ ```
68
+
69
+ ### 调试模式
70
+
71
+ 如果需要查看详细的调试信息,可以启用调试模式:
72
+ ```bash
73
+ # 方式 1: 修改 .env 文件
74
+ DEBUG=true
75
+
76
+ # 方式 2: 使用环境变量
77
+ DEBUG=true ./cursor2api-go
78
+ ```
79
+
80
+ 这将显示:
81
+ - 每次请求的 `x-is-human` token (前 50 字符)
82
+ - 请求的 payload 大小
83
+ - 重试次数
84
+ - 详细的错误信息
85
+
86
+ ## 其他常见问题
87
+
88
+ ### Cloudflare 403 错误
89
+ 如果看到 `Cloudflare 403` 错误,说明请求被 Cloudflare 防火墙拦截。这通常是因为:
90
+ - IP 被标记为可疑
91
+ - User-Agent 不匹配
92
+ - 缺少必要的浏览器指纹
93
+
94
+ **解决方案**: 检查 `.env` 文件中的浏览器指纹配置(`USER_AGENT`、`UNMASKED_VENDOR_WEBGL`、`UNMASKED_RENDERER_WEBGL`)是否正确。
95
+
96
+ ### 连接超时
97
+ 如果频繁出现连接超时:
98
+ 1. 检查网络连接
99
+ 2. 增加 `.env` 文件中的 `TIMEOUT` 配置值
100
+ 3. 检查防火墙设置
101
+
102
+ ### Token 获取失败
103
+ 如果无法获取 `x-is-human` token:
104
+ 1. 检查 `.env` 文件中的 `SCRIPT_URL` 配置是否正确
105
+ 2. 确保 `jscode/main.js` 和 `jscode/env.js` 文件存在
106
+ 3. 检查 Node.js 环境是否正常安装(Node.js 18+)
107
+
108
+ ## 联系支持
109
+
110
+ 如果问题仍未解决,请提供以下信息:
111
+ 1. 完整的错误日志
112
+ 2. `.env` 文件配置(隐藏敏感信息如 `API_KEY`)
113
+ 3. 使用的 Go 版本和 Node.js 版本
114
+ 4. 操作系统信息
config/config.go ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package config
22
+
23
+ import (
24
+ "cursor2api-go/models"
25
+ "encoding/json"
26
+ "fmt"
27
+ "os"
28
+ "strconv"
29
+ "strings"
30
+
31
+ "github.com/joho/godotenv"
32
+ "github.com/sirupsen/logrus"
33
+ )
34
+
35
+ // Config 应用程序配置结构
36
+ type Config struct {
37
+ // 服务器配置
38
+ Port int `json:"port"`
39
+ Debug bool `json:"debug"`
40
+
41
+ // API配置
42
+ APIKey string `json:"api_key"`
43
+ Models string `json:"models"`
44
+ SystemPromptInject string `json:"system_prompt_inject"`
45
+ Timeout int `json:"timeout"`
46
+ MaxInputLength int `json:"max_input_length"`
47
+
48
+ // 兼容性配置
49
+ // KILO_TOOL_STRICT=true 时:只要请求提供了 tools,就强制/强提示模型至少发起一次工具调用
50
+ // 以适配 Kilo Code 这类“必须用工具”的上层编排器。
51
+ KiloToolStrict bool `json:"kilo_tool_strict"`
52
+
53
+ // Cursor相关配置
54
+ ScriptURL string `json:"script_url"`
55
+ FP FP `json:"fp"`
56
+ }
57
+
58
+ // FP 指纹配置结构
59
+ type FP struct {
60
+ UserAgent string `json:"userAgent"`
61
+ UNMASKED_VENDOR_WEBGL string `json:"unmaskedVendorWebgl"`
62
+ UNMASKED_RENDERER_WEBGL string `json:"unmaskedRendererWebgl"`
63
+ }
64
+
65
+ // LoadConfig 加载配置
66
+ func LoadConfig() (*Config, error) {
67
+ // 尝试加载.env文件
68
+ if err := godotenv.Load(); err != nil {
69
+ logrus.Debug("No .env file found, using environment variables")
70
+ }
71
+
72
+ config := &Config{
73
+ // 设置默认值
74
+ Port: getEnvAsInt("PORT", 8002),
75
+ Debug: getEnvAsBool("DEBUG", false),
76
+ APIKey: getEnv("API_KEY", "0000"),
77
+ Models: getEnv("MODELS", "claude-sonnet-4.6"),
78
+ SystemPromptInject: getEnv("SYSTEM_PROMPT_INJECT", ""),
79
+ Timeout: getEnvAsInt("TIMEOUT", 60),
80
+ MaxInputLength: getEnvAsInt("MAX_INPUT_LENGTH", 200000),
81
+ KiloToolStrict: getEnvAsBool("KILO_TOOL_STRICT", false),
82
+ ScriptURL: getEnv("SCRIPT_URL", "https://cursor.com/_next/static/chunks/pages/_app.js"),
83
+ FP: FP{
84
+ UserAgent: getEnv("USER_AGENT", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"),
85
+ UNMASKED_VENDOR_WEBGL: getEnv("UNMASKED_VENDOR_WEBGL", "Google Inc. (Intel)"),
86
+ UNMASKED_RENDERER_WEBGL: getEnv("UNMASKED_RENDERER_WEBGL", "ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)"),
87
+ },
88
+ }
89
+
90
+ // 验证必要的配置
91
+ if err := config.validate(); err != nil {
92
+ return nil, fmt.Errorf("config validation failed: %w", err)
93
+ }
94
+
95
+ return config, nil
96
+ }
97
+
98
+ // validate 验证配置
99
+ func (c *Config) validate() error {
100
+ if c.Port <= 0 || c.Port > 65535 {
101
+ return fmt.Errorf("invalid port: %d", c.Port)
102
+ }
103
+
104
+ if c.APIKey == "" {
105
+ return fmt.Errorf("API_KEY is required")
106
+ }
107
+
108
+ if c.Timeout <= 0 {
109
+ return fmt.Errorf("timeout must be positive")
110
+ }
111
+
112
+ if c.MaxInputLength <= 0 {
113
+ return fmt.Errorf("max input length must be positive")
114
+ }
115
+
116
+ return nil
117
+ }
118
+
119
+ // GetBaseModels 获取基础模型列表
120
+ func (c *Config) GetBaseModels() []string {
121
+ modelsList := strings.Split(c.Models, ",")
122
+ result := make([]string, 0, len(modelsList))
123
+ for _, model := range modelsList {
124
+ if trimmed := strings.TrimSpace(model); trimmed != "" {
125
+ result = append(result, trimmed)
126
+ }
127
+ }
128
+ return result
129
+ }
130
+
131
+ // GetModels 获取模型列表
132
+ func (c *Config) GetModels() []string {
133
+ return models.ExpandModelList(c.GetBaseModels())
134
+ }
135
+
136
+ // IsValidModel 检查模型是否有效
137
+ func (c *Config) IsValidModel(model string) bool {
138
+ validModels := c.GetModels()
139
+ for _, validModel := range validModels {
140
+ if validModel == model {
141
+ return true
142
+ }
143
+ }
144
+ return false
145
+ }
146
+
147
+ // ToJSON 将配置序列化为JSON(用于调试)
148
+ func (c *Config) ToJSON() string {
149
+ // 创建一个副本,隐藏敏感信息
150
+ safeCfg := *c
151
+ safeCfg.APIKey = "***"
152
+
153
+ data, err := json.MarshalIndent(safeCfg, "", " ")
154
+ if err != nil {
155
+ return fmt.Sprintf("Error marshaling config: %v", err)
156
+ }
157
+ return string(data)
158
+ }
159
+
160
+ // 辅助函数
161
+
162
+ // getEnv 获取环境变量,如果不存在则返回默认值
163
+ func getEnv(key, defaultValue string) string {
164
+ if value := os.Getenv(key); value != "" {
165
+ return value
166
+ }
167
+ return defaultValue
168
+ }
169
+
170
+ // getEnvAsInt 获取环境变量并转换为int
171
+ func getEnvAsInt(key string, defaultValue int) int {
172
+ valueStr := os.Getenv(key)
173
+ if valueStr == "" {
174
+ return defaultValue
175
+ }
176
+
177
+ value, err := strconv.Atoi(valueStr)
178
+ if err != nil {
179
+ logrus.Warnf("Invalid integer value for %s: %s, using default: %d", key, valueStr, defaultValue)
180
+ return defaultValue
181
+ }
182
+
183
+ return value
184
+ }
185
+
186
+ // getEnvAsBool 获取环境变量并转换为bool
187
+ func getEnvAsBool(key string, defaultValue bool) bool {
188
+ valueStr := os.Getenv(key)
189
+ if valueStr == "" {
190
+ return defaultValue
191
+ }
192
+
193
+ value, err := strconv.ParseBool(valueStr)
194
+ if err != nil {
195
+ logrus.Warnf("Invalid boolean value for %s: %s, using default: %t", key, valueStr, defaultValue)
196
+ return defaultValue
197
+ }
198
+
199
+ return value
200
+ }
config/config_test.go ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package config
22
+
23
+ import (
24
+ "os"
25
+ "testing"
26
+ )
27
+
28
+ func TestLoadConfig(t *testing.T) {
29
+ // Create a temporary .env file for testing
30
+ envContent := `PORT=9000
31
+ DEBUG=true
32
+ API_KEY=test-key
33
+ MODELS=claude-sonnet-4.6
34
+ SYSTEM_PROMPT_INJECT=Test prompt
35
+ TIMEOUT=60
36
+ MAX_INPUT_LENGTH=10000
37
+ USER_AGENT=Test Agent
38
+ SCRIPT_URL=https://test.com/script.js`
39
+
40
+ // Write to temporary .env file
41
+ err := os.WriteFile(".env", []byte(envContent), 0644)
42
+ if err != nil {
43
+ t.Fatalf("Failed to create test .env file: %v", err)
44
+ }
45
+ defer os.Remove(".env") // Clean up
46
+
47
+ config, err := LoadConfig()
48
+ if err != nil {
49
+ t.Fatalf("LoadConfig() error = %v", err)
50
+ }
51
+
52
+ // Test loaded values
53
+ if config.Port != 9000 {
54
+ t.Errorf("Port = %v, want 9000", config.Port)
55
+ }
56
+ if !config.Debug {
57
+ t.Errorf("Debug = %v, want true", config.Debug)
58
+ }
59
+ if config.APIKey != "test-key" {
60
+ t.Errorf("APIKey = %v, want test-key", config.APIKey)
61
+ }
62
+ if config.SystemPromptInject != "Test prompt" {
63
+ t.Errorf("SystemPromptInject = %v, want Test prompt", config.SystemPromptInject)
64
+ }
65
+ if config.Timeout != 60 {
66
+ t.Errorf("Timeout = %v, want 60", config.Timeout)
67
+ }
68
+ if config.MaxInputLength != 10000 {
69
+ t.Errorf("MaxInputLength = %v, want 10000", config.MaxInputLength)
70
+ }
71
+ if config.FP.UserAgent != "Test Agent" {
72
+ t.Errorf("UserAgent = %v, want Test Agent", config.FP.UserAgent)
73
+ }
74
+ if config.ScriptURL != "https://test.com/script.js" {
75
+ t.Errorf("ScriptURL = %v, want https://test.com/script.js", config.ScriptURL)
76
+ }
77
+ }
78
+
79
+ func TestGetModels(t *testing.T) {
80
+ config := &Config{
81
+ Models: "claude-sonnet-4.6",
82
+ }
83
+
84
+ models := config.GetModels()
85
+ expected := []string{
86
+ "claude-sonnet-4.6",
87
+ "claude-sonnet-4.6-thinking",
88
+ }
89
+
90
+ if len(models) != len(expected) {
91
+ t.Errorf("GetModels() length = %v, want %v", len(models), len(expected))
92
+ }
93
+
94
+ for i, model := range models {
95
+ if model != expected[i] {
96
+ t.Errorf("GetModels()[%d] = %v, want %v", i, model, expected[i])
97
+ }
98
+ }
99
+ }
100
+
101
+ func TestIsValidModel(t *testing.T) {
102
+ config := &Config{
103
+ Models: "claude-sonnet-4.6",
104
+ }
105
+
106
+ tests := []struct {
107
+ name string
108
+ model string
109
+ expected bool
110
+ }{
111
+ {"valid base model", "claude-sonnet-4.6", true},
112
+ {"valid thinking model", "claude-sonnet-4.6-thinking", true},
113
+ {"invalid model", "unknown-model", false},
114
+ {"empty model", "", false},
115
+ }
116
+
117
+ for _, tt := range tests {
118
+ t.Run(tt.name, func(t *testing.T) {
119
+ result := config.IsValidModel(tt.model)
120
+ if result != tt.expected {
121
+ t.Errorf("IsValidModel(%q) = %v, want %v", tt.model, result, tt.expected)
122
+ }
123
+ })
124
+ }
125
+ }
126
+
127
+ func TestValidate(t *testing.T) {
128
+ tests := []struct {
129
+ name string
130
+ config *Config
131
+ wantErr bool
132
+ }{
133
+ {
134
+ name: "valid config",
135
+ config: &Config{
136
+ Port: 8000,
137
+ APIKey: "test-key",
138
+ Timeout: 30,
139
+ MaxInputLength: 1000,
140
+ },
141
+ wantErr: false,
142
+ },
143
+ {
144
+ name: "invalid port - too low",
145
+ config: &Config{
146
+ Port: 0,
147
+ APIKey: "test-key",
148
+ Timeout: 30,
149
+ MaxInputLength: 1000,
150
+ },
151
+ wantErr: true,
152
+ },
153
+ {
154
+ name: "invalid port - too high",
155
+ config: &Config{
156
+ Port: 70000,
157
+ APIKey: "test-key",
158
+ Timeout: 30,
159
+ MaxInputLength: 1000,
160
+ },
161
+ wantErr: true,
162
+ },
163
+ {
164
+ name: "missing API key",
165
+ config: &Config{
166
+ Port: 8000,
167
+ APIKey: "",
168
+ Timeout: 30,
169
+ MaxInputLength: 1000,
170
+ },
171
+ wantErr: true,
172
+ },
173
+ {
174
+ name: "invalid timeout",
175
+ config: &Config{
176
+ Port: 8000,
177
+ APIKey: "test-key",
178
+ Timeout: 0,
179
+ MaxInputLength: 1000,
180
+ },
181
+ wantErr: true,
182
+ },
183
+ {
184
+ name: "invalid max input length",
185
+ config: &Config{
186
+ Port: 8000,
187
+ APIKey: "test-key",
188
+ Timeout: 30,
189
+ MaxInputLength: 0,
190
+ },
191
+ wantErr: true,
192
+ },
193
+ }
194
+
195
+ for _, tt := range tests {
196
+ t.Run(tt.name, func(t *testing.T) {
197
+ err := tt.config.validate()
198
+ if (err != nil) != tt.wantErr {
199
+ t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
200
+ }
201
+ })
202
+ }
203
+ }
docker-compose.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ cursor2api:
5
+ build: .
6
+ container_name: cursor2api-go
7
+ restart: unless-stopped
8
+ ports:
9
+ - "8002:8002"
10
+ environment:
11
+ # 服务器配置
12
+ - PORT=8002
13
+ - DEBUG=false
14
+
15
+ # API 配置(⚠️ 生产环境请修改默认密钥)
16
+ - API_KEY=0000
17
+ - MODELS=claude-sonnet-4.6
18
+ - SYSTEM_PROMPT_INJECT=
19
+
20
+ # 请求配置
21
+ - TIMEOUT=60
22
+ - MAX_INPUT_LENGTH=200000
23
+
24
+ # 浏览器指纹配置
25
+ - USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
26
+ - UNMASKED_VENDOR_WEBGL=Google Inc. (Intel)
27
+ - UNMASKED_RENDERER_WEBGL=ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)
28
+
29
+ # Cursor 配置
30
+ - SCRIPT_URL=https://cursor.com/_next/static/chunks/pages/_app.js
31
+ healthcheck:
32
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8002/health"]
33
+ interval: 30s
34
+ timeout: 10s
35
+ retries: 3
36
+ start_period: 5s
37
+ networks:
38
+ - cursor2api-network
39
+
40
+ networks:
41
+ cursor2api-network:
42
+ driver: bridge
docs/API_CAPABILITIES.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Capabilities
2
+
3
+ ## Supported
4
+
5
+ - OpenAI-compatible `POST /v1/chat/completions`
6
+ - OpenAI-compatible `GET /v1/models`
7
+ - Non-stream responses with:
8
+ - plain assistant text
9
+ - assistant `tool_calls`
10
+ - Stream responses with:
11
+ - `delta.content`
12
+ - `delta.tool_calls`
13
+ - Multi-turn context via the `messages` array
14
+ - Tool request fields:
15
+ - `tools`
16
+ - `tool_choice`
17
+ - assistant history `tool_calls`
18
+ - tool role history `tool_call_id`
19
+ - Automatically derived `*-thinking` public models, for example:
20
+ - `claude-sonnet-4.6`
21
+ - `claude-sonnet-4.6-thinking`
22
+ - Bearer token auth via `Authorization: Bearer <API_KEY>`
23
+
24
+ ## Behavior Notes
25
+
26
+ - Tool support is implemented through an internal prompt-and-parser bridge, not Cursor-native tool calling.
27
+ - Base models keep the current model name and enable tool use.
28
+ - `*-thinking` models map back to the same upstream base model, but also enable the internal thinking protocol.
29
+ - Thinking is an internal bridge capability only. The public OpenAI response does not expose a separate reasoning field.
30
+
31
+ ## Not Supported
32
+
33
+ - Anthropic `/v1/messages`
34
+ - MCP orchestration
35
+ - Native upstream OpenAI tool execution
36
+ - Exposed reasoning/thinking response fields
37
+ - Local filesystem or OS command execution through the API
38
+
39
+ ## Example: Non-Stream Tool Call
40
+
41
+ ```bash
42
+ curl -X POST http://127.0.0.1:8002/v1/chat/completions \
43
+ -H "Content-Type: application/json" \
44
+ -H "Authorization: Bearer 0000" \
45
+ -d '{
46
+ "model": "claude-sonnet-4.6",
47
+ "stream": false,
48
+ "messages": [
49
+ {"role": "user", "content": "帮我查询北京天气"}
50
+ ],
51
+ "tools": [
52
+ {
53
+ "type": "function",
54
+ "function": {
55
+ "name": "get_weather",
56
+ "description": "Get current weather",
57
+ "parameters": {
58
+ "type": "object",
59
+ "properties": {
60
+ "city": {"type": "string"}
61
+ },
62
+ "required": ["city"]
63
+ }
64
+ }
65
+ }
66
+ ]
67
+ }'
68
+ ```
69
+
70
+ ## Example: Thinking Model
71
+
72
+ ```bash
73
+ curl -X POST http://127.0.0.1:8002/v1/chat/completions \
74
+ -H "Content-Type: application/json" \
75
+ -H "Authorization: Bearer 0000" \
76
+ -d '{
77
+ "model": "claude-sonnet-4.6-thinking",
78
+ "stream": true,
79
+ "messages": [
80
+ {"role": "user", "content": "先思考,再决定是否需要工具"}
81
+ ],
82
+ "tools": [
83
+ {
84
+ "type": "function",
85
+ "function": {
86
+ "name": "lookup",
87
+ "parameters": {
88
+ "type": "object",
89
+ "properties": {
90
+ "q": {"type": "string"}
91
+ },
92
+ "required": ["q"]
93
+ }
94
+ }
95
+ }
96
+ ]
97
+ }'
98
+ ```
docs/DYNAMIC_HEADERS.md ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 动态 Header 改进说明
2
+
3
+ ## 问题背景
4
+
5
+ 之前的实现中,HTTP headers 是硬编码的:
6
+ - `sec-ch-ua-platform` 固定为 `"macOS"` 或 `"Windows"`
7
+ - `sec-ch-ua` 固定为特定的 Chrome 版本
8
+ - `Referer` 和 `accept-language` 固定不变
9
+
10
+ 这种硬编码的方式容易被 Cursor API 识别为异常请求,导致 403 错误。
11
+
12
+ ## 改进方案
13
+
14
+ ### 1. 动态浏览器指纹生成器 (`utils/headers.go`)
15
+
16
+ 创建了 `HeaderGenerator` 类,实现以下功能:
17
+
18
+ #### 智能平台选择
19
+ - 根据当前操作系统自动选择合适的浏览器配置
20
+ - macOS: 支持 Intel (x86) 和 Apple Silicon (arm) 架构
21
+ - Windows: 支持多个版本 (10.0, 11.0, 15.0)
22
+ - Linux: 标准 x86_64 配置
23
+
24
+ #### 随机化配置
25
+ - **Chrome 版本**: 从 120-130 随机选择
26
+ - **语言设置**: 支持 en-US, zh-CN, en-GB, ja-JP
27
+ - **Referer**: 随机选择不同的 Cursor 页面
28
+ - **User-Agent**: 根据平台和版本动态生成
29
+
30
+ #### 真实的浏览器指纹
31
+ 生成的 headers 包含完整的浏览器指纹信息:
32
+ ```json
33
+ {
34
+ "sec-ch-ua-platform": "macOS",
35
+ "sec-ch-ua-platform-version": "14.0.0",
36
+ "sec-ch-ua-arch": "arm",
37
+ "sec-ch-ua-bitness": "64",
38
+ "sec-ch-ua": "\"Google Chrome\";v=\"126\", \"Chromium\";v=\"126\", \"Not(A:Brand\";v=\"24\"",
39
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36..."
40
+ }
41
+ ```
42
+
43
+ ### 2. 自动刷新机制
44
+
45
+ 当遇到 403 错误时:
46
+ 1. 自动刷新浏览器指纹配置
47
+ 2. 清除 x-is-human token 缓存
48
+ 3. 使用新的配置重试请求
49
+
50
+ ### 3. 代码改进
51
+
52
+ #### 服务初始化
53
+ ```go
54
+ type CursorService struct {
55
+ // ... 其他字段
56
+ headerGenerator *utils.HeaderGenerator
57
+ }
58
+
59
+ func NewCursorService(cfg *config.Config) *CursorService {
60
+ return &CursorService{
61
+ // ... 其他初始化
62
+ headerGenerator: utils.NewHeaderGenerator(),
63
+ }
64
+ }
65
+ ```
66
+
67
+ #### Headers 生成
68
+ ```go
69
+ // 之前:硬编码
70
+ func (s *CursorService) chatHeaders(xIsHuman string) map[string]string {
71
+ return map[string]string{
72
+ "sec-ch-ua-platform": `"macOS"`, // 固定值
73
+ "sec-ch-ua": `"Google Chrome";v="143"...`, // 固定版本
74
+ // ...
75
+ }
76
+ }
77
+
78
+ // 现在:动态生成
79
+ func (s *CursorService) chatHeaders(xIsHuman string) map[string]string {
80
+ return s.headerGenerator.GetChatHeaders(xIsHuman)
81
+ }
82
+ ```
83
+
84
+ #### 403 错误处理
85
+ ```go
86
+ if resp.StatusCode == http.StatusForbidden && attempt < maxRetries {
87
+ logrus.Warn("Received 403, refreshing browser fingerprint...")
88
+
89
+ // 刷新浏览器指纹
90
+ s.headerGenerator.Refresh()
91
+
92
+ // 清除 token 缓存
93
+ s.scriptMutex.Lock()
94
+ s.scriptCache = ""
95
+ s.scriptCacheTime = time.Time{}
96
+ s.scriptMutex.Unlock()
97
+
98
+ // 重试
99
+ continue
100
+ }
101
+ ```
102
+
103
+ ## 优势
104
+
105
+ ### 1. 更难被检测
106
+ - 每次请求的指纹信息都可能不同
107
+ - 模拟真实用户的多样性
108
+ - 避免固定模式被识别
109
+
110
+ ### 2. 自动适应
111
+ - 根据运行环境自动选择合适的配置
112
+ - macOS 上运行自动使用 macOS 配置
113
+ - Windows 上运行自动使用 Windows 配置
114
+
115
+ ### 3. 更好的容错性
116
+ - 遇到 403 错误自动切换配置
117
+ - 增加请求成功率
118
+ - 减少人工干预
119
+
120
+ ### 4. 易于维护
121
+ - 集中管理浏览器配置
122
+ - 易于添加新的平台或版本
123
+ - 代码更简洁清晰
124
+
125
+ ## 测试结果
126
+
127
+ 运行测试程序可以看到:
128
+ ```
129
+ 浏览器配置:
130
+ 平台: macOS
131
+ 平台版本: 14.0.0
132
+ 架构: arm
133
+ 位数: 64
134
+ Chrome 版本: 126
135
+ User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...
136
+
137
+ 生成 5 个随机配置:
138
+ 1. macOS | Chrome 130 | arm
139
+ 2. macOS | Chrome 125 | arm
140
+ 3. macOS | Chrome 130 | x86
141
+ 4. macOS | Chrome 128 | arm
142
+ 5. macOS | Chrome 122 | arm
143
+ ```
144
+
145
+ 每次生成的配置都不同,增加了多样性。
146
+
147
+ ## 使用方法
148
+
149
+ 无需任何配置,直接使用即可:
150
+
151
+ ```bash
152
+ # 重新编译
153
+ go build -o cursor2api-go
154
+
155
+ # 运行服务
156
+ ./cursor2api-go
157
+ ```
158
+
159
+ 服务会自动:
160
+ - 根据操作系统选择合适的浏览器配置
161
+ - 为每个请求生成动态 headers
162
+ - 遇到 403 错误时自动刷新配置并重试
163
+
164
+ ## 日志示例
165
+
166
+ 启用调试模式后可以看到:
167
+ ```
168
+ DEBU Sending request to Cursor API attempt=1 model=claude-sonnet-4.6
169
+ WARN Received 403 Access Denied, refreshing browser fingerprint...
170
+ DEBU Refreshed browser fingerprint platform=macOS chrome_version=124
171
+ DEBU Sending request to Cursor API attempt=2 model=claude-sonnet-4.6
172
+ ```
173
+
174
+ ## 未来改进
175
+
176
+ 可以考虑的进一步优化:
177
+ 1. 添加更多浏览器类型 (Firefox, Safari)
178
+ 2. 支持移动设备指纹
179
+ 3. 根据成功率动态调整配置策略
180
+ 4. 添加指纹轮换策略 (定期刷新)
docs/STARTUP_OPTIMIZATION.md ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 启动日志优化说明
2
+
3
+ ## 优化前 vs 优化后
4
+
5
+ ### 优化前(调试模式)
6
+ 启动时会显示大量 GIN 框架的调试信息:
7
+ ```
8
+ [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
9
+ - using env: export GIN_MODE=release
10
+ - using code: gin.SetMode(gin.ReleaseMode)
11
+
12
+ [GIN-debug] GET /health --> main.setupRoutes.func1 (5 handlers)
13
+ [GIN-debug] GET / --> cursor2api-go/handlers.(*Handler).ServeDocs-fm (5 handlers)
14
+ [GIN-debug] GET /v1/models --> cursor2api-go/handlers.(*Handler).ListModels-fm (6 handlers)
15
+ [GIN-debug] POST /v1/chat/completions --> cursor2api-go/handlers.(*Handler).ChatCompletions-fm (6 handlers)
16
+ [GIN-debug] GET /static/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (5 handlers)
17
+ [GIN-debug] HEAD /static/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (5 handlers)
18
+ INFO[0000] Starting Cursor2API server on port 8002
19
+ ```
20
+
21
+ ### 优化后(简洁模式,默认)
22
+ 启动时只显示必要的服务信息:
23
+ ```
24
+ ╔══════════════════════════════════════════════════════════════╗
25
+ ║ Cursor2API Server ║
26
+ ╚══════════════════════════════════════════════════════════════╝
27
+
28
+ 🚀 服务地址: http://localhost:8002
29
+ 📚 API 文档: http://localhost:8002/
30
+ 💊 健康检查: http://localhost:8002/health
31
+ 🔑 API 密钥: 0000
32
+ 🤖 支持模型: 仅 `claude-sonnet-4.6` 与 `claude-sonnet-4.6-thinking`(thinking 为派生公开模型)
33
+
34
+ ✨ 服务已启动,按 Ctrl+C 停止
35
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
+ ```
37
+
38
+ ## 主要改进
39
+
40
+ ### 1. 默认使用简洁模式
41
+ - 修改 `.env.example` 中 `DEBUG=false`
42
+ - 生产环境默认不显示调试信息
43
+ - 启动输出更清爽、专业
44
+
45
+ ### 2. 美观的启动横幅
46
+ - 使用 Unicode 框线字符绘制横幅
47
+ - 使用 Emoji 图标增强可读性
48
+ - 清晰展示关键信息:
49
+ - 🚀 服务地址
50
+ - 📚 API 文档
51
+ - 💊 健康检查
52
+ - 🔑 API 密钥
53
+ - 🤖 支持的模型
54
+
55
+ ### 3. 条件性日志输出
56
+ - 只在 `DEBUG=true` 时显示详细日志
57
+ - GIN 的 Logger 中间件仅在调试模式启用
58
+ - 减少生产环境的日志噪音
59
+
60
+ ### 4. 智能模型显示
61
+ - 模型数量 > 3 时,只显示第一个和总数
62
+ - 避免启动信息过长
63
+ - 保持输出简洁
64
+
65
+ ## 代码改进
66
+
67
+ ### main.go
68
+ ```go
69
+ // 设置日志级别和 GIN 模式
70
+ if cfg.Debug {
71
+ logrus.SetLevel(logrus.DebugLevel)
72
+ gin.SetMode(gin.DebugMode)
73
+ } else {
74
+ logrus.SetLevel(logrus.InfoLevel)
75
+ gin.SetMode(gin.ReleaseMode)
76
+ }
77
+
78
+ // 只在 Debug 模式下启用 GIN 的日志
79
+ if cfg.Debug {
80
+ router.Use(gin.Logger())
81
+ }
82
+
83
+ // 打印启动信息
84
+ printStartupBanner(cfg)
85
+ ```
86
+
87
+ ### printStartupBanner 函数
88
+ ```go
89
+ func printStartupBanner(cfg *config.Config) {
90
+ banner := `
91
+ ╔══════════════════════════════════════════════════════════════╗
92
+ ║ Cursor2API Server ║
93
+ ╚══════════════════════════════════════════════════════════════╝
94
+ `
95
+ fmt.Println(banner)
96
+
97
+ fmt.Printf("🚀 服务地址: http://localhost:%d\n", cfg.Port)
98
+ fmt.Printf("📚 API 文档: http://localhost:%d/\n", cfg.Port)
99
+ fmt.Printf("💊 健康检查: http://localhost:%d/health\n", cfg.Port)
100
+ fmt.Printf("🔑 API 密钥: %s\n", cfg.APIKey)
101
+
102
+ models := cfg.GetModels()
103
+ if len(models) > 3 {
104
+ fmt.Printf("🤖 支持模型: %s 等 %d 个模型\n", models[0], len(models))
105
+ } else {
106
+ fmt.Printf("🤖 支持模型: %v\n", models)
107
+ }
108
+
109
+ if cfg.Debug {
110
+ fmt.Println("🐛 调试模式: 已启用")
111
+ }
112
+
113
+ fmt.Println("\n✨ 服务已启动,按 Ctrl+C 停止")
114
+ fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
115
+ }
116
+ ```
117
+
118
+ ## 使用方法
119
+
120
+ ### 简洁模式(默认)
121
+ ```bash
122
+ ./cursor2api-go
123
+ ```
124
+
125
+ 或者使用启动脚本:
126
+ ```bash
127
+ ./start.sh
128
+ ```
129
+
130
+ ### 调试模式
131
+
132
+ **方式 1**: 修改 `.env` 文件
133
+ ```bash
134
+ DEBUG=true
135
+ ```
136
+
137
+ **方式 2**: 临时启用
138
+ ```bash
139
+ DEBUG=true ./cursor2api-go
140
+ ```
141
+
142
+ ### 调试模式会显示
143
+ - ✅ 详细的 GIN 路由信息
144
+ - ✅ 每个请求的详���日志
145
+ - ✅ x-is-human token 信息
146
+ - ✅ 浏览器指纹配置
147
+ - ✅ 重试和错误处理详情
148
+
149
+ ## 优势
150
+
151
+ 1. **更专业** - 简洁的输出适合生产环境
152
+ 2. **更清晰** - 关键信息一目了然
153
+ 3. **更美观** - 使用 Unicode 和 Emoji 增强视觉效果
154
+ 4. **更灵活** - 可以随时切换调试模式
155
+ 5. **更友好** - 新用户更容易理解服务状态
156
+
157
+ ## 兼容性
158
+
159
+ - ✅ 完全向后兼容
160
+ - ✅ 不影响现有功能
161
+ - ✅ 可以随时切换模式
162
+ - ✅ 支持所有平台(Windows/macOS/Linux)
163
+
164
+ ## 注意事项
165
+
166
+ 1. **首次使用**: 需要更新 `.env` 文件(或删除后重新生成)
167
+ 2. **调试问题**: 遇到问题时,建议启用 `DEBUG=true` 查看详细日志
168
+ 3. **生产部署**: 建议使用 `DEBUG=false` 以获得最佳性能和简洁输出
docs/images/home.png ADDED

Git LFS Details

  • SHA256: 6060e09d266e8dd6502789929a092038958d5e46840f251ed46659b751afb207
  • Pointer size: 131 Bytes
  • Size of remote file: 424 kB
docs/images/play1.png ADDED

Git LFS Details

  • SHA256: 17de2aaf118264b9bde51ddac050f6ba458d55b165b997a4ed4cbd004223a456
  • Pointer size: 131 Bytes
  • Size of remote file: 162 kB
docs/images/play2.png ADDED

Git LFS Details

  • SHA256: 8e917060e04526c8d371011d12b731afded678d3be1ad13c8af22ce6bfee2297
  • Pointer size: 131 Bytes
  • Size of remote file: 125 kB
go.md ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cursor2API Go 实现说明:OpenAI `chat/completions` 下的 thinking + tools
2
+
3
+ 本文档只描述当前 Go 仓库已经实现或明确约束的行为,不再沿用旧的 Deno `/v1/messages` 迁移口径。
4
+
5
+ ## 1. 当前范围
6
+
7
+ 当前仓库只支持两个公开接口:
8
+
9
+ - `GET /v1/models`
10
+ - `POST /v1/chat/completions`
11
+
12
+ 不实现 `/v1/messages`,也不承诺 Anthropic 原生 block/SSE 兼容。
13
+
14
+ ## 2. 真实能力边界
15
+
16
+ ### 2.1 公开模型目录
17
+
18
+ 配置项 `MODELS` 只填写基础模型,例如:
19
+
20
+ ```env
21
+ MODELS=claude-sonnet-4.6
22
+ ```
23
+
24
+ 服务会自动向外暴露两类模型:
25
+
26
+ - 基础模型:`claude-sonnet-4.6`
27
+ - thinking 派生模型:`claude-sonnet-4.6-thinking`
28
+
29
+ 约定如下:
30
+
31
+ - 基础模型:保留现有模型名,启用 tool 能力。
32
+ - `-thinking` 模型:自动映射回基础模型出站,同时启用 thinking 和 tool 能力。
33
+ - 不允许继续派生 `-thinking-thinking`。
34
+
35
+ ### 2.2 外部接口契约
36
+
37
+ 当前实现的是 OpenAI `chat/completions` 兼容面:
38
+
39
+ - 请求支持 `messages`
40
+ - 请求支持 `tools`
41
+ - 请求支持 `tool_choice`
42
+ - assistant 历史消息支持 `tool_calls`
43
+ - tool 历史消息支持 `tool_call_id`
44
+ - 响应支持非流式 `message.tool_calls`
45
+ - 响应支持流式 `delta.tool_calls`
46
+
47
+ thinking 不作为 OpenAI 独立字段对外暴露。它只作为内部 prompt 协议与解析协议使用。
48
+
49
+ ## 3. 请求侧链路
50
+
51
+ ### 3.1 入口文件
52
+
53
+ - `handlers/handler.go`
54
+ - `services/cursor.go`
55
+ - `services/cursor_protocol.go`
56
+
57
+ ### 3.2 处理步骤
58
+
59
+ 1. `handlers.ChatCompletions` 绑定并校验 OpenAI 请求。
60
+ 2. `CursorService.buildCursorRequest(...)` 解析模型能力:
61
+ - `claude-sonnet-4.6` -> 基础模型
62
+ - `claude-sonnet-4.6-thinking` -> 基础模型 + thinking 开启
63
+ 3. `tool_choice` 被解析为三类模式:
64
+ - `auto`
65
+ - `none`
66
+ - `required`
67
+ - 或 `{"type":"function","function":{"name":"..."}}`
68
+ 4. 工具定义被统一校验:
69
+ - 仅支持 `type=function`
70
+ - `function.name` 必填
71
+ - 不允许重名
72
+ 5. 构造单次请求专用 `TriggerSignal`,格式:
73
+
74
+ ```text
75
+ <<CALL_xxxxxxxx>>
76
+ ```
77
+
78
+ 6. 构造发往 Cursor 的纯文本消息。
79
+
80
+ ## 4. 内部 prompt 协议
81
+
82
+ 上游 Cursor 仍然只收到文本消息,不使用原生 OpenAI tool calling。
83
+
84
+ ### 4.1 tool 协议
85
+
86
+ 当请求含有 `tools` 且 `tool_choice != "none"` 时,system message 会注入:
87
+
88
+ - 工具桥接说明
89
+ - tool 调用格式约束
90
+ - `<function_list>` 工具清单
91
+ - `required` / 指定 function 的额外约束
92
+
93
+ 模型被要求按如下格式输出工具调用:
94
+
95
+ ```text
96
+ <<CALL_xxxxxxxx>>
97
+ <invoke name="tool_name">{"arg":"value"}</invoke>
98
+ ```
99
+
100
+ ### 4.2 thinking 协议
101
+
102
+ 当请求命中 `*-thinking` 模型时:
103
+
104
+ - system prompt 注入 thinking 规则
105
+ - 每条 user message 追加固定 thinking hint
106
+
107
+ hint 为:
108
+
109
+ ```text
110
+ Use <thinking>...</thinking> for hidden reasoning when it helps. Keep your final visible answer outside the thinking tags.
111
+ ```
112
+
113
+ ### 4.3 历史消息回放
114
+
115
+ 为了让多轮 tool loop 继续工作,OpenAI 历史消息会被回放成内部文本协议:
116
+
117
+ - assistant `tool_calls` -> `TriggerSignal + <invoke ...>`
118
+ - tool message -> `<tool_result id="...">...</tool_result>`
119
+
120
+ 普通 `system/user/assistant` 文本则继续按 Cursor `parts[].text` 发送。
121
+
122
+ ## 5. 响应解析链路
123
+
124
+ ### 5.1 入口文件
125
+
126
+ - `services/cursor.go`
127
+ - `utils/cursor_protocol.go`
128
+ - `utils/utils.go`
129
+
130
+ ### 5.2 解析步骤
131
+
132
+ 1. `consumeSSE(...)` 读取 Cursor SSE。
133
+ 2. 每个 `delta` 文本片段进入 `CursorProtocolParser`。
134
+ 3. 解析器增量产出三类内部事件:
135
+ - `text`
136
+ - `thinking`
137
+ - `tool_call`
138
+ 4. `thinking` 只保留在内部事件层,不直接回写给客户端。
139
+
140
+ ### 5.3 解析规则
141
+
142
+ - `<thinking>...</thinking>` 在 thinking 模型下会被消费为内部 thinking 事件。
143
+ - `TriggerSignal + <invoke ...>` 会被消费为工具调用事件。
144
+ - 不完整标签会在流结束时降级为普通文本,避免丢内容。
145
+ - 多个连续 `<invoke>` 会逐个产出,不再只保留第一个。
146
+
147
+ ## 6. OpenAI 响应写回
148
+
149
+ ### 6.1 非流式
150
+
151
+ 非流式响应会在 `services.CursorService.ChatCompletionNonStream(...)` 中聚合内部事件:
152
+
153
+ - 只有文本时,返回普通 `message.content`
154
+ - 有工具调用时,返回 assistant `tool_calls`
155
+ - 若本轮出现工具调用,`finish_reason = "tool_calls"`
156
+ - 否则 `finish_reason = "stop"`
157
+
158
+ 为提升与部分编排器(例如 Kilo Code)在“必须用工具”场景下的兼容性:
159
+
160
+ - 当请求含 `tools` 且被判定为“必须至少调用一次工具”(`tool_choice=required/指定函数`,或启用 `KILO_TOOL_STRICT`)时,
161
+ 如果第一轮没有产出任何 `tool_calls`,服务会自动重试 1 次(仅非流式)。
162
+
163
+ ### 6.2 流式
164
+
165
+ `utils.StreamChatCompletion(...)` 会输出:
166
+
167
+ - 首个 role chunk:`assistant`
168
+ - 文本 chunk:`delta.content`
169
+ - 工具调用 chunk:`delta.tool_calls`
170
+ - 收尾 chunk:
171
+ - 有工具调用 -> `finish_reason = "tool_calls"`
172
+ - 无工具调用 -> `finish_reason = "stop"`
173
+
174
+ thinking 内容不会作为独立 OpenAI 字段透出。
175
+
176
+ ## 7. 代码落点
177
+
178
+ 本次能力集中在以下模块:
179
+
180
+ - `models/`
181
+ - OpenAI request/response/tool 类型
182
+ - thinking 模型派生规则
183
+ - 基础模型到 Cursor 模型的映射
184
+ - `config/`
185
+ - `MODELS` 基础模型配置
186
+ - 自动扩展 `*-thinking` 公开模型目录
187
+ - `services/cursor_protocol.go`
188
+ - `tool_choice` 解析
189
+ - 工具定义校验
190
+ - OpenAI 历史消息到 Cursor 文本协议的转换
191
+ - `utils/cursor_protocol.go`
192
+ - Cursor 文本增量到 `text/thinking/tool_call` 事件的解析
193
+ - `utils/utils.go`
194
+ - OpenAI 流式/非流式响应写回
195
+
196
+ ## 8. 测试基线
197
+
198
+ 当前测试覆盖以下关键路径:
199
+
200
+ - `MODELS` 自动扩展基础模型和 `-thinking` 模型
201
+ - thinking 模型回落到基础出站模型
202
+ - tool prompt 注入与 tool history 回放
203
+ - `<thinking>` / `<invoke>` 的增量解析
204
+ - 流式 `delta.tool_calls`
205
+ - 非流式 `message.tool_calls`
206
+ - 纯文本聊天回归不破坏
207
+
208
+ ## 9. 非目标
209
+
210
+ 当前不做以下内容:
211
+
212
+ - Anthropic `/v1/messages`
213
+ - MCP
214
+ - 原生 OpenAI tool execution
215
+ - 可见的 reasoning/thinking 对外字段
216
+ - 图像、文档、文件等多模态 block 协议
go.mod ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module cursor2api-go
2
+
3
+ go 1.24
4
+
5
+ require (
6
+ github.com/gin-gonic/gin v1.10.0
7
+ github.com/imroc/req/v3 v3.55.0
8
+ github.com/joho/godotenv v1.5.1
9
+ github.com/sirupsen/logrus v1.9.3
10
+ )
11
+
12
+ require (
13
+ github.com/andybalholm/brotli v1.2.0 // indirect
14
+ github.com/bytedance/sonic v1.11.6 // indirect
15
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
16
+ github.com/cloudflare/circl v1.6.1 // indirect
17
+ github.com/cloudwego/base64x v0.1.4 // indirect
18
+ github.com/cloudwego/iasm v0.2.0 // indirect
19
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
20
+ github.com/gin-contrib/sse v0.1.0 // indirect
21
+ github.com/go-playground/locales v0.14.1 // indirect
22
+ github.com/go-playground/universal-translator v0.18.1 // indirect
23
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
24
+ github.com/goccy/go-json v0.10.2 // indirect
25
+ github.com/google/go-querystring v1.1.0 // indirect
26
+ github.com/icholy/digest v1.1.0 // indirect
27
+ github.com/json-iterator/go v1.1.12 // indirect
28
+ github.com/klauspost/compress v1.18.0 // indirect
29
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
30
+ github.com/leodido/go-urn v1.4.0 // indirect
31
+ github.com/mattn/go-isatty v0.0.20 // indirect
32
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
33
+ github.com/modern-go/reflect2 v1.0.2 // indirect
34
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
35
+ github.com/quic-go/qpack v0.5.1 // indirect
36
+ github.com/quic-go/quic-go v0.53.0 // indirect
37
+ github.com/refraction-networking/utls v1.7.3 // indirect
38
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
39
+ github.com/ugorji/go/codec v1.2.12 // indirect
40
+ go.uber.org/mock v0.5.2 // indirect
41
+ golang.org/x/arch v0.8.0 // indirect
42
+ golang.org/x/crypto v0.39.0 // indirect
43
+ golang.org/x/mod v0.25.0 // indirect
44
+ golang.org/x/net v0.41.0 // indirect
45
+ golang.org/x/sync v0.15.0 // indirect
46
+ golang.org/x/sys v0.33.0 // indirect
47
+ golang.org/x/text v0.26.0 // indirect
48
+ golang.org/x/tools v0.34.0 // indirect
49
+ google.golang.org/protobuf v1.34.1 // indirect
50
+ gopkg.in/yaml.v3 v3.0.1 // indirect
51
+ )
go.sum ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
2
+ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
3
+ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
4
+ github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
5
+ github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
6
+ github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
7
+ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
8
+ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
9
+ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
10
+ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
11
+ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
12
+ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
13
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
15
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16
+ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
17
+ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
18
+ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
19
+ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
20
+ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
21
+ github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
22
+ github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
23
+ github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
24
+ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
25
+ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
26
+ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
27
+ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
28
+ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
29
+ github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
30
+ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
31
+ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
32
+ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
33
+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
34
+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
35
+ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
36
+ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
37
+ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
38
+ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
39
+ github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
40
+ github.com/imroc/req/v3 v3.55.0 h1:vg2Q33TGU12wZWZyPkiPbCGGTeiOmlEOdOwHLH03//I=
41
+ github.com/imroc/req/v3 v3.55.0/go.mod h1:MOn++r2lE4+du3nuefTaPGQ6pY3/yRP2r1pFK1BUqq0=
42
+ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
43
+ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
44
+ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
45
+ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
46
+ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
47
+ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
48
+ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
49
+ github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
50
+ github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
51
+ github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
52
+ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
53
+ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
54
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
55
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
56
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
57
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
58
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
59
+ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
60
+ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
61
+ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
62
+ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
63
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
64
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
65
+ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
66
+ github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
67
+ github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
68
+ github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
69
+ github.com/refraction-networking/utls v1.7.3 h1:L0WRhHY7Oq1T0zkdzVZMR6zWZv+sXbHB9zcuvsAEqCo=
70
+ github.com/refraction-networking/utls v1.7.3/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ=
71
+ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
72
+ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
73
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
74
+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
75
+ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
76
+ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
77
+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
78
+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
79
+ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
80
+ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
81
+ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
82
+ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
83
+ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
84
+ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
85
+ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
86
+ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
87
+ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
88
+ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
89
+ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
90
+ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
91
+ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
92
+ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
93
+ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
94
+ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
95
+ golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
96
+ golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
97
+ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
98
+ golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
99
+ golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
100
+ golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
101
+ golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
102
+ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
103
+ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
104
+ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105
+ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
107
+ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
108
+ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
109
+ golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
110
+ golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
111
+ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
112
+ golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
113
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
114
+ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
115
+ google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
116
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
117
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
118
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
119
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
120
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
121
+ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
122
+ gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
123
+ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
124
+ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
handlers/handler.go ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package handlers
22
+
23
+ import (
24
+ "cursor2api-go/config"
25
+ "cursor2api-go/middleware"
26
+ "cursor2api-go/models"
27
+ "cursor2api-go/services"
28
+ "cursor2api-go/utils"
29
+ "net/http"
30
+ "os"
31
+ "time"
32
+
33
+ "github.com/gin-gonic/gin"
34
+ "github.com/sirupsen/logrus"
35
+ )
36
+
37
+ // Handler 处理器结构
38
+ type Handler struct {
39
+ config *config.Config
40
+ cursorService *services.CursorService
41
+ docsContent []byte
42
+ }
43
+
44
+ // NewHandler 创建新的处理器
45
+ func NewHandler(cfg *config.Config) *Handler {
46
+ cursorService := services.NewCursorService(cfg)
47
+
48
+ // 预加载文档内容
49
+ docsPath := "static/docs.html"
50
+ var docsContent []byte
51
+
52
+ if data, err := os.ReadFile(docsPath); err == nil {
53
+ docsContent = data
54
+ } else {
55
+ // 如果文件不存在,使用默认的简单HTML页面
56
+ simpleHTML := `
57
+ <!DOCTYPE html>
58
+ <html lang="en">
59
+ <head>
60
+ <meta charset="UTF-8">
61
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
62
+ <title>Cursor2API - Go Version</title>
63
+ <style>
64
+ body {
65
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
66
+ max-width: 800px;
67
+ margin: 50px auto;
68
+ padding: 20px;
69
+ background-color: #f5f5f5;
70
+ }
71
+ .container {
72
+ background: white;
73
+ padding: 30px;
74
+ border-radius: 10px;
75
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
76
+ }
77
+ h1 {
78
+ color: #333;
79
+ border-bottom: 2px solid #007bff;
80
+ padding-bottom: 10px;
81
+ }
82
+ .info {
83
+ background: #f8f9fa;
84
+ padding: 20px;
85
+ border-radius: 8px;
86
+ margin: 20px 0;
87
+ border-left: 4px solid #007bff;
88
+ }
89
+ code {
90
+ background: #e9ecef;
91
+ padding: 2px 6px;
92
+ border-radius: 4px;
93
+ font-family: 'Courier New', monospace;
94
+ }
95
+ .endpoint {
96
+ background: #e3f2fd;
97
+ padding: 10px;
98
+ margin: 10px 0;
99
+ border-radius: 5px;
100
+ border-left: 3px solid #2196f3;
101
+ }
102
+ .status-ok {
103
+ color: #28a745;
104
+ font-weight: bold;
105
+ }
106
+ </style>
107
+ </head>
108
+ <body>
109
+ <div class="container">
110
+ <h1>🚀 Cursor2API - Go Version</h1>
111
+
112
+ <div class="info">
113
+ <p><strong>Status:</strong> <span class="status-ok">✅ Running</span></p>
114
+ <p><strong>Version:</strong> Go Implementation</p>
115
+ <p><strong>Description:</strong> OpenAI-compatible chat completions proxy for Cursor AI</p>
116
+ </div>
117
+
118
+ <div class="info">
119
+ <h3>📡 Available Endpoints:</h3>
120
+ <div class="endpoint">
121
+ <strong>GET</strong> <code>/v1/models</code><br>
122
+ <small>List available AI models</small>
123
+ </div>
124
+ <div class="endpoint">
125
+ <strong>POST</strong> <code>/v1/chat/completions</code><br>
126
+ <small>Create chat completion (supports streaming and tool calls)</small>
127
+ </div>
128
+ <div class="endpoint">
129
+ <strong>GET</strong> <code>/health</code><br>
130
+ <small>Health check endpoint</small>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="info">
135
+ <h3>🔐 Authentication:</h3>
136
+ <p>Use Bearer token authentication:</p>
137
+ <code>Authorization: Bearer YOUR_API_KEY</code>
138
+ <p><small>Default API key: <code>0000</code> (change via API_KEY environment variable)</small></p>
139
+ </div>
140
+
141
+ <div class="info">
142
+ <h3>💻 Example Usage:</h3>
143
+ <pre><code>curl -X POST http://localhost:8002/v1/chat/completions \
144
+ -H "Content-Type: application/json" \
145
+ -H "Authorization: Bearer 0000" \
146
+ -d '{
147
+ "model": "claude-sonnet-4.6-thinking",
148
+ "messages": [
149
+ {"role": "user", "content": "Plan first, then decide whether a tool is needed."}
150
+ ],
151
+ "stream": true
152
+ }'</code></pre>
153
+ </div>
154
+
155
+ <div class="info">
156
+ <p><strong>Repository:</strong> <a href="https://github.com/cursor2api/cursor2api-go">cursor2api-go</a></p>
157
+ <p><strong>Documentation:</strong> OpenAI API compatible</p>
158
+ </div>
159
+ </div>
160
+ </body>
161
+ </html>`
162
+ docsContent = []byte(simpleHTML)
163
+ }
164
+
165
+ return &Handler{
166
+ config: cfg,
167
+ cursorService: cursorService,
168
+ docsContent: docsContent,
169
+ }
170
+
171
+ }
172
+
173
+ // ListModels 列出可用模型
174
+ func (h *Handler) ListModels(c *gin.Context) {
175
+ modelNames := h.config.GetModels()
176
+ modelList := make([]models.Model, 0, len(modelNames))
177
+
178
+ for _, modelID := range modelNames {
179
+ // 获取模型配置信息
180
+ modelConfig, exists := models.GetModelConfig(modelID)
181
+
182
+ model := models.Model{
183
+ ID: modelID,
184
+ Object: "model",
185
+ Created: time.Now().Unix(),
186
+ OwnedBy: "cursor2api",
187
+ }
188
+
189
+ // 如果找到模型配置,添加max_tokens和context_window信息
190
+ if exists {
191
+ model.MaxTokens = modelConfig.MaxTokens
192
+ model.ContextWindow = modelConfig.ContextWindow
193
+ }
194
+
195
+ modelList = append(modelList, model)
196
+ }
197
+
198
+ response := models.ModelsResponse{
199
+ Object: "list",
200
+ Data: modelList,
201
+ }
202
+
203
+ c.JSON(http.StatusOK, response)
204
+ }
205
+
206
+ // ChatCompletions 处理聊天完成请求
207
+ func (h *Handler) ChatCompletions(c *gin.Context) {
208
+ var request models.ChatCompletionRequest
209
+ if err := c.ShouldBindJSON(&request); err != nil {
210
+ logrus.WithError(err).Error("Failed to bind request")
211
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
212
+ "Invalid request format",
213
+ "invalid_request_error",
214
+ "invalid_json",
215
+ ))
216
+ return
217
+ }
218
+
219
+ // 验证模型
220
+ if !h.config.IsValidModel(request.Model) {
221
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
222
+ "Invalid model specified",
223
+ "invalid_request_error",
224
+ "model_not_found",
225
+ ))
226
+ return
227
+ }
228
+
229
+ // 验证消息
230
+ if len(request.Messages) == 0 {
231
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
232
+ "Messages cannot be empty",
233
+ "invalid_request_error",
234
+ "missing_messages",
235
+ ))
236
+ return
237
+ }
238
+
239
+ // 验证并调整max_tokens参数
240
+ request.MaxTokens = models.ValidateMaxTokens(request.Model, request.MaxTokens)
241
+
242
+ // 调用Cursor服务
243
+ // 根据是否流式返回不同响应
244
+ if request.Stream {
245
+ chatGenerator, err := h.cursorService.ChatCompletion(c.Request.Context(), &request)
246
+ if err != nil {
247
+ logrus.WithError(err).Error("Failed to create chat completion")
248
+ middleware.HandleError(c, err)
249
+ return
250
+ }
251
+ utils.SafeStreamWrapper(utils.StreamChatCompletion, c, chatGenerator, request.Model)
252
+ } else {
253
+ resp, err := h.cursorService.ChatCompletionNonStream(c.Request.Context(), &request)
254
+ if err != nil {
255
+ logrus.WithError(err).Error("Failed to create non-stream chat completion")
256
+ middleware.HandleError(c, err)
257
+ return
258
+ }
259
+ c.JSON(http.StatusOK, resp)
260
+ }
261
+ }
262
+
263
+ // ServeDocs 服务API文档页面
264
+ func (h *Handler) ServeDocs(c *gin.Context) {
265
+ c.Data(http.StatusOK, "text/html; charset=utf-8", h.docsContent)
266
+ }
267
+
268
+ // Health 健康检查
269
+ func (h *Handler) Health(c *gin.Context) {
270
+ c.JSON(http.StatusOK, gin.H{
271
+ "status": "ok",
272
+ "timestamp": time.Now().Unix(),
273
+ "version": "go-1.0.0",
274
+ })
275
+ }
jscode/env.js ADDED
The diff for this file is too large to render. See raw diff
 
jscode/main.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ global.cursor_config = {
2
+ currentScriptSrc: "$$currentScriptSrc$$",
3
+ fp:{
4
+ UNMASKED_VENDOR_WEBGL:"$$UNMASKED_VENDOR_WEBGL$$",
5
+ UNMASKED_RENDERER_WEBGL:"$$UNMASKED_RENDERER_WEBGL$$",
6
+ userAgent: "$$userAgent$$"
7
+ }
8
+ }
9
+
10
+ $$env_jscode$$
11
+
12
+ let console_log = console.log;
13
+ console.log = function () {
14
+
15
+ }
16
+
17
+ dtavm = console;
18
+ delete __dirname;
19
+ delete __filename;
20
+
21
+ function proxy(obj, objname, type) {
22
+ function getMethodHandler(WatchName, target_obj) {
23
+ let methodhandler = {
24
+ apply(target, thisArg, argArray) {
25
+ if (this.target_obj) {
26
+ thisArg = this.target_obj
27
+ }
28
+ let result = Reflect.apply(target, thisArg, argArray)
29
+ if (target.name !== "toString") {
30
+ if (target.name === "addEventListener") {
31
+ dtavm.log(`调用者 => [${WatchName}] 函数名 => [${target.name}], 传参 => [${argArray[0]}], 结果 => [${result}].`)
32
+ } else if (WatchName === "window.console") {
33
+ } else {
34
+ dtavm.log(`调用者 => [${WatchName}] 函数名 => [${target.name}], 传参 => [${argArray}], 结果 => [${result}].`)
35
+ }
36
+ } else {
37
+ dtavm.log(`调用者 => [${WatchName}] 函数名 => [${target.name}], 传参 => [${argArray}], 结果 => [${result}].`)
38
+ }
39
+ return result
40
+ },
41
+ construct(target, argArray, newTarget) {
42
+ var result = Reflect.construct(target, argArray, newTarget)
43
+ dtavm.log(`调用者 => [${WatchName}] 构造函数名 => [${target.name}], 传参 => [${argArray}], 结果 => [${(result)}].`)
44
+ return result;
45
+ }
46
+ }
47
+ methodhandler.target_obj = target_obj
48
+ return methodhandler
49
+ }
50
+
51
+ function getObjhandler(WatchName) {
52
+ let handler = {
53
+ get(target, propKey, receiver) {
54
+ let result = target[propKey]
55
+ if (result instanceof Object) {
56
+ if (typeof result === "function") {
57
+ dtavm.log(`调用者 => [${WatchName}] 获取属性名 => [${propKey}] , 是个函数`)
58
+ return new Proxy(result, getMethodHandler(WatchName, target))
59
+ } else {
60
+ dtavm.log(`调用者 => [${WatchName}] 获取属性名 => [${propKey}], 结果 => [${(result)}]`);
61
+ }
62
+ return new Proxy(result, getObjhandler(`${WatchName}.${propKey}`))
63
+ }
64
+ if (typeof (propKey) !== "symbol") {
65
+ dtavm.log(`调用者 => [${WatchName}] 获取属性名 => [${propKey?.description ?? propKey}], 结果 => [${result}]`);
66
+ }
67
+ return result;
68
+ },
69
+ set(target, propKey, value, receiver) {
70
+ if (value instanceof Object) {
71
+ dtavm.log(`调用者 => [${WatchName}] 设置属性名 => [${propKey}], 值为 => [${(value)}]`);
72
+ } else {
73
+ dtavm.log(`调用者 => [${WatchName}] 设置属性名 => [${propKey}], 值为 => [${value}]`);
74
+ }
75
+ return Reflect.set(target, propKey, value, receiver);
76
+ },
77
+ has(target, propKey) {
78
+ var result = Reflect.has(target, propKey);
79
+ dtavm.log(`针对in操作符的代理has=> [${WatchName}] 有无属性名 => [${propKey}], 结果 => [${result}]`)
80
+ return result;
81
+ },
82
+ deleteProperty(target, propKey) {
83
+ var result = Reflect.deleteProperty(target, propKey);
84
+ dtavm.log(`拦截属性delete => [${WatchName}] 删除属性名 => [${propKey}], 结果 => [${result}]`)
85
+ return result;
86
+ },
87
+ defineProperty(target, propKey, attributes) {
88
+ var result = Reflect.defineProperty(target, propKey, attributes);
89
+ dtavm.log(`拦截对象define操作 => [${WatchName}] 待检索属性名 => [${propKey.toString()}] 属性描述 => [${(attributes)}], 结果 => [${result}]`)
90
+ // debugger
91
+ return result
92
+ },
93
+ getPrototypeOf(target) {
94
+ var result = Reflect.getPrototypeOf(target)
95
+ dtavm.log(`被代理的目标对象 => [${WatchName}] 代理结果 => [${(result)}]`)
96
+ return result;
97
+ },
98
+ setPrototypeOf(target, proto) {
99
+ dtavm.log(`被拦截的目标对象 => [${WatchName}] 对象新原型==> [${(proto)}]`)
100
+ return Reflect.setPrototypeOf(target, proto);
101
+ },
102
+ preventExtensions(target) {
103
+ dtavm.log(`方法用于设置preventExtensions => [${WatchName}] 防止扩展`)
104
+ return Reflect.preventExtensions(target);
105
+ },
106
+ isExtensible(target) {
107
+ var result = Reflect.isExtensible(target)
108
+ dtavm.log(`拦截对对象的isExtensible() => [${WatchName}] isExtensible, 返回值==> [${result}]`)
109
+ return result;
110
+ },
111
+ }
112
+ return handler;
113
+ }
114
+
115
+ if (type === "method") {
116
+ return new Proxy(obj, getMethodHandler(objname, obj));
117
+ }
118
+ return new Proxy(obj, getObjhandler(objname));
119
+ }
120
+
121
+ // window = proxy(window, 'window');
122
+ global.document = window.document;
123
+
124
+ $$cursor_jscode$$
125
+
126
+
127
+ window.V_C[0]().then(value => console_log(JSON.stringify(value)));
128
+
main.go ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "cursor2api-go/config"
6
+ "cursor2api-go/handlers"
7
+ "cursor2api-go/middleware"
8
+ "cursor2api-go/models"
9
+ "fmt"
10
+ "net/http"
11
+ "os"
12
+ "os/signal"
13
+ "strings"
14
+ "syscall"
15
+ "time"
16
+
17
+ "github.com/gin-gonic/gin"
18
+ "github.com/sirupsen/logrus"
19
+ )
20
+
21
+ func main() {
22
+ // 加载配置
23
+ cfg, err := config.LoadConfig()
24
+ if err != nil {
25
+ logrus.Fatalf("Failed to load config: %v", err)
26
+ }
27
+
28
+ // 设置日志级别和 GIN 模式(必须在创建路由器之前设置)
29
+ if cfg.Debug {
30
+ logrus.SetLevel(logrus.DebugLevel)
31
+ gin.SetMode(gin.DebugMode)
32
+ } else {
33
+ logrus.SetLevel(logrus.InfoLevel)
34
+ gin.SetMode(gin.ReleaseMode)
35
+ }
36
+
37
+ // 禁用 Gin 的调试信息输出
38
+ gin.DisableConsoleColor()
39
+
40
+ // 创建路由器(使用 gin.New() 而不是 gin.Default() 以避免默认日志)
41
+ router := gin.New()
42
+
43
+ // 添加中间件
44
+ router.Use(gin.Recovery())
45
+ router.Use(middleware.CORS())
46
+ router.Use(middleware.ErrorHandler())
47
+ // 只在 Debug 模式下启用 GIN 的日志
48
+ if cfg.Debug {
49
+ router.Use(gin.Logger())
50
+ }
51
+
52
+ // 创建处理器
53
+ handler := handlers.NewHandler(cfg)
54
+
55
+ // 注册路由
56
+ setupRoutes(router, handler)
57
+
58
+ // 创建HTTP服务器
59
+ server := &http.Server{
60
+ Addr: fmt.Sprintf(":%d", cfg.Port),
61
+ Handler: router,
62
+ }
63
+
64
+ // 打印启动信息
65
+ printStartupBanner(cfg)
66
+
67
+ // 启动服务器的goroutine
68
+ go func() {
69
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
70
+ logrus.Fatalf("Failed to start server: %v", err)
71
+ }
72
+ }()
73
+
74
+ // 等待中断信号以优雅关闭服务器
75
+ quit := make(chan os.Signal, 1)
76
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
77
+ <-quit
78
+ logrus.Info("Shutting down server...")
79
+
80
+ // 给服务器5秒时间完成处理正在进行的请求
81
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
82
+ defer cancel()
83
+ if err := server.Shutdown(ctx); err != nil {
84
+ logrus.Fatalf("Server forced to shutdown: %v", err)
85
+ }
86
+
87
+ logrus.Info("Server exited")
88
+ }
89
+
90
+ func setupRoutes(router *gin.Engine, handler *handlers.Handler) {
91
+ // 健康检查
92
+ router.GET("/health", func(c *gin.Context) {
93
+ c.JSON(http.StatusOK, gin.H{
94
+ "status": "ok",
95
+ "time": time.Now().Unix(),
96
+ })
97
+ })
98
+
99
+ // API文档页面
100
+ router.GET("/", handler.ServeDocs)
101
+
102
+ // API v1路由组
103
+ v1 := router.Group("/v1")
104
+ {
105
+ // 模型列表
106
+ v1.GET("/models", middleware.AuthRequired(), handler.ListModels)
107
+
108
+ // 聊天完成
109
+ v1.POST("/chat/completions", middleware.AuthRequired(), handler.ChatCompletions)
110
+ }
111
+
112
+ // 静态文件服务(如果需要)
113
+ router.Static("/static", "./static")
114
+ }
115
+
116
+ // printStartupBanner 打印启动横幅
117
+ func printStartupBanner(cfg *config.Config) {
118
+ banner := `
119
+ ╔══════════════════════════════════════════════════════════════╗
120
+ ║ Cursor2API Server ║
121
+ ╚══════════════════════════════════════════════════════════════╝
122
+ `
123
+ fmt.Println(banner)
124
+
125
+ fmt.Printf("🚀 服务地址: http://localhost:%d\n", cfg.Port)
126
+ fmt.Printf("📚 API 文档: http://localhost:%d/\n", cfg.Port)
127
+ fmt.Printf("💊 健康检查: http://localhost:%d/health\n", cfg.Port)
128
+ fmt.Printf("🔑 API 密钥: %s\n", maskAPIKey(cfg.APIKey))
129
+
130
+ modelList := cfg.GetModels()
131
+ fmt.Printf("\n🤖 支持模型 (%d 个):\n", len(modelList))
132
+
133
+ // 按类别分组显示模型
134
+ providers := make(map[string][]string)
135
+ for _, modelID := range modelList {
136
+ if config, exists := models.GetModelConfig(modelID); exists {
137
+ providers[config.Provider] = append(providers[config.Provider], modelID)
138
+ } else {
139
+ providers["Other"] = append(providers["Other"], modelID)
140
+ }
141
+ }
142
+
143
+ // 按Provider排序并显示
144
+ for _, provider := range []string{"Anthropic", "OpenAI", "Google", "Other"} {
145
+ if models, ok := providers[provider]; ok && len(models) > 0 {
146
+ fmt.Printf(" %s: %s\n", provider, strings.Join(models, ", "))
147
+ }
148
+ }
149
+
150
+ if cfg.Debug {
151
+ fmt.Println("\n🐛 调试模式: 已启用")
152
+ }
153
+
154
+ fmt.Println("\n✨ 服务已启动,按 Ctrl+C 停止")
155
+ fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
156
+ }
157
+
158
+ // maskAPIKey 掩码 API 密钥,只显示前 4 位
159
+ func maskAPIKey(key string) string {
160
+ if len(key) <= 4 {
161
+ return "****"
162
+ }
163
+ return key[:4] + "****"
164
+ }
middleware/auth.go ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package middleware
22
+
23
+ import (
24
+ "cursor2api-go/models"
25
+ "net/http"
26
+ "os"
27
+ "strings"
28
+
29
+ "github.com/gin-gonic/gin"
30
+ )
31
+
32
+ // AuthRequired 认证中间件
33
+ func AuthRequired() gin.HandlerFunc {
34
+ return func(c *gin.Context) {
35
+ authHeader := c.GetHeader("Authorization")
36
+
37
+ if authHeader == "" {
38
+ errorResponse := models.NewErrorResponse(
39
+ "Missing authorization header",
40
+ "authentication_error",
41
+ "missing_auth",
42
+ )
43
+ c.JSON(http.StatusUnauthorized, errorResponse)
44
+ c.Abort()
45
+ return
46
+ }
47
+
48
+ if !strings.HasPrefix(authHeader, "Bearer ") {
49
+ errorResponse := models.NewErrorResponse(
50
+ "Invalid authorization format. Expected 'Bearer <token>'",
51
+ "authentication_error",
52
+ "invalid_auth_format",
53
+ )
54
+ c.JSON(http.StatusUnauthorized, errorResponse)
55
+ c.Abort()
56
+ return
57
+ }
58
+
59
+ token := strings.TrimPrefix(authHeader, "Bearer ")
60
+ expectedToken := os.Getenv("API_KEY")
61
+ if expectedToken == "" {
62
+ expectedToken = "0000" // 默认值
63
+ }
64
+
65
+ if token != expectedToken {
66
+ errorResponse := models.NewErrorResponse(
67
+ "Invalid API key",
68
+ "authentication_error",
69
+ "invalid_api_key",
70
+ )
71
+ c.JSON(http.StatusUnauthorized, errorResponse)
72
+ c.Abort()
73
+ return
74
+ }
75
+
76
+ // 认证通过,继续处理请求
77
+ c.Next()
78
+ }
79
+ }
middleware/cors.go ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package middleware
22
+
23
+ import (
24
+ "github.com/gin-gonic/gin"
25
+ )
26
+
27
+ // CORS 跨域中间件
28
+ func CORS() gin.HandlerFunc {
29
+ return func(c *gin.Context) {
30
+ // 设置CORS头
31
+ c.Header("Access-Control-Allow-Origin", "*")
32
+ c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
33
+ c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
34
+ c.Header("Access-Control-Allow-Credentials", "true")
35
+ c.Header("Access-Control-Max-Age", "86400")
36
+
37
+ // 处理预检请求
38
+ if c.Request.Method == "OPTIONS" {
39
+ c.AbortWithStatus(200)
40
+ return
41
+ }
42
+
43
+ c.Next()
44
+ }
45
+ }
middleware/error.go ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package middleware
22
+
23
+ import (
24
+ "cursor2api-go/models"
25
+ "net/http"
26
+
27
+ "github.com/gin-gonic/gin"
28
+ "github.com/sirupsen/logrus"
29
+ )
30
+
31
+ // CursorWebError Cursor Web API错误
32
+ type CursorWebError struct {
33
+ StatusCode int `json:"status_code"`
34
+ Message string `json:"message"`
35
+ }
36
+
37
+ // Error 实现error接口
38
+ func (e *CursorWebError) Error() string {
39
+ return e.Message
40
+ }
41
+
42
+ // NewCursorWebError 创建新的CursorWebError
43
+ func NewCursorWebError(statusCode int, message string) *CursorWebError {
44
+ return &CursorWebError{
45
+ StatusCode: statusCode,
46
+ Message: message,
47
+ }
48
+ }
49
+
50
+ // ErrorHandler 全局错误处理中间件
51
+ func ErrorHandler() gin.HandlerFunc {
52
+ return func(c *gin.Context) {
53
+ c.Next()
54
+
55
+ // 处理上下文中的错误
56
+ if len(c.Errors) > 0 {
57
+ err := c.Errors.Last().Err
58
+ handleError(c, err)
59
+ }
60
+ }
61
+ }
62
+
63
+ // HandleError 处理错误并返回适当的响应
64
+ func HandleError(c *gin.Context, err error) {
65
+ handleError(c, err)
66
+ }
67
+
68
+ // handleError 内部错误处理逻辑
69
+ func handleError(c *gin.Context, err error) {
70
+ // 如果已经写入了响应头,则不再处理
71
+ if c.Writer.Written() {
72
+ return
73
+ }
74
+
75
+ logrus.WithError(err).Error("API error occurred")
76
+
77
+ switch e := err.(type) {
78
+ case *CursorWebError:
79
+ // 处理Cursor Web错误
80
+ errorResponse := models.NewErrorResponse(
81
+ e.Message,
82
+ "cursor_web_error",
83
+ "",
84
+ )
85
+ c.JSON(e.StatusCode, errorResponse)
86
+
87
+ case *gin.Error:
88
+ // 处理Gin绑定错误
89
+ statusCode := http.StatusBadRequest
90
+ if e.Type == gin.ErrorTypePublic {
91
+ statusCode = http.StatusInternalServerError
92
+ }
93
+
94
+ errorResponse := models.NewErrorResponse(
95
+ e.Error(),
96
+ "validation_error",
97
+ "invalid_request",
98
+ )
99
+ c.JSON(statusCode, errorResponse)
100
+
101
+ case *RequestValidationError:
102
+ errorResponse := models.NewErrorResponse(
103
+ e.Message,
104
+ "invalid_request_error",
105
+ e.Code,
106
+ )
107
+ c.JSON(http.StatusBadRequest, errorResponse)
108
+
109
+ default:
110
+ // 处理其他错误
111
+ errorResponse := models.NewErrorResponse(
112
+ "Internal server error",
113
+ "internal_error",
114
+ "",
115
+ )
116
+ c.JSON(http.StatusInternalServerError, errorResponse)
117
+ }
118
+ }
119
+
120
+ // RequestValidationError 请求参数验证错误
121
+ type RequestValidationError struct {
122
+ Message string `json:"message"`
123
+ Code string `json:"code,omitempty"`
124
+ }
125
+
126
+ // Error 实现 error 接口
127
+ func (e *RequestValidationError) Error() string {
128
+ return e.Message
129
+ }
130
+
131
+ // NewRequestValidationError 创建请求参数验证错误
132
+ func NewRequestValidationError(message, code string) *RequestValidationError {
133
+ return &RequestValidationError{
134
+ Message: message,
135
+ Code: code,
136
+ }
137
+ }
138
+
139
+ // RecoveryHandler 自定义恢复中间件
140
+ func RecoveryHandler() gin.HandlerFunc {
141
+ return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
142
+ logrus.WithField("panic", recovered).Error("Panic occurred")
143
+
144
+ if c.Writer.Written() {
145
+ return
146
+ }
147
+
148
+ errorResponse := models.NewErrorResponse(
149
+ "Internal server error",
150
+ "panic_error",
151
+ "",
152
+ )
153
+ c.JSON(http.StatusInternalServerError, errorResponse)
154
+ })
155
+ }
156
+
157
+ // ValidationError 验证错误
158
+ type ValidationError struct {
159
+ Field string `json:"field"`
160
+ Message string `json:"message"`
161
+ }
162
+
163
+ // MultipleValidationError 多个验证错误
164
+ type MultipleValidationError struct {
165
+ Errors []ValidationError `json:"errors"`
166
+ }
167
+
168
+ // Error 实现error接口
169
+ func (e *MultipleValidationError) Error() string {
170
+ return "validation failed"
171
+ }
172
+
173
+ // NewValidationError 创建验证错误
174
+ func NewValidationError(field, message string) *ValidationError {
175
+ return &ValidationError{
176
+ Field: field,
177
+ Message: message,
178
+ }
179
+ }
180
+
181
+ // AuthenticationError 认证错误
182
+ type AuthenticationError struct {
183
+ Message string `json:"message"`
184
+ }
185
+
186
+ // Error 实现error接口
187
+ func (e *AuthenticationError) Error() string {
188
+ return e.Message
189
+ }
190
+
191
+ // NewAuthenticationError 创建认证错误
192
+ func NewAuthenticationError(message string) *AuthenticationError {
193
+ return &AuthenticationError{
194
+ Message: message,
195
+ }
196
+ }
197
+
198
+ // RateLimitError 限流错误
199
+ type RateLimitError struct {
200
+ Message string `json:"message"`
201
+ RetryAfter int `json:"retry_after"`
202
+ }
203
+
204
+ // Error 实现error接口
205
+ func (e *RateLimitError) Error() string {
206
+ return e.Message
207
+ }
208
+
209
+ // NewRateLimitError 创建限流错误
210
+ func NewRateLimitError(message string, retryAfter int) *RateLimitError {
211
+ return &RateLimitError{
212
+ Message: message,
213
+ RetryAfter: retryAfter,
214
+ }
215
+ }
models/model_capabilities.go ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package models
2
+
3
+ import "strings"
4
+
5
+ // ThinkingModelSuffix 是自动派生的公开思维模型后缀
6
+ const ThinkingModelSuffix = "-thinking"
7
+
8
+ // ModelCapability 描述公开模型的能力视图
9
+ type ModelCapability struct {
10
+ RequestedModel string
11
+ BaseModel string
12
+ ThinkingEnabled bool
13
+ ToolCapable bool
14
+ }
15
+
16
+ // ResolveModelCapability 解析公开模型名到内部基础模型与能力开关
17
+ func ResolveModelCapability(modelID string) ModelCapability {
18
+ return ModelCapability{
19
+ RequestedModel: modelID,
20
+ BaseModel: TrimThinkingModel(modelID),
21
+ ThinkingEnabled: IsThinkingModel(modelID),
22
+ ToolCapable: true,
23
+ }
24
+ }
25
+
26
+ // ExpandModelList 将基础模型列表扩展为公开模型目录
27
+ func ExpandModelList(baseModels []string) []string {
28
+ seen := make(map[string]struct{}, len(baseModels)*2)
29
+ result := make([]string, 0, len(baseModels)*2)
30
+
31
+ add := func(model string) {
32
+ if model == "" {
33
+ return
34
+ }
35
+ if _, exists := seen[model]; exists {
36
+ return
37
+ }
38
+ seen[model] = struct{}{}
39
+ result = append(result, model)
40
+ }
41
+
42
+ for _, model := range baseModels {
43
+ model = strings.TrimSpace(model)
44
+ if model == "" {
45
+ continue
46
+ }
47
+
48
+ add(model)
49
+ if !IsThinkingModel(model) {
50
+ add(ThinkingModelID(model))
51
+ }
52
+ }
53
+
54
+ return result
55
+ }
56
+
57
+ // IsThinkingModel 判断是否为公开 thinking 模型
58
+ func IsThinkingModel(modelID string) bool {
59
+ return strings.HasSuffix(strings.TrimSpace(modelID), ThinkingModelSuffix)
60
+ }
61
+
62
+ // TrimThinkingModel 去除公开 thinking 模型后缀
63
+ func TrimThinkingModel(modelID string) string {
64
+ modelID = strings.TrimSpace(modelID)
65
+ if IsThinkingModel(modelID) {
66
+ return strings.TrimSuffix(modelID, ThinkingModelSuffix)
67
+ }
68
+ return modelID
69
+ }
70
+
71
+ // ThinkingModelID 生成公开 thinking 模型名
72
+ func ThinkingModelID(baseModel string) string {
73
+ baseModel = strings.TrimSpace(baseModel)
74
+ if baseModel == "" || IsThinkingModel(baseModel) {
75
+ return baseModel
76
+ }
77
+ return baseModel + ThinkingModelSuffix
78
+ }
models/model_config.go ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package models
22
+
23
+ // ModelConfig 模型配置结构
24
+ type ModelConfig struct {
25
+ ID string `json:"id"`
26
+ Provider string `json:"provider"`
27
+ MaxTokens int `json:"max_tokens"`
28
+ ContextWindow int `json:"context_window"`
29
+ CursorModel string `json:"cursor_model"`
30
+ }
31
+
32
+ // GetModelConfigs 获取所有基础模型配置
33
+ func GetModelConfigs() map[string]ModelConfig {
34
+ return map[string]ModelConfig{
35
+ "claude-sonnet-4.6": {
36
+ ID: "claude-sonnet-4.6",
37
+ Provider: "Anthropic",
38
+ MaxTokens: 200000,
39
+ ContextWindow: 200000,
40
+ CursorModel: "anthropic/claude-sonnet-4.6",
41
+ },
42
+ }
43
+ }
44
+
45
+ // GetModelConfig 获取指定模型的配置,支持公开 thinking 模型映射回基础模型
46
+ func GetModelConfig(modelID string) (ModelConfig, bool) {
47
+ configs := GetModelConfigs()
48
+ baseModel := TrimThinkingModel(modelID)
49
+ config, exists := configs[baseModel]
50
+ if !exists {
51
+ return ModelConfig{}, false
52
+ }
53
+
54
+ config.ID = modelID
55
+ return config, true
56
+ }
57
+
58
+ // GetCursorModel 获取Cursor API使用的模型名称
59
+ func GetCursorModel(modelID string) string {
60
+ if config, exists := GetModelConfig(modelID); exists && config.CursorModel != "" {
61
+ return config.CursorModel
62
+ }
63
+ return TrimThinkingModel(modelID)
64
+ }
65
+
66
+ // GetMaxTokensForModel 获取指定模型的最大token数
67
+ func GetMaxTokensForModel(modelID string) int {
68
+ if config, exists := GetModelConfig(modelID); exists {
69
+ return config.MaxTokens
70
+ }
71
+ return 4096
72
+ }
73
+
74
+ // GetContextWindowForModel 获取指定模型的上下文窗口大小
75
+ func GetContextWindowForModel(modelID string) int {
76
+ if config, exists := GetModelConfig(modelID); exists {
77
+ return config.ContextWindow
78
+ }
79
+ return 128000
80
+ }
81
+
82
+ // ValidateMaxTokens 验证并调整max_tokens参数
83
+ func ValidateMaxTokens(modelID string, requestedMaxTokens *int) *int {
84
+ modelMaxTokens := GetMaxTokensForModel(modelID)
85
+
86
+ if requestedMaxTokens == nil {
87
+ return &modelMaxTokens
88
+ }
89
+
90
+ if *requestedMaxTokens > modelMaxTokens {
91
+ return &modelMaxTokens
92
+ }
93
+
94
+ if *requestedMaxTokens <= 0 {
95
+ return &modelMaxTokens
96
+ }
97
+
98
+ return requestedMaxTokens
99
+ }
models/models.go ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package models
22
+
23
+ import (
24
+ "encoding/json"
25
+ "time"
26
+ )
27
+
28
+ // ChatCompletionRequest OpenAI聊天完成请求
29
+ type ChatCompletionRequest struct {
30
+ Model string `json:"model" binding:"required"`
31
+ Messages []Message `json:"messages" binding:"required"`
32
+ Stream bool `json:"stream,omitempty"`
33
+ Temperature *float64 `json:"temperature,omitempty"`
34
+ MaxTokens *int `json:"max_tokens,omitempty"`
35
+ TopP *float64 `json:"top_p,omitempty"`
36
+ Stop []string `json:"stop,omitempty"`
37
+ User string `json:"user,omitempty"`
38
+ Tools []Tool `json:"tools,omitempty"`
39
+ ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
40
+ }
41
+
42
+ // Message 消息结构
43
+ type Message struct {
44
+ Role string `json:"role" binding:"required"`
45
+ Content interface{} `json:"content"`
46
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
47
+ ToolCallID string `json:"tool_call_id,omitempty"`
48
+ Name string `json:"name,omitempty"`
49
+ }
50
+
51
+ // ContentPart 消息内容部分(用于多模态内容)
52
+ type ContentPart struct {
53
+ Type string `json:"type"`
54
+ Text string `json:"text,omitempty"`
55
+ URL string `json:"url,omitempty"`
56
+ }
57
+
58
+ // Tool OpenAI工具定义
59
+ type Tool struct {
60
+ Type string `json:"type"`
61
+ Function FunctionDefinition `json:"function"`
62
+ }
63
+
64
+ // FunctionDefinition OpenAI函数定义
65
+ type FunctionDefinition struct {
66
+ Name string `json:"name"`
67
+ Description string `json:"description,omitempty"`
68
+ Parameters map[string]interface{} `json:"parameters,omitempty"`
69
+ }
70
+
71
+ // ToolCall OpenAI工具调用
72
+ type ToolCall struct {
73
+ ID string `json:"id"`
74
+ Type string `json:"type"`
75
+ Function FunctionCall `json:"function"`
76
+ }
77
+
78
+ // FunctionCall OpenAI函数调用信息
79
+ type FunctionCall struct {
80
+ Name string `json:"name"`
81
+ Arguments string `json:"arguments"`
82
+ }
83
+
84
+ // ToolChoiceObject OpenAI tool_choice 对象形式
85
+ type ToolChoiceObject struct {
86
+ Type string `json:"type"`
87
+ Function *ToolChoiceFunction `json:"function,omitempty"`
88
+ }
89
+
90
+ // ToolChoiceFunction tool_choice 中的函数名
91
+ type ToolChoiceFunction struct {
92
+ Name string `json:"name"`
93
+ }
94
+
95
+ // ChatCompletionResponse OpenAI聊天完成响应
96
+ type ChatCompletionResponse struct {
97
+ ID string `json:"id"`
98
+ Object string `json:"object"`
99
+ Created int64 `json:"created"`
100
+ Model string `json:"model"`
101
+ Choices []Choice `json:"choices"`
102
+ Usage Usage `json:"usage"`
103
+ }
104
+
105
+ // ChatCompletionStreamResponse 流式响应
106
+ type ChatCompletionStreamResponse struct {
107
+ ID string `json:"id"`
108
+ Object string `json:"object"`
109
+ Created int64 `json:"created"`
110
+ Model string `json:"model"`
111
+ Choices []StreamChoice `json:"choices"`
112
+ }
113
+
114
+ // Choice 选择结构
115
+ type Choice struct {
116
+ Index int `json:"index"`
117
+ Message Message `json:"message"`
118
+ FinishReason string `json:"finish_reason"`
119
+ }
120
+
121
+ // StreamChoice 流式选择结构
122
+ type StreamChoice struct {
123
+ Index int `json:"index"`
124
+ Delta StreamDelta `json:"delta"`
125
+ FinishReason *string `json:"finish_reason"`
126
+ }
127
+
128
+ // StreamDelta 流式增量数据
129
+ type StreamDelta struct {
130
+ Role string `json:"role,omitempty"`
131
+ Content string `json:"content,omitempty"`
132
+ ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
133
+ }
134
+
135
+ // ToolCallDelta 流式工具调用增量
136
+ type ToolCallDelta struct {
137
+ Index int `json:"index"`
138
+ ID string `json:"id,omitempty"`
139
+ Type string `json:"type,omitempty"`
140
+ Function *FunctionCallDelta `json:"function,omitempty"`
141
+ }
142
+
143
+ // FunctionCallDelta 流式函数调用增量
144
+ type FunctionCallDelta struct {
145
+ Name string `json:"name,omitempty"`
146
+ Arguments string `json:"arguments,omitempty"`
147
+ }
148
+
149
+ // Usage 使用统计
150
+ type Usage struct {
151
+ PromptTokens int `json:"prompt_tokens"`
152
+ CompletionTokens int `json:"completion_tokens"`
153
+ TotalTokens int `json:"total_tokens"`
154
+ }
155
+
156
+ // Model 模型信息
157
+ type Model struct {
158
+ ID string `json:"id"`
159
+ Object string `json:"object"`
160
+ Created int64 `json:"created"`
161
+ OwnedBy string `json:"owned_by"`
162
+ MaxTokens int `json:"max_tokens,omitempty"`
163
+ ContextWindow int `json:"context_window,omitempty"`
164
+ }
165
+
166
+ // ModelsResponse 模型列表响应
167
+ type ModelsResponse struct {
168
+ Object string `json:"object"`
169
+ Data []Model `json:"data"`
170
+ }
171
+
172
+ // ErrorResponse 错误响应
173
+ type ErrorResponse struct {
174
+ Error ErrorDetail `json:"error"`
175
+ }
176
+
177
+ // ErrorDetail 错误详情
178
+ type ErrorDetail struct {
179
+ Message string `json:"message"`
180
+ Type string `json:"type"`
181
+ Code string `json:"code,omitempty"`
182
+ }
183
+
184
+ // CursorMessage Cursor消息格式
185
+ type CursorMessage struct {
186
+ Role string `json:"role"`
187
+ Parts []CursorPart `json:"parts"`
188
+ }
189
+
190
+ // CursorPart Cursor消息部分
191
+ type CursorPart struct {
192
+ Type string `json:"type"`
193
+ Text string `json:"text"`
194
+ }
195
+
196
+ // CursorRequest Cursor请求格式
197
+ type CursorRequest struct {
198
+ Context []interface{} `json:"context"`
199
+ Model string `json:"model"`
200
+ ID string `json:"id"`
201
+ Messages []CursorMessage `json:"messages"`
202
+ Trigger string `json:"trigger"`
203
+ }
204
+
205
+ // CursorEventData Cursor事件数据
206
+ type CursorEventData struct {
207
+ Type string `json:"type"`
208
+ Delta string `json:"delta,omitempty"`
209
+ ErrorText string `json:"errorText,omitempty"`
210
+ MessageMetadata *CursorMessageMetadata `json:"messageMetadata,omitempty"`
211
+ }
212
+
213
+ // CursorMessageMetadata Cursor消息元数据
214
+ type CursorMessageMetadata struct {
215
+ Usage *CursorUsage `json:"usage,omitempty"`
216
+ }
217
+
218
+ // CursorUsage Cursor使用统计
219
+ type CursorUsage struct {
220
+ InputTokens int `json:"inputTokens"`
221
+ OutputTokens int `json:"outputTokens"`
222
+ TotalTokens int `json:"totalTokens"`
223
+ }
224
+
225
+ // SSEEvent 服务器发送事件
226
+ type SSEEvent struct {
227
+ Data string `json:"data"`
228
+ Event string `json:"event,omitempty"`
229
+ ID string `json:"id,omitempty"`
230
+ }
231
+
232
+ // CursorParseConfig 定义上游文本协议解析选项
233
+ type CursorParseConfig struct {
234
+ TriggerSignal string
235
+ ThinkingEnabled bool
236
+ }
237
+
238
+ // AssistantEventKind 助手输出事件类型
239
+ type AssistantEventKind string
240
+
241
+ const (
242
+ AssistantEventText AssistantEventKind = "text"
243
+ AssistantEventThinking AssistantEventKind = "thinking"
244
+ AssistantEventToolCall AssistantEventKind = "tool_call"
245
+ )
246
+
247
+ // AssistantEvent 是内部流式解析事件
248
+ type AssistantEvent struct {
249
+ Kind AssistantEventKind
250
+ Text string
251
+ Thinking string
252
+ ToolCall *ToolCall
253
+ }
254
+
255
+ // GetStringContent 获取消息的字符串内容
256
+ func (m *Message) GetStringContent() string {
257
+ if m.Content == nil {
258
+ return ""
259
+ }
260
+
261
+ switch content := m.Content.(type) {
262
+ case string:
263
+ return content
264
+ case []ContentPart:
265
+ var text string
266
+ for _, part := range content {
267
+ if part.Type == "text" {
268
+ text += part.Text
269
+ }
270
+ }
271
+ return text
272
+ case []interface{}:
273
+ var text string
274
+ for _, item := range content {
275
+ if part, ok := item.(map[string]interface{}); ok {
276
+ if partType, exists := part["type"].(string); exists && partType == "text" {
277
+ if textContent, exists := part["text"].(string); exists {
278
+ text += textContent
279
+ }
280
+ }
281
+ }
282
+ }
283
+ return text
284
+ default:
285
+ if data, err := json.Marshal(content); err == nil {
286
+ return string(data)
287
+ }
288
+ return ""
289
+ }
290
+ }
291
+
292
+ // ToCursorMessages 将OpenAI消息转换为Cursor格式
293
+ func ToCursorMessages(messages []Message, systemPromptInject string) []CursorMessage {
294
+ var result []CursorMessage
295
+
296
+ if systemPromptInject != "" {
297
+ if len(messages) > 0 && messages[0].Role == "system" {
298
+ content := messages[0].GetStringContent()
299
+ content += "\n" + systemPromptInject
300
+ result = append(result, CursorMessage{
301
+ Role: "system",
302
+ Parts: []CursorPart{
303
+ {Type: "text", Text: content},
304
+ },
305
+ })
306
+ messages = messages[1:]
307
+ } else {
308
+ result = append(result, CursorMessage{
309
+ Role: "system",
310
+ Parts: []CursorPart{
311
+ {Type: "text", Text: systemPromptInject},
312
+ },
313
+ })
314
+ }
315
+ } else if len(messages) > 0 && messages[0].Role == "system" {
316
+ result = append(result, CursorMessage{
317
+ Role: "system",
318
+ Parts: []CursorPart{
319
+ {Type: "text", Text: messages[0].GetStringContent()},
320
+ },
321
+ })
322
+ messages = messages[1:]
323
+ }
324
+
325
+ for _, msg := range messages {
326
+ if msg.Role == "" {
327
+ continue
328
+ }
329
+
330
+ result = append(result, CursorMessage{
331
+ Role: msg.Role,
332
+ Parts: []CursorPart{
333
+ {
334
+ Type: "text",
335
+ Text: msg.GetStringContent(),
336
+ },
337
+ },
338
+ })
339
+ }
340
+
341
+ return result
342
+ }
343
+
344
+ // NewChatCompletionResponse 创建聊天完成响应
345
+ func NewChatCompletionResponse(id, model string, message Message, finishReason string, usage Usage) *ChatCompletionResponse {
346
+ return &ChatCompletionResponse{
347
+ ID: id,
348
+ Object: "chat.completion",
349
+ Created: time.Now().Unix(),
350
+ Model: model,
351
+ Choices: []Choice{
352
+ {
353
+ Index: 0,
354
+ Message: message,
355
+ FinishReason: finishReason,
356
+ },
357
+ },
358
+ Usage: usage,
359
+ }
360
+ }
361
+
362
+ // NewChatCompletionStreamResponse 创建流式响应
363
+ func NewChatCompletionStreamResponse(id, model string, delta StreamDelta, finishReason *string) *ChatCompletionStreamResponse {
364
+ return &ChatCompletionStreamResponse{
365
+ ID: id,
366
+ Object: "chat.completion.chunk",
367
+ Created: time.Now().Unix(),
368
+ Model: model,
369
+ Choices: []StreamChoice{
370
+ {
371
+ Index: 0,
372
+ Delta: delta,
373
+ FinishReason: finishReason,
374
+ },
375
+ },
376
+ }
377
+ }
378
+
379
+ // NewErrorResponse 创建错误响应
380
+ func NewErrorResponse(message, errorType, code string) *ErrorResponse {
381
+ return &ErrorResponse{
382
+ Error: ErrorDetail{
383
+ Message: message,
384
+ Type: errorType,
385
+ Code: code,
386
+ },
387
+ }
388
+ }
models/models_test.go ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package models
22
+
23
+ import (
24
+ "testing"
25
+ )
26
+
27
+ func TestGetStringContent(t *testing.T) {
28
+ tests := []struct {
29
+ name string
30
+ content interface{}
31
+ expected string
32
+ }{
33
+ {
34
+ name: "string content",
35
+ content: "Hello world",
36
+ expected: "Hello world",
37
+ },
38
+ {
39
+ name: "array content",
40
+ content: []ContentPart{
41
+ {Type: "text", Text: "Hello"},
42
+ {Type: "text", Text: " world"},
43
+ },
44
+ expected: "Hello world",
45
+ },
46
+ {
47
+ name: "empty array",
48
+ content: []ContentPart{},
49
+ expected: "",
50
+ },
51
+ {
52
+ name: "nil content",
53
+ content: nil,
54
+ expected: "",
55
+ },
56
+ }
57
+
58
+ for _, tt := range tests {
59
+ t.Run(tt.name, func(t *testing.T) {
60
+ msg := &Message{Content: tt.content}
61
+ result := msg.GetStringContent()
62
+ if result != tt.expected {
63
+ t.Errorf("GetStringContent() = %v, want %v", result, tt.expected)
64
+ }
65
+ })
66
+ }
67
+ }
68
+
69
+ func TestToCursorMessages(t *testing.T) {
70
+ tests := []struct {
71
+ name string
72
+ messages []Message
73
+ systemPrompt string
74
+ expectedLength int
75
+ expectedFirstMsg string
76
+ }{
77
+ {
78
+ name: "no system prompt",
79
+ messages: []Message{
80
+ {Role: "user", Content: "Hello"},
81
+ },
82
+ systemPrompt: "",
83
+ expectedLength: 1,
84
+ expectedFirstMsg: "Hello",
85
+ },
86
+ {
87
+ name: "with system prompt, no system message",
88
+ messages: []Message{
89
+ {Role: "user", Content: "Hello"},
90
+ },
91
+ systemPrompt: "You are a helpful assistant",
92
+ expectedLength: 2,
93
+ expectedFirstMsg: "You are a helpful assistant",
94
+ },
95
+ {
96
+ name: "with system prompt, has system message",
97
+ messages: []Message{
98
+ {Role: "system", Content: "Be helpful"},
99
+ {Role: "user", Content: "Hello"},
100
+ },
101
+ systemPrompt: "You are an AI",
102
+ expectedLength: 2,
103
+ expectedFirstMsg: "Be helpful\nYou are an AI",
104
+ },
105
+ }
106
+
107
+ for _, tt := range tests {
108
+ t.Run(tt.name, func(t *testing.T) {
109
+ result := ToCursorMessages(tt.messages, tt.systemPrompt)
110
+ if len(result) != tt.expectedLength {
111
+ t.Errorf("ToCursorMessages() length = %v, want %v", len(result), tt.expectedLength)
112
+ }
113
+ if len(result) > 0 && result[0].Parts[0].Text != tt.expectedFirstMsg {
114
+ t.Errorf("ToCursorMessages() first message = %v, want %v", result[0].Parts[0].Text, tt.expectedFirstMsg)
115
+ }
116
+ })
117
+ }
118
+ }
119
+
120
+ func TestNewChatCompletionResponse(t *testing.T) {
121
+ response := NewChatCompletionResponse(
122
+ "test-id",
123
+ "claude-sonnet-4.6",
124
+ Message{Role: "assistant", Content: "Hello world"},
125
+ "stop",
126
+ Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
127
+ )
128
+
129
+ if response.ID != "test-id" {
130
+ t.Errorf("ID = %v, want test-id", response.ID)
131
+ }
132
+ if response.Model != "claude-sonnet-4.6" {
133
+ t.Errorf("Model = %v, want claude-sonnet-4.6", response.Model)
134
+ }
135
+ if response.Choices[0].Message.Content != "Hello world" {
136
+ t.Errorf("Content = %v, want Hello world", response.Choices[0].Message.Content)
137
+ }
138
+ if response.Usage.PromptTokens != 10 {
139
+ t.Errorf("PromptTokens = %v, want 10", response.Usage.PromptTokens)
140
+ }
141
+ }
142
+
143
+ func TestNewChatCompletionStreamResponse(t *testing.T) {
144
+ response := NewChatCompletionStreamResponse(
145
+ "test-id",
146
+ "claude-sonnet-4.6",
147
+ StreamDelta{Content: "Hello"},
148
+ stringPtr("stop"),
149
+ )
150
+
151
+ if response.ID != "test-id" {
152
+ t.Errorf("ID = %v, want test-id", response.ID)
153
+ }
154
+ if response.Choices[0].Delta.Content != "Hello" {
155
+ t.Errorf("Content = %v, want Hello", response.Choices[0].Delta.Content)
156
+ }
157
+ if response.Choices[0].FinishReason == nil || *response.Choices[0].FinishReason != "stop" {
158
+ t.Errorf("FinishReason = %v, want stop", response.Choices[0].FinishReason)
159
+ }
160
+ }
161
+
162
+ func TestResolveModelCapability(t *testing.T) {
163
+ capability := ResolveModelCapability("claude-sonnet-4.6-thinking")
164
+ if capability.BaseModel != "claude-sonnet-4.6" {
165
+ t.Fatalf("BaseModel = %v, want claude-sonnet-4.6", capability.BaseModel)
166
+ }
167
+ if !capability.ThinkingEnabled {
168
+ t.Fatalf("ThinkingEnabled = false, want true")
169
+ }
170
+ }
171
+
172
+ func TestExpandModelList(t *testing.T) {
173
+ models := ExpandModelList([]string{"claude-sonnet-4.6"})
174
+ expected := []string{"claude-sonnet-4.6", "claude-sonnet-4.6-thinking"}
175
+ if len(models) != len(expected) {
176
+ t.Fatalf("ExpandModelList() length = %v, want %v", len(models), len(expected))
177
+ }
178
+ for i := range expected {
179
+ if models[i] != expected[i] {
180
+ t.Fatalf("ExpandModelList()[%d] = %v, want %v", i, models[i], expected[i])
181
+ }
182
+ }
183
+ }
184
+
185
+ func TestNewErrorResponse(t *testing.T) {
186
+ response := NewErrorResponse("Test error", "test_error", "error_code")
187
+
188
+ if response.Error.Message != "Test error" {
189
+ t.Errorf("Message = %v, want Test error", response.Error.Message)
190
+ }
191
+ if response.Error.Type != "test_error" {
192
+ t.Errorf("Type = %v, want test_error", response.Error.Type)
193
+ }
194
+ if response.Error.Code != "error_code" {
195
+ t.Errorf("Code = %v, want error_code", response.Error.Code)
196
+ }
197
+ }
198
+
199
+ // Helper function
200
+ func stringPtr(s string) *string {
201
+ return &s
202
+ }
services/cursor.go ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package services
22
+
23
+ import (
24
+ "bufio"
25
+ "context"
26
+ "cursor2api-go/config"
27
+ "cursor2api-go/middleware"
28
+ "cursor2api-go/models"
29
+ "cursor2api-go/utils"
30
+ "encoding/json"
31
+ "errors"
32
+ "fmt"
33
+ "io"
34
+ "net/http"
35
+ "net/http/cookiejar"
36
+ "os"
37
+ "path/filepath"
38
+ "strings"
39
+ "sync"
40
+ "time"
41
+
42
+ "github.com/imroc/req/v3"
43
+ "github.com/sirupsen/logrus"
44
+ )
45
+
46
+ const cursorAPIURL = "https://cursor.com/api/chat"
47
+
48
+ // CursorService handles interactions with Cursor API.
49
+ type CursorService struct {
50
+ config *config.Config
51
+ client *req.Client
52
+ mainJS string
53
+ envJS string
54
+ scriptCache string
55
+ scriptCacheTime time.Time
56
+ scriptMutex sync.RWMutex
57
+ headerGenerator *utils.HeaderGenerator
58
+ }
59
+
60
+ // NewCursorService creates a new service instance.
61
+ func NewCursorService(cfg *config.Config) *CursorService {
62
+ mainJS, err := os.ReadFile(filepath.Join("jscode", "main.js"))
63
+ if err != nil {
64
+ logrus.Fatalf("failed to read jscode/main.js: %v", err)
65
+ }
66
+
67
+ envJS, err := os.ReadFile(filepath.Join("jscode", "env.js"))
68
+ if err != nil {
69
+ logrus.Fatalf("failed to read jscode/env.js: %v", err)
70
+ }
71
+
72
+ jar, err := cookiejar.New(nil)
73
+ if err != nil {
74
+ logrus.Warnf("failed to create cookie jar: %v", err)
75
+ }
76
+
77
+ client := req.C()
78
+ client.SetTimeout(time.Duration(cfg.Timeout) * time.Second)
79
+ client.ImpersonateChrome()
80
+ if jar != nil {
81
+ client.SetCookieJar(jar)
82
+ }
83
+
84
+ return &CursorService{
85
+ config: cfg,
86
+ client: client,
87
+ mainJS: string(mainJS),
88
+ envJS: string(envJS),
89
+ headerGenerator: utils.NewHeaderGenerator(),
90
+ }
91
+ }
92
+
93
+ // ChatCompletion creates a chat completion stream for the given request.
94
+ func (s *CursorService) ChatCompletion(ctx context.Context, request *models.ChatCompletionRequest) (<-chan interface{}, error) {
95
+ buildResult, err := s.buildCursorRequest(request)
96
+ if err != nil {
97
+ return nil, err
98
+ }
99
+
100
+ jsonPayload, err := json.Marshal(buildResult.Payload)
101
+ if err != nil {
102
+ return nil, fmt.Errorf("failed to marshal cursor payload: %w", err)
103
+ }
104
+
105
+ // 尝试最多2次
106
+ maxRetries := 2
107
+ for attempt := 1; attempt <= maxRetries; attempt++ {
108
+ xIsHuman, err := s.fetchXIsHuman(ctx)
109
+ if err != nil {
110
+ if attempt < maxRetries {
111
+ logrus.WithError(err).Warnf("Failed to fetch x-is-human token (attempt %d/%d), retrying...", attempt, maxRetries)
112
+ time.Sleep(time.Second * time.Duration(attempt)) // 指数退避
113
+ continue
114
+ }
115
+ return nil, err
116
+ }
117
+
118
+ // 添加详细的调试日志
119
+ headers := s.chatHeaders(xIsHuman)
120
+ logrus.WithFields(logrus.Fields{
121
+ "url": cursorAPIURL,
122
+ "x-is-human": xIsHuman[:50] + "...", // 只显示前50个字符
123
+ "payload_length": len(jsonPayload),
124
+ "model": request.Model,
125
+ "attempt": attempt,
126
+ }).Debug("Sending request to Cursor API")
127
+
128
+ resp, err := s.client.R().
129
+ SetContext(ctx).
130
+ SetHeaders(headers).
131
+ SetBody(jsonPayload).
132
+ DisableAutoReadResponse().
133
+ Post(cursorAPIURL)
134
+ if err != nil {
135
+ if attempt < maxRetries {
136
+ logrus.WithError(err).Warnf("Cursor request failed (attempt %d/%d), retrying...", attempt, maxRetries)
137
+ time.Sleep(time.Second * time.Duration(attempt))
138
+ continue
139
+ }
140
+ return nil, fmt.Errorf("cursor request failed: %w", err)
141
+ }
142
+
143
+ if resp.StatusCode != http.StatusOK {
144
+ body, _ := io.ReadAll(resp.Response.Body)
145
+ resp.Response.Body.Close()
146
+ message := strings.TrimSpace(string(body))
147
+
148
+ // 记录详细的错误信息
149
+ logrus.WithFields(logrus.Fields{
150
+ "status_code": resp.StatusCode,
151
+ "response": message,
152
+ "headers": resp.Header,
153
+ "attempt": attempt,
154
+ }).Error("Cursor API returned non-OK status")
155
+
156
+ // 如果是 403 错误且还有重试机会,清除缓存并重试
157
+ if resp.StatusCode == http.StatusForbidden && attempt < maxRetries {
158
+ logrus.Warn("Received 403 Access Denied, refreshing browser fingerprint and clearing token cache...")
159
+
160
+ // 刷新浏览器指纹
161
+ s.headerGenerator.Refresh()
162
+ logrus.WithFields(logrus.Fields{
163
+ "platform": s.headerGenerator.GetProfile().Platform,
164
+ "chrome_version": s.headerGenerator.GetProfile().ChromeVersion,
165
+ }).Debug("Refreshed browser fingerprint")
166
+
167
+ // 清除 token 缓存
168
+ s.scriptMutex.Lock()
169
+ s.scriptCache = ""
170
+ s.scriptCacheTime = time.Time{}
171
+ s.scriptMutex.Unlock()
172
+
173
+ time.Sleep(time.Second * time.Duration(attempt))
174
+ continue
175
+ }
176
+
177
+ if strings.Contains(message, "Attention Required! | Cloudflare") {
178
+ message = "Cloudflare 403"
179
+ }
180
+ return nil, middleware.NewCursorWebError(resp.StatusCode, message)
181
+ }
182
+
183
+ // 成功,返回结果
184
+ output := make(chan interface{}, 32)
185
+ go s.consumeSSE(ctx, resp.Response, output, buildResult.ParseConfig)
186
+ return output, nil
187
+ }
188
+
189
+ return nil, fmt.Errorf("failed after %d attempts", maxRetries)
190
+ }
191
+
192
+ type nonStreamCollectResult struct {
193
+ Message models.Message
194
+ FinishReason string
195
+ Usage models.Usage
196
+ ToolCalls []models.ToolCall
197
+ Text string
198
+ }
199
+
200
+ func (s *CursorService) collectNonStream(ctx context.Context, gen <-chan interface{}, modelName string) (nonStreamCollectResult, error) {
201
+ var fullContent strings.Builder
202
+ var usage models.Usage
203
+ toolCalls := make([]models.ToolCall, 0, 2)
204
+ finishReason := "stop"
205
+
206
+ for {
207
+ select {
208
+ case <-ctx.Done():
209
+ return nonStreamCollectResult{}, ctx.Err()
210
+ case data, ok := <-gen:
211
+ if !ok {
212
+ msg := models.Message{Role: "assistant"}
213
+ if fullContent.Len() > 0 || len(toolCalls) == 0 {
214
+ msg.Content = fullContent.String()
215
+ }
216
+ if len(toolCalls) > 0 {
217
+ msg.ToolCalls = toolCalls
218
+ finishReason = "tool_calls"
219
+ }
220
+ return nonStreamCollectResult{
221
+ Message: msg,
222
+ FinishReason: finishReason,
223
+ Usage: usage,
224
+ ToolCalls: toolCalls,
225
+ Text: fullContent.String(),
226
+ }, nil
227
+ }
228
+
229
+ switch v := data.(type) {
230
+ case models.AssistantEvent:
231
+ switch v.Kind {
232
+ case models.AssistantEventText:
233
+ fullContent.WriteString(v.Text)
234
+ case models.AssistantEventToolCall:
235
+ if v.ToolCall != nil {
236
+ toolCalls = append(toolCalls, *v.ToolCall)
237
+ }
238
+ case models.AssistantEventThinking:
239
+ // thinking 对于 OpenAI chat.completion 的 message.content 不直接暴露
240
+ continue
241
+ }
242
+ case string:
243
+ fullContent.WriteString(v)
244
+ case models.Usage:
245
+ usage = v
246
+ case error:
247
+ return nonStreamCollectResult{}, v
248
+ default:
249
+ continue
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ func (s *CursorService) toolCallRequiredForRequest(request *models.ChatCompletionRequest) (bool, toolChoiceSpec, error) {
256
+ choice, err := parseToolChoice(request.ToolChoice)
257
+ if err != nil {
258
+ return false, toolChoiceSpec{}, err
259
+ }
260
+ if s.config != nil && s.config.KiloToolStrict && len(request.Tools) > 0 && choice.Mode == "auto" {
261
+ choice.Mode = "required"
262
+ }
263
+ if len(request.Tools) == 0 {
264
+ return false, choice, nil
265
+ }
266
+ return choice.Mode == "required" || choice.Mode == "function", choice, nil
267
+ }
268
+
269
+ func (s *CursorService) withToolRetrySystemMessage(request *models.ChatCompletionRequest, choice toolChoiceSpec) *models.ChatCompletionRequest {
270
+ cloned := *request
271
+ cloned.Messages = append([]models.Message(nil), request.Messages...)
272
+
273
+ var b strings.Builder
274
+ b.WriteString("TOOL USE REQUIRED.\n")
275
+ b.WriteString("Your next assistant message MUST be a tool call and must contain only the tool call in the exact bridge format. Do not output any natural language.\n")
276
+ if choice.Mode == "function" && strings.TrimSpace(choice.FunctionName) != "" {
277
+ b.WriteString(fmt.Sprintf("You MUST call function %q.\n", strings.TrimSpace(choice.FunctionName)))
278
+ } else {
279
+ b.WriteString("You MUST call at least one tool.\n")
280
+ }
281
+ b.WriteString("After receiving the tool result, you will provide the final answer.\n")
282
+
283
+ sys := models.Message{Role: "system", Content: b.String()}
284
+ cloned.Messages = append([]models.Message{sys}, cloned.Messages...)
285
+ return &cloned
286
+ }
287
+
288
+ // ChatCompletionNonStream runs a non-stream chat completion and returns a single OpenAI-compatible response.
289
+ // It includes a Kilo-compatibility retry: if tools are provided and tool use is required but no tool_calls
290
+ // are produced, it retries once with a stronger system instruction.
291
+ func (s *CursorService) ChatCompletionNonStream(ctx context.Context, request *models.ChatCompletionRequest) (*models.ChatCompletionResponse, error) {
292
+ required, choice, err := s.toolCallRequiredForRequest(request)
293
+ if err != nil {
294
+ return nil, middleware.NewRequestValidationError(err.Error(), "invalid_tool_choice")
295
+ }
296
+
297
+ runOnce := func(req *models.ChatCompletionRequest) (nonStreamCollectResult, error) {
298
+ gen, err := s.ChatCompletion(ctx, req)
299
+ if err != nil {
300
+ return nonStreamCollectResult{}, err
301
+ }
302
+ return s.collectNonStream(ctx, gen, req.Model)
303
+ }
304
+
305
+ result, err := runOnce(request)
306
+ if err != nil {
307
+ return nil, err
308
+ }
309
+
310
+ if required && len(result.ToolCalls) == 0 {
311
+ retryReq := s.withToolRetrySystemMessage(request, choice)
312
+ retryResult, retryErr := runOnce(retryReq)
313
+ if retryErr == nil {
314
+ result = retryResult
315
+ } else {
316
+ logrus.WithError(retryErr).Warn("tool-required retry failed; returning first attempt")
317
+ }
318
+ }
319
+
320
+ respID := utils.GenerateChatCompletionID()
321
+ return models.NewChatCompletionResponse(respID, request.Model, result.Message, result.FinishReason, result.Usage), nil
322
+ }
323
+
324
+ func (s *CursorService) consumeSSE(ctx context.Context, resp *http.Response, output chan interface{}, parseConfig models.CursorParseConfig) {
325
+ defer close(output)
326
+ defer resp.Body.Close()
327
+
328
+ parser := utils.NewCursorProtocolParser(parseConfig)
329
+ scanner := bufio.NewScanner(resp.Body)
330
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
331
+
332
+ flushParser := func() {
333
+ for _, event := range parser.Finish() {
334
+ select {
335
+ case output <- event:
336
+ case <-ctx.Done():
337
+ return
338
+ }
339
+ }
340
+ }
341
+
342
+ for scanner.Scan() {
343
+ select {
344
+ case <-ctx.Done():
345
+ return
346
+ default:
347
+ }
348
+
349
+ data := utils.ParseSSELine(scanner.Text())
350
+ if data == "" {
351
+ continue
352
+ }
353
+
354
+ if data == "[DONE]" {
355
+ flushParser()
356
+ return
357
+ }
358
+
359
+ var eventData models.CursorEventData
360
+ if err := json.Unmarshal([]byte(data), &eventData); err != nil {
361
+ logrus.WithError(err).Debugf("Failed to parse SSE data: %s", data)
362
+ continue
363
+ }
364
+
365
+ switch eventData.Type {
366
+ case "error":
367
+ if eventData.ErrorText != "" {
368
+ errResp := middleware.NewCursorWebError(http.StatusBadGateway, "cursor API error: "+eventData.ErrorText)
369
+ select {
370
+ case output <- errResp:
371
+ default:
372
+ logrus.WithError(errResp).Warn("failed to push SSE error to channel")
373
+ }
374
+ return
375
+ }
376
+ case "finish":
377
+ flushParser()
378
+ if eventData.MessageMetadata != nil && eventData.MessageMetadata.Usage != nil {
379
+ usage := models.Usage{
380
+ PromptTokens: eventData.MessageMetadata.Usage.InputTokens,
381
+ CompletionTokens: eventData.MessageMetadata.Usage.OutputTokens,
382
+ TotalTokens: eventData.MessageMetadata.Usage.TotalTokens,
383
+ }
384
+ select {
385
+ case output <- usage:
386
+ case <-ctx.Done():
387
+ return
388
+ }
389
+ }
390
+ return
391
+ default:
392
+ if eventData.Delta == "" {
393
+ continue
394
+ }
395
+ for _, event := range parser.Feed(eventData.Delta) {
396
+ select {
397
+ case output <- event:
398
+ case <-ctx.Done():
399
+ return
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ if err := scanner.Err(); err != nil {
406
+ if errors.Is(err, context.Canceled) {
407
+ return
408
+ }
409
+ errResp := middleware.NewCursorWebError(http.StatusBadGateway, err.Error())
410
+ select {
411
+ case output <- errResp:
412
+ default:
413
+ logrus.WithError(err).Warn("failed to push SSE error to channel")
414
+ }
415
+ return
416
+ }
417
+
418
+ flushParser()
419
+ }
420
+
421
+ func (s *CursorService) fetchXIsHuman(ctx context.Context) (string, error) {
422
+ // 检查缓存
423
+ s.scriptMutex.RLock()
424
+ cached := s.scriptCache
425
+ lastFetch := s.scriptCacheTime
426
+ s.scriptMutex.RUnlock()
427
+
428
+ var scriptBody string
429
+ // 缓存有效期缩短到1分钟,避免 token 过期
430
+ if cached != "" && time.Since(lastFetch) < 1*time.Minute {
431
+ scriptBody = cached
432
+ } else {
433
+ resp, err := s.client.R().
434
+ SetContext(ctx).
435
+ SetHeaders(s.scriptHeaders()).
436
+ Get(s.config.ScriptURL)
437
+
438
+ if err != nil {
439
+ // 如果请求失败且有缓存,使用缓存
440
+ if cached != "" {
441
+ logrus.Warnf("Failed to fetch script, using cached version: %v", err)
442
+ scriptBody = cached
443
+ } else {
444
+ // 清除缓存并生成一个简单的token
445
+ s.scriptMutex.Lock()
446
+ s.scriptCache = ""
447
+ s.scriptCacheTime = time.Time{}
448
+ s.scriptMutex.Unlock()
449
+ // 生成一个简单的x-is-human token作为fallback
450
+ token := utils.GenerateRandomString(64)
451
+ logrus.Warnf("Failed to fetch script, generated fallback token")
452
+ return token, nil
453
+ }
454
+ } else if resp.StatusCode != http.StatusOK {
455
+ // 如果状态码异常且有缓存,使用缓存
456
+ if cached != "" {
457
+ logrus.Warnf("Script fetch returned status %d, using cached version", resp.StatusCode)
458
+ scriptBody = cached
459
+ } else {
460
+ // 清除缓存并生成一个简单的token
461
+ s.scriptMutex.Lock()
462
+ s.scriptCache = ""
463
+ s.scriptCacheTime = time.Time{}
464
+ s.scriptMutex.Unlock()
465
+ // 生成一个简单的x-is-human token作为fallback
466
+ token := utils.GenerateRandomString(64)
467
+ logrus.Warnf("Script fetch returned status %d, generated fallback token", resp.StatusCode)
468
+ return token, nil
469
+ }
470
+ } else {
471
+ scriptBody = string(resp.Bytes())
472
+ // 更新缓存
473
+ s.scriptMutex.Lock()
474
+ s.scriptCache = scriptBody
475
+ s.scriptCacheTime = time.Now()
476
+ s.scriptMutex.Unlock()
477
+ }
478
+ }
479
+
480
+ compiled := s.prepareJS(scriptBody)
481
+ value, err := utils.RunJS(compiled)
482
+ if err != nil {
483
+ // JS 执行失败时清除缓存并生成fallback token
484
+ s.scriptMutex.Lock()
485
+ s.scriptCache = ""
486
+ s.scriptCacheTime = time.Time{}
487
+ s.scriptMutex.Unlock()
488
+ token := utils.GenerateRandomString(64)
489
+ logrus.Warnf("Failed to execute JS, generated fallback token: %v", err)
490
+ return token, nil
491
+ }
492
+
493
+ logrus.WithField("length", len(value)).Debug("Fetched x-is-human token")
494
+
495
+ return value, nil
496
+ }
497
+
498
+ func (s *CursorService) prepareJS(cursorJS string) string {
499
+ replacer := strings.NewReplacer(
500
+ "$$currentScriptSrc$$", s.config.ScriptURL,
501
+ "$$UNMASKED_VENDOR_WEBGL$$", s.config.FP.UNMASKED_VENDOR_WEBGL,
502
+ "$$UNMASKED_RENDERER_WEBGL$$", s.config.FP.UNMASKED_RENDERER_WEBGL,
503
+ "$$userAgent$$", s.config.FP.UserAgent,
504
+ )
505
+
506
+ mainScript := replacer.Replace(s.mainJS)
507
+ mainScript = strings.Replace(mainScript, "$$env_jscode$$", s.envJS, 1)
508
+ mainScript = strings.Replace(mainScript, "$$cursor_jscode$$", cursorJS, 1)
509
+ return mainScript
510
+ }
511
+
512
+ func (s *CursorService) truncateCursorMessages(messages []models.CursorMessage) []models.CursorMessage {
513
+ if len(messages) == 0 || s.config.MaxInputLength <= 0 {
514
+ return messages
515
+ }
516
+
517
+ maxLength := s.config.MaxInputLength
518
+ total := 0
519
+ for _, msg := range messages {
520
+ total += cursorMessageTextLength(msg)
521
+ }
522
+
523
+ if total <= maxLength {
524
+ return messages
525
+ }
526
+
527
+ var result []models.CursorMessage
528
+ startIdx := 0
529
+
530
+ if strings.EqualFold(messages[0].Role, "system") {
531
+ result = append(result, messages[0])
532
+ maxLength -= cursorMessageTextLength(messages[0])
533
+ if maxLength < 0 {
534
+ maxLength = 0
535
+ }
536
+ startIdx = 1
537
+ }
538
+
539
+ current := 0
540
+ collected := make([]models.CursorMessage, 0, len(messages)-startIdx)
541
+ for i := len(messages) - 1; i >= startIdx; i-- {
542
+ msg := messages[i]
543
+ msgLen := cursorMessageTextLength(msg)
544
+ if msgLen == 0 {
545
+ continue
546
+ }
547
+ if current+msgLen > maxLength {
548
+ continue
549
+ }
550
+ collected = append(collected, msg)
551
+ current += msgLen
552
+ }
553
+
554
+ for i, j := 0, len(collected)-1; i < j; i, j = i+1, j-1 {
555
+ collected[i], collected[j] = collected[j], collected[i]
556
+ }
557
+
558
+ return append(result, collected...)
559
+ }
560
+
561
+ func cursorMessageTextLength(msg models.CursorMessage) int {
562
+ total := 0
563
+ for _, part := range msg.Parts {
564
+ total += len(part.Text)
565
+ }
566
+ return total
567
+ }
568
+
569
+ func (s *CursorService) chatHeaders(xIsHuman string) map[string]string {
570
+ return s.headerGenerator.GetChatHeaders(xIsHuman)
571
+ }
572
+
573
+ func (s *CursorService) scriptHeaders() map[string]string {
574
+ return s.headerGenerator.GetScriptHeaders()
575
+ }
services/cursor_protocol.go ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "bytes"
5
+ "cursor2api-go/middleware"
6
+ "cursor2api-go/models"
7
+ "cursor2api-go/utils"
8
+ "encoding/json"
9
+ "fmt"
10
+ "strings"
11
+ )
12
+
13
+ const thinkingHint = "Use <thinking>...</thinking> for hidden reasoning when it helps. Keep your final visible answer outside the thinking tags."
14
+
15
+ type cursorBuildResult struct {
16
+ Payload models.CursorRequest
17
+ ParseConfig models.CursorParseConfig
18
+ }
19
+
20
+ type toolChoiceSpec struct {
21
+ Mode string
22
+ FunctionName string
23
+ }
24
+
25
+ func (s *CursorService) buildCursorRequest(request *models.ChatCompletionRequest) (cursorBuildResult, error) {
26
+ capability := models.ResolveModelCapability(request.Model)
27
+ toolChoice, err := parseToolChoice(request.ToolChoice)
28
+ if err != nil {
29
+ return cursorBuildResult{}, middleware.NewRequestValidationError(err.Error(), "invalid_tool_choice")
30
+ }
31
+
32
+ // Kilo Code 兼容:当上层编排器希望“必须用工具”时,即便 tool_choice=auto,
33
+ // 也可以通过环境变量强制要求至少一次工具调用,避免 MODEL_NO_TOOLS_USED 一类的上层报错。
34
+ if s.config != nil && s.config.KiloToolStrict && len(request.Tools) > 0 && toolChoice.Mode == "auto" {
35
+ toolChoice.Mode = "required"
36
+ }
37
+
38
+ if len(request.Tools) == 0 && toolChoice.Mode != "auto" && toolChoice.Mode != "none" {
39
+ return cursorBuildResult{}, middleware.NewRequestValidationError("tool_choice requires tools to be provided", "missing_tools")
40
+ }
41
+
42
+ if err := validateTools(request.Tools); err != nil {
43
+ return cursorBuildResult{}, middleware.NewRequestValidationError(err.Error(), "invalid_tools")
44
+ }
45
+
46
+ if toolChoice.FunctionName != "" && !toolExists(request.Tools, toolChoice.FunctionName) {
47
+ return cursorBuildResult{}, middleware.NewRequestValidationError(
48
+ fmt.Sprintf("tool_choice references unknown function %q", toolChoice.FunctionName),
49
+ "unknown_tool_choice_function",
50
+ )
51
+ }
52
+
53
+ hasToolHistory := messagesContainToolHistory(request.Messages)
54
+ toolProtocolEnabled := len(request.Tools) > 0 && toolChoice.Mode != "none"
55
+ triggerSignal := ""
56
+ if toolProtocolEnabled || hasToolHistory {
57
+ triggerSignal = "<<CALL_" + utils.GenerateRandomString(8) + ">>"
58
+ }
59
+
60
+ cursorMessages := buildCursorMessages(
61
+ request.Messages,
62
+ s.config.SystemPromptInject,
63
+ request.Tools,
64
+ toolChoice,
65
+ capability,
66
+ hasToolHistory,
67
+ triggerSignal,
68
+ )
69
+ cursorMessages = s.truncateCursorMessages(cursorMessages)
70
+
71
+ payload := models.CursorRequest{
72
+ Context: []interface{}{},
73
+ Model: models.GetCursorModel(request.Model),
74
+ ID: utils.GenerateRandomString(16),
75
+ Messages: cursorMessages,
76
+ Trigger: "submit-message",
77
+ }
78
+
79
+ return cursorBuildResult{
80
+ Payload: payload,
81
+ ParseConfig: models.CursorParseConfig{
82
+ TriggerSignal: triggerSignal,
83
+ ThinkingEnabled: capability.ThinkingEnabled,
84
+ },
85
+ }, nil
86
+ }
87
+
88
+ func parseToolChoice(raw json.RawMessage) (toolChoiceSpec, error) {
89
+ if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
90
+ return toolChoiceSpec{Mode: "auto"}, nil
91
+ }
92
+
93
+ var choiceString string
94
+ if err := json.Unmarshal(raw, &choiceString); err == nil {
95
+ switch choiceString {
96
+ case "auto", "none", "required":
97
+ return toolChoiceSpec{Mode: choiceString}, nil
98
+ default:
99
+ return toolChoiceSpec{}, fmt.Errorf("unsupported tool_choice value %q", choiceString)
100
+ }
101
+ }
102
+
103
+ var choiceObject models.ToolChoiceObject
104
+ if err := json.Unmarshal(raw, &choiceObject); err != nil {
105
+ return toolChoiceSpec{}, fmt.Errorf("tool_choice must be a string or function object")
106
+ }
107
+
108
+ if choiceObject.Type != "function" {
109
+ return toolChoiceSpec{}, fmt.Errorf("unsupported tool_choice type %q", choiceObject.Type)
110
+ }
111
+ if choiceObject.Function == nil || strings.TrimSpace(choiceObject.Function.Name) == "" {
112
+ return toolChoiceSpec{}, fmt.Errorf("tool_choice.function.name is required")
113
+ }
114
+
115
+ return toolChoiceSpec{
116
+ Mode: "function",
117
+ FunctionName: strings.TrimSpace(choiceObject.Function.Name),
118
+ }, nil
119
+ }
120
+
121
+ func validateTools(tools []models.Tool) error {
122
+ seen := make(map[string]struct{}, len(tools))
123
+ for _, tool := range tools {
124
+ toolType := tool.Type
125
+ if toolType == "" {
126
+ toolType = "function"
127
+ }
128
+ if toolType != "function" {
129
+ return fmt.Errorf("unsupported tool type %q", tool.Type)
130
+ }
131
+
132
+ name := strings.TrimSpace(tool.Function.Name)
133
+ if name == "" {
134
+ return fmt.Errorf("tool function name is required")
135
+ }
136
+ if _, exists := seen[name]; exists {
137
+ return fmt.Errorf("duplicate tool function name %q", name)
138
+ }
139
+ seen[name] = struct{}{}
140
+ }
141
+ return nil
142
+ }
143
+
144
+ func toolExists(tools []models.Tool, name string) bool {
145
+ for _, tool := range tools {
146
+ if strings.TrimSpace(tool.Function.Name) == name {
147
+ return true
148
+ }
149
+ }
150
+ return false
151
+ }
152
+
153
+ func buildCursorMessages(
154
+ messages []models.Message,
155
+ systemPromptInject string,
156
+ tools []models.Tool,
157
+ toolChoice toolChoiceSpec,
158
+ capability models.ModelCapability,
159
+ hasToolHistory bool,
160
+ triggerSignal string,
161
+ ) []models.CursorMessage {
162
+ result := make([]models.CursorMessage, 0, len(messages)+1)
163
+ startIdx := 0
164
+ systemSegments := make([]string, 0, 3)
165
+
166
+ if len(messages) > 0 && strings.EqualFold(messages[0].Role, "system") {
167
+ if systemText := strings.TrimSpace(messages[0].GetStringContent()); systemText != "" {
168
+ systemSegments = append(systemSegments, systemText)
169
+ }
170
+ startIdx = 1
171
+ }
172
+ if inject := strings.TrimSpace(systemPromptInject); inject != "" {
173
+ systemSegments = append(systemSegments, inject)
174
+ }
175
+ if protocolText := strings.TrimSpace(buildProtocolPrompt(tools, toolChoice, capability.ThinkingEnabled, hasToolHistory, triggerSignal)); protocolText != "" {
176
+ systemSegments = append(systemSegments, protocolText)
177
+ }
178
+ if len(systemSegments) > 0 {
179
+ result = append(result, newCursorTextMessage("system", strings.Join(systemSegments, "\n\n")))
180
+ }
181
+
182
+ for _, msg := range messages[startIdx:] {
183
+ converted, ok := convertMessage(msg, capability.ThinkingEnabled, triggerSignal)
184
+ if !ok {
185
+ continue
186
+ }
187
+ result = append(result, converted)
188
+ }
189
+
190
+ return result
191
+ }
192
+
193
+ func buildProtocolPrompt(tools []models.Tool, toolChoice toolChoiceSpec, thinkingEnabled bool, hasToolHistory bool, triggerSignal string) string {
194
+ var sections []string
195
+
196
+ if len(tools) > 0 && triggerSignal != "" {
197
+ var builder strings.Builder
198
+ builder.WriteString("You may call external tools through the bridge below.\n")
199
+ builder.WriteString("When you need a tool, output exactly in this format with no markdown fences:\n")
200
+ builder.WriteString(triggerSignal)
201
+ builder.WriteString("\n<invoke name=\"tool_name\">{\"arg\":\"value\"}</invoke>\n")
202
+ builder.WriteString("Available tools:\n")
203
+ builder.WriteString(renderFunctionList(tools))
204
+
205
+ switch toolChoice.Mode {
206
+ case "required":
207
+ builder.WriteString("\nYou must call at least one tool before your final answer.")
208
+ builder.WriteString("\nIMPORTANT: Your next assistant message MUST be a tool call using the exact format above. Do not include any natural language text in that message.")
209
+ case "function":
210
+ builder.WriteString(fmt.Sprintf("\nYou must call the function %q before your final answer.", toolChoice.FunctionName))
211
+ builder.WriteString("\nIMPORTANT: Your next assistant message MUST be a tool call using the exact format above. Do not include any natural language text in that message.")
212
+ }
213
+
214
+ sections = append(sections, builder.String())
215
+ } else if hasToolHistory && triggerSignal != "" {
216
+ var builder strings.Builder
217
+ builder.WriteString("Previous assistant tool calls in this conversation are serialized in the following format:\n")
218
+ builder.WriteString(triggerSignal)
219
+ builder.WriteString("\n<invoke name=\"tool_name\">{\"arg\":\"value\"}</invoke>\n")
220
+ builder.WriteString("Previous tool results are serialized as <tool_result ...>...</tool_result>.\n")
221
+ builder.WriteString("Treat those tool transcripts as completed history. Do not emit a new tool call unless a current tool list is provided.")
222
+ sections = append(sections, builder.String())
223
+ }
224
+
225
+ if thinkingEnabled {
226
+ sections = append(sections, thinkingHint)
227
+ }
228
+
229
+ return strings.Join(sections, "\n\n")
230
+ }
231
+
232
+ func messagesContainToolHistory(messages []models.Message) bool {
233
+ for _, msg := range messages {
234
+ if len(msg.ToolCalls) > 0 {
235
+ return true
236
+ }
237
+ if strings.EqualFold(strings.TrimSpace(msg.Role), "tool") {
238
+ return true
239
+ }
240
+ }
241
+ return false
242
+ }
243
+
244
+ func renderFunctionList(tools []models.Tool) string {
245
+ var builder strings.Builder
246
+ builder.WriteString("<function_list>\n")
247
+ for _, tool := range tools {
248
+ schema := "{}"
249
+ if len(tool.Function.Parameters) > 0 {
250
+ if marshaled, err := json.MarshalIndent(tool.Function.Parameters, "", " "); err == nil {
251
+ schema = string(marshaled)
252
+ }
253
+ }
254
+
255
+ builder.WriteString(fmt.Sprintf("<function name=\"%s\">\n", tool.Function.Name))
256
+ if desc := strings.TrimSpace(tool.Function.Description); desc != "" {
257
+ builder.WriteString(desc)
258
+ builder.WriteString("\n")
259
+ }
260
+ builder.WriteString("JSON Schema:\n")
261
+ builder.WriteString(schema)
262
+ builder.WriteString("\n</function>\n")
263
+ }
264
+ builder.WriteString("</function_list>")
265
+ return builder.String()
266
+ }
267
+
268
+ func convertMessage(msg models.Message, thinkingEnabled bool, triggerSignal string) (models.CursorMessage, bool) {
269
+ role := strings.TrimSpace(msg.Role)
270
+ if role == "" {
271
+ return models.CursorMessage{}, false
272
+ }
273
+
274
+ switch role {
275
+ case "tool":
276
+ return newCursorTextMessage("user", formatToolResult(msg)), true
277
+ case "assistant":
278
+ text := strings.TrimSpace(msg.GetStringContent())
279
+ segments := make([]string, 0, len(msg.ToolCalls)+1)
280
+ if text != "" {
281
+ segments = append(segments, text)
282
+ }
283
+ for _, toolCall := range msg.ToolCalls {
284
+ segments = append(segments, formatAssistantToolCall(toolCall, triggerSignal))
285
+ }
286
+ if len(segments) == 0 {
287
+ return models.CursorMessage{}, false
288
+ }
289
+ return newCursorTextMessage("assistant", strings.Join(segments, "\n\n")), true
290
+ case "user":
291
+ text := msg.GetStringContent()
292
+ if thinkingEnabled {
293
+ text = appendThinkingHint(text)
294
+ }
295
+ if strings.TrimSpace(text) == "" {
296
+ return models.CursorMessage{}, false
297
+ }
298
+ return newCursorTextMessage("user", text), true
299
+ default:
300
+ text := msg.GetStringContent()
301
+ if strings.TrimSpace(text) == "" {
302
+ return models.CursorMessage{}, false
303
+ }
304
+ return newCursorTextMessage(role, text), true
305
+ }
306
+ }
307
+
308
+ func appendThinkingHint(content string) string {
309
+ content = strings.TrimSpace(content)
310
+ if content == "" {
311
+ return thinkingHint
312
+ }
313
+ return content + "\n\n" + thinkingHint
314
+ }
315
+
316
+ func formatAssistantToolCall(toolCall models.ToolCall, triggerSignal string) string {
317
+ pieces := make([]string, 0, 2)
318
+ if triggerSignal != "" {
319
+ pieces = append(pieces, triggerSignal)
320
+ }
321
+
322
+ callType := toolCall.Type
323
+ if callType == "" {
324
+ callType = "function"
325
+ }
326
+ name := strings.TrimSpace(toolCall.Function.Name)
327
+ if name == "" {
328
+ name = "tool"
329
+ }
330
+
331
+ arguments := strings.TrimSpace(toolCall.Function.Arguments)
332
+ if arguments == "" {
333
+ arguments = "{}"
334
+ }
335
+
336
+ pieces = append(pieces, fmt.Sprintf("<invoke name=\"%s\">%s</invoke>", name, arguments))
337
+ return strings.Join(pieces, "\n")
338
+ }
339
+
340
+ func formatToolResult(msg models.Message) string {
341
+ content := msg.GetStringContent()
342
+ id := strings.TrimSpace(msg.ToolCallID)
343
+ name := strings.TrimSpace(msg.Name)
344
+
345
+ var builder strings.Builder
346
+ builder.WriteString("<tool_result")
347
+ if id != "" {
348
+ builder.WriteString(fmt.Sprintf(" id=\"%s\"", id))
349
+ }
350
+ if name != "" {
351
+ builder.WriteString(fmt.Sprintf(" name=\"%s\"", name))
352
+ }
353
+ builder.WriteString(">")
354
+ builder.WriteString(content)
355
+ builder.WriteString("</tool_result>")
356
+ return builder.String()
357
+ }
358
+
359
+ func newCursorTextMessage(role, text string) models.CursorMessage {
360
+ return models.CursorMessage{
361
+ Role: role,
362
+ Parts: []models.CursorPart{
363
+ {
364
+ Type: "text",
365
+ Text: text,
366
+ },
367
+ },
368
+ }
369
+ }
services/cursor_protocol_test.go ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "cursor2api-go/config"
5
+ "cursor2api-go/models"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestBuildCursorRequestEnablesToolProtocolForBaseModel(t *testing.T) {
11
+ service := &CursorService{
12
+ config: &config.Config{
13
+ SystemPromptInject: "Injected system prompt",
14
+ MaxInputLength: 10000,
15
+ },
16
+ }
17
+
18
+ request := &models.ChatCompletionRequest{
19
+ Model: "claude-sonnet-4.6",
20
+ Messages: []models.Message{
21
+ {Role: "user", Content: "What's the weather?"},
22
+ },
23
+ Tools: []models.Tool{
24
+ {
25
+ Type: "function",
26
+ Function: models.FunctionDefinition{
27
+ Name: "get_weather",
28
+ Description: "Fetch current weather",
29
+ Parameters: map[string]interface{}{
30
+ "type": "object",
31
+ },
32
+ },
33
+ },
34
+ },
35
+ }
36
+
37
+ result, err := service.buildCursorRequest(request)
38
+ if err != nil {
39
+ t.Fatalf("buildCursorRequest() error = %v", err)
40
+ }
41
+
42
+ if result.Payload.Model != "anthropic/claude-sonnet-4.6" {
43
+ t.Fatalf("Payload.Model = %v, want anthropic/claude-sonnet-4.6", result.Payload.Model)
44
+ }
45
+ if result.ParseConfig.TriggerSignal == "" {
46
+ t.Fatalf("TriggerSignal should not be empty")
47
+ }
48
+ if result.ParseConfig.ThinkingEnabled {
49
+ t.Fatalf("ThinkingEnabled = true, want false")
50
+ }
51
+
52
+ systemText := result.Payload.Messages[0].Parts[0].Text
53
+ if !strings.Contains(systemText, "<function_list>") {
54
+ t.Fatalf("system prompt does not include function list: %s", systemText)
55
+ }
56
+ if strings.Contains(systemText, thinkingHint) {
57
+ t.Fatalf("system prompt should not include thinking hint for base model")
58
+ }
59
+ }
60
+
61
+ func TestBuildCursorRequestThinkingModelFormatsToolHistory(t *testing.T) {
62
+ service := &CursorService{
63
+ config: &config.Config{
64
+ MaxInputLength: 10000,
65
+ },
66
+ }
67
+
68
+ request := &models.ChatCompletionRequest{
69
+ Model: "claude-sonnet-4.6-thinking",
70
+ Messages: []models.Message{
71
+ {Role: "user", Content: "Plan first, then use tools."},
72
+ {
73
+ Role: "assistant",
74
+ ToolCalls: []models.ToolCall{
75
+ {
76
+ ID: "call_1",
77
+ Type: "function",
78
+ Function: models.FunctionCall{
79
+ Name: "lookup",
80
+ Arguments: `{"q":"revivalquant"}`,
81
+ },
82
+ },
83
+ },
84
+ },
85
+ {Role: "tool", ToolCallID: "call_1", Name: "lookup", Content: "Found result"},
86
+ },
87
+ Tools: []models.Tool{
88
+ {
89
+ Type: "function",
90
+ Function: models.FunctionDefinition{
91
+ Name: "lookup",
92
+ },
93
+ },
94
+ },
95
+ }
96
+
97
+ result, err := service.buildCursorRequest(request)
98
+ if err != nil {
99
+ t.Fatalf("buildCursorRequest() error = %v", err)
100
+ }
101
+
102
+ if result.ParseConfig.TriggerSignal == "" {
103
+ t.Fatalf("TriggerSignal should not be empty")
104
+ }
105
+ if !result.ParseConfig.ThinkingEnabled {
106
+ t.Fatalf("ThinkingEnabled = false, want true")
107
+ }
108
+ if result.Payload.Model != "anthropic/claude-sonnet-4.6" {
109
+ t.Fatalf("Payload.Model = %v, want anthropic/claude-sonnet-4.6", result.Payload.Model)
110
+ }
111
+
112
+ userText := result.Payload.Messages[1].Parts[0].Text
113
+ if !strings.Contains(userText, thinkingHint) {
114
+ t.Fatalf("user message should contain thinking hint, got: %s", userText)
115
+ }
116
+
117
+ assistantText := result.Payload.Messages[2].Parts[0].Text
118
+ if !strings.Contains(assistantText, result.ParseConfig.TriggerSignal) {
119
+ t.Fatalf("assistant tool history should include trigger signal, got: %s", assistantText)
120
+ }
121
+ if !strings.Contains(assistantText, `<invoke name="lookup">{"q":"revivalquant"}</invoke>`) {
122
+ t.Fatalf("assistant tool history missing invoke block, got: %s", assistantText)
123
+ }
124
+
125
+ toolText := result.Payload.Messages[3].Parts[0].Text
126
+ if !strings.Contains(toolText, `<tool_result id="call_1" name="lookup">Found result</tool_result>`) {
127
+ t.Fatalf("tool result history missing tool_result block, got: %s", toolText)
128
+ }
129
+ }
130
+
131
+ func TestBuildCursorRequestPreservesToolHistoryWithoutCurrentTools(t *testing.T) {
132
+ service := &CursorService{
133
+ config: &config.Config{
134
+ MaxInputLength: 10000,
135
+ },
136
+ }
137
+
138
+ request := &models.ChatCompletionRequest{
139
+ Model: "claude-sonnet-4.6",
140
+ ToolChoice: []byte(`"none"`),
141
+ Messages: []models.Message{
142
+ {
143
+ Role: "assistant",
144
+ ToolCalls: []models.ToolCall{
145
+ {
146
+ ID: "call_weather",
147
+ Type: "function",
148
+ Function: models.FunctionCall{
149
+ Name: "get_weather",
150
+ Arguments: `{"city":"Beijing"}`,
151
+ },
152
+ },
153
+ },
154
+ },
155
+ {Role: "tool", ToolCallID: "call_weather", Name: "get_weather", Content: "Sunny"},
156
+ {Role: "user", Content: "Summarize the result."},
157
+ },
158
+ }
159
+
160
+ result, err := service.buildCursorRequest(request)
161
+ if err != nil {
162
+ t.Fatalf("buildCursorRequest() error = %v", err)
163
+ }
164
+
165
+ if result.ParseConfig.TriggerSignal == "" {
166
+ t.Fatalf("TriggerSignal should be kept for tool history replay")
167
+ }
168
+
169
+ systemText := result.Payload.Messages[0].Parts[0].Text
170
+ if !strings.Contains(systemText, "completed history") {
171
+ t.Fatalf("system prompt should explain historical tool transcript, got: %s", systemText)
172
+ }
173
+ if !strings.Contains(systemText, result.ParseConfig.TriggerSignal) {
174
+ t.Fatalf("system prompt should include trigger signal, got: %s", systemText)
175
+ }
176
+
177
+ assistantText := result.Payload.Messages[1].Parts[0].Text
178
+ if !strings.Contains(assistantText, result.ParseConfig.TriggerSignal) {
179
+ t.Fatalf("assistant history should preserve trigger signal, got: %s", assistantText)
180
+ }
181
+ }
182
+
183
+ func TestBuildCursorRequestAllowsToolChoiceNoneWithoutTools(t *testing.T) {
184
+ service := &CursorService{
185
+ config: &config.Config{
186
+ MaxInputLength: 10000,
187
+ },
188
+ }
189
+
190
+ request := &models.ChatCompletionRequest{
191
+ Model: "claude-sonnet-4.6",
192
+ ToolChoice: []byte(`"none"`),
193
+ Messages: []models.Message{
194
+ {Role: "user", Content: "Hello"},
195
+ },
196
+ }
197
+
198
+ result, err := service.buildCursorRequest(request)
199
+ if err != nil {
200
+ t.Fatalf("buildCursorRequest() error = %v", err)
201
+ }
202
+ if result.ParseConfig.TriggerSignal != "" {
203
+ t.Fatalf("TriggerSignal = %q, want empty for plain chat", result.ParseConfig.TriggerSignal)
204
+ }
205
+ if len(result.Payload.Messages) != 1 {
206
+ t.Fatalf("payload message count = %d, want 1", len(result.Payload.Messages))
207
+ }
208
+ }
209
+
210
+ func TestBuildCursorRequestCountsSerializedToolCallsInMaxInputLength(t *testing.T) {
211
+ service := &CursorService{
212
+ config: &config.Config{
213
+ MaxInputLength: 20,
214
+ },
215
+ }
216
+
217
+ request := &models.ChatCompletionRequest{
218
+ Model: "claude-sonnet-4.6",
219
+ Messages: []models.Message{
220
+ {
221
+ Role: "assistant",
222
+ ToolCalls: []models.ToolCall{
223
+ {
224
+ ID: "call_1",
225
+ Type: "function",
226
+ Function: models.FunctionCall{
227
+ Name: "lookup",
228
+ Arguments: `{"payload":"1234567890123456789012345678901234567890"}`,
229
+ },
230
+ },
231
+ },
232
+ },
233
+ {Role: "user", Content: "Short"},
234
+ },
235
+ }
236
+
237
+ result, err := service.buildCursorRequest(request)
238
+ if err != nil {
239
+ t.Fatalf("buildCursorRequest() error = %v", err)
240
+ }
241
+
242
+ for _, msg := range result.Payload.Messages {
243
+ if strings.Contains(msg.Parts[0].Text, `"payload":"1234567890123456789012345678901234567890"`) {
244
+ t.Fatalf("serialized tool call arguments should be removed by truncation, payload still contains long tool json: %#v", result.Payload.Messages)
245
+ }
246
+ }
247
+ totalLength := 0
248
+ for _, msg := range result.Payload.Messages {
249
+ totalLength += len(msg.Parts[0].Text)
250
+ }
251
+ if totalLength == 0 {
252
+ t.Fatalf("truncation should preserve at least one message")
253
+ }
254
+ }
start-go-utf8.bat ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ chcp 65001 >nul 2>&1
3
+ setlocal enabledelayedexpansion
4
+
5
+ :: Cursor2API启动脚本
6
+
7
+ echo.
8
+ echo =========================================
9
+ echo 🚀 Cursor2API启动器
10
+ echo =========================================
11
+ echo.
12
+
13
+ :: 检查Go是否安装
14
+ go version >nul 2>&1
15
+ if errorlevel 1 (
16
+ echo ❌ Go 未安装,请先安装 Go 1.21 或更高版本
17
+ echo 💡 安装方法: https://golang.org/dl/
18
+ pause
19
+ exit /b 1
20
+ )
21
+
22
+ :: 显示Go版本并检查版本号
23
+ for /f "tokens=3" %%i in ('go version') do set GO_VERSION=%%i
24
+ set GO_VERSION=!GO_VERSION:go=!
25
+
26
+ :: 检查Go版本是否满足要求 (需要 >= 1.21)
27
+ for /f "tokens=1,2 delims=." %%a in ("!GO_VERSION!") do (
28
+ set MAJOR=%%a
29
+ set MINOR=%%b
30
+ )
31
+ if !MAJOR! LSS 1 (
32
+ echo ❌ Go 版本 !GO_VERSION! 过低,请安装 Go 1.21 或更高版本
33
+ pause
34
+ exit /b 1
35
+ )
36
+ if !MAJOR! EQU 1 if !MINOR! LSS 21 (
37
+ echo ❌ Go 版本 !GO_VERSION! 过低,请安装 Go 1.21 或更高版本
38
+ pause
39
+ exit /b 1
40
+ )
41
+
42
+ echo ✅ Go 版本检查通过: !GO_VERSION!
43
+
44
+ :: 检查Node.js是否安装
45
+ node --version >nul 2>&1
46
+ if errorlevel 1 (
47
+ echo ❌ Node.js 未安装,请先安装 Node.js 18 或更高版本
48
+ echo 💡 安装方法: https://nodejs.org/
49
+ pause
50
+ exit /b 1
51
+ )
52
+
53
+ :: 显示Node.js版本并检查版本号
54
+ for /f "delims=" %%i in ('node --version') do set NODE_VERSION=%%i
55
+ set NODE_VERSION=!NODE_VERSION:v=!
56
+
57
+ :: 检查Node.js版本是否满足要求 (需要 >= 18)
58
+ for /f "tokens=1 delims=." %%a in ("!NODE_VERSION!") do set NODE_MAJOR=%%a
59
+ if !NODE_MAJOR! LSS 18 (
60
+ echo ❌ Node.js 版本 !NODE_VERSION! 过低,请安装 Node.js 18 或更高版本
61
+ pause
62
+ exit /b 1
63
+ )
64
+
65
+ echo ✅ Node.js 版本检查通过: !NODE_VERSION!
66
+
67
+ :: 创建.env文件(如果不存在)
68
+ if not exist .env (
69
+ echo 📝 创建默认 .env 配置文件...
70
+ (
71
+ echo # 服务器配置
72
+ echo PORT=8002
73
+ echo DEBUG=false
74
+ echo.
75
+ echo # API配置
76
+ echo API_KEY=0000
77
+ echo MODELS=claude-sonnet-4.6
78
+ echo SYSTEM_PROMPT_INJECT=
79
+ echo.
80
+ echo # 请求配置
81
+ echo TIMEOUT=30
82
+ echo USER_AGENT=Mozilla/5.0 ^(Windows NT 10.0; Win64; x64^) AppleWebKit/537.36 ^(KHTML, like Gecko^) Chrome/140.0.0.0 Safari/537.36
83
+ echo.
84
+ echo # Cursor配置
85
+ echo SCRIPT_URL=https://cursor.com/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js?i=0^^^&v=3^^^&h=cursor.com
86
+ ) > .env
87
+ echo ✅ 默认 .env 文件已创建
88
+ ) else (
89
+ echo ✅ 配置文件 .env 已存在
90
+ )
91
+
92
+ :: 下载依赖
93
+ echo.
94
+ echo 📦 正在下载 Go 依赖...
95
+ go mod download
96
+ if errorlevel 1 (
97
+ echo ❌ 依赖下载失败!
98
+ pause
99
+ exit /b 1
100
+ )
101
+
102
+ :: 构建应用
103
+ echo 🔨 正在编译 Go 应用...
104
+ go build -o cursor2api-go.exe .
105
+ if errorlevel 1 (
106
+ echo ❌ 编译失败!
107
+ pause
108
+ exit /b 1
109
+ )
110
+
111
+ :: 检查构建是否成功
112
+ if not exist cursor2api-go.exe (
113
+ echo ❌ 编译失败 - 可执行文件未找到!
114
+ pause
115
+ exit /b 1
116
+ )
117
+
118
+ echo ✅ 应用编译成功!
119
+
120
+ :: 显示服务信息
121
+ echo.
122
+ echo ✅ 准备就绪,正在启动服务...
123
+ echo.
124
+
125
+ :: 启动服务
126
+ cursor2api-go.exe
127
+
128
+ pause
start-go.bat ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ chcp 65001 >nul 2>&1
3
+ setlocal enabledelayedexpansion
4
+
5
+ :: Cursor2API Go启动脚本
6
+
7
+ echo.
8
+ echo =========================================
9
+ echo 🚀 Cursor2API启动器 Go版本
10
+ echo =========================================
11
+ echo.
12
+
13
+ :: 检查Go是否安装
14
+ go version >nul 2>&1
15
+ if errorlevel 1 (
16
+ echo [错误] Go 未安装,请先安装 Go 1.21 或更高版本
17
+ echo [提示] 安装方法: https://golang.org/dl/
18
+ pause
19
+ exit /b 1
20
+ )
21
+
22
+ :: 显示Go版本并检查版本号
23
+ for /f "tokens=3" %%i in ('go version') do set GO_VERSION=%%i
24
+ set GO_VERSION=!GO_VERSION:go=!
25
+
26
+ :: 检查Go版本是否满足要求 (需要 >= 1.21)
27
+ for /f "tokens=1,2 delims=." %%a in ("!GO_VERSION!") do (
28
+ set MAJOR=%%a
29
+ set MINOR=%%b
30
+ )
31
+ if !MAJOR! LSS 1 (
32
+ echo [错误] Go 版本 !GO_VERSION! 过低,请安装 Go 1.21 或更高版本
33
+ pause
34
+ exit /b 1
35
+ )
36
+ if !MAJOR! EQU 1 if !MINOR! LSS 21 (
37
+ echo [错误] Go 版本 !GO_VERSION! 过低,请安装 Go 1.21 或更高版本
38
+ pause
39
+ exit /b 1
40
+ )
41
+
42
+ echo [成功] Go 版本检查通过: !GO_VERSION!
43
+
44
+ :: 检查Node.js是否安装
45
+ node --version >nul 2>&1
46
+ if errorlevel 1 (
47
+ echo [错误] Node.js 未安装,请先安装 Node.js 18 或更高版本
48
+ echo [提示] 安装方法: https://nodejs.org/
49
+ pause
50
+ exit /b 1
51
+ )
52
+
53
+ :: 显示Node.js版本并检查版本号
54
+ for /f "delims=" %%i in ('node --version') do set NODE_VERSION=%%i
55
+ set NODE_VERSION=!NODE_VERSION:v=!
56
+
57
+ :: 检查Node.js版本是否满足要求 (需要 >= 18)
58
+ for /f "tokens=1 delims=." %%a in ("!NODE_VERSION!") do set NODE_MAJOR=%%a
59
+ if !NODE_MAJOR! LSS 18 (
60
+ echo [错误] Node.js 版本 !NODE_VERSION! 过低,请安装 Node.js 18 或更高版本
61
+ pause
62
+ exit /b 1
63
+ )
64
+
65
+ echo [成功] Node.js 版本检查通过: !NODE_VERSION!
66
+
67
+ :: 创建.env文件(如果不存在)
68
+ if not exist .env (
69
+ echo [信息] 创建默认 .env 配置文件...
70
+ (
71
+ echo # 服务器配置
72
+ echo PORT=8002
73
+ echo DEBUG=false
74
+ echo.
75
+ echo # API配置
76
+ echo API_KEY=0000
77
+ echo MODELS=claude-sonnet-4.6
78
+ echo SYSTEM_PROMPT_INJECT=
79
+ echo.
80
+ echo # 请求配置
81
+ echo TIMEOUT=30
82
+ echo USER_AGENT=Mozilla/5.0 ^(Windows NT 10.0; Win64; x64^) AppleWebKit/537.36 ^(KHTML, like Gecko^) Chrome/140.0.0.0 Safari/537.36
83
+ echo.
84
+ echo # Cursor配置
85
+ echo SCRIPT_URL=https://cursor.com/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js?i=0^^^&v=3^^^&h=cursor.com
86
+ ) > .env
87
+ echo [成功] 默认 .env 文件已创建
88
+ ) else (
89
+ echo [成功] 配置文件 .env 已存在
90
+ )
91
+
92
+ :: 下载依赖
93
+ echo.
94
+ echo [信息] 正在下载 Go 依赖...
95
+ go mod download
96
+ if errorlevel 1 (
97
+ echo [错误] 依赖下载失败!
98
+ pause
99
+ exit /b 1
100
+ )
101
+
102
+ :: 构建应用
103
+ echo [信息] 正在编译 Go 应用...
104
+ go build -o cursor2api-go.exe .
105
+ if errorlevel 1 (
106
+ echo [错误] 编译失败!
107
+ pause
108
+ exit /b 1
109
+ )
110
+
111
+ :: 检查构建是否成功
112
+ if not exist cursor2api-go.exe (
113
+ echo [错误] 编译失败 - 可执行文件未找到
114
+ pause
115
+ exit /b 1
116
+ )
117
+
118
+ echo [成功] 应用编译成功!
119
+
120
+ :: 显示服务信息
121
+ echo.
122
+ echo [成功] 准备就绪,正在启动服务...
123
+ echo.
124
+
125
+ :: 启动服务
126
+ cursor2api-go.exe
127
+
128
+ pause
start.sh ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Cursor2API启动脚本
4
+
5
+ set -e
6
+
7
+ # 定义颜色代码
8
+ RED='\033[0;31m'
9
+ GREEN='\033[0;32m'
10
+ BLUE='\033[0;34m'
11
+ YELLOW='\033[1;33m'
12
+ PURPLE='\033[0;35m'
13
+ CYAN='\033[0;36m'
14
+ WHITE='\033[1;37m'
15
+ NC='\033[0m' # No Color
16
+
17
+ # 打印标题
18
+ print_header() {
19
+ echo ""
20
+ echo -e "${CYAN}=========================================${NC}"
21
+ echo -e "${WHITE} 🚀 Cursor2API启动器${NC}"
22
+ echo -e "${CYAN}=========================================${NC}"
23
+ }
24
+
25
+ # 检查Go环境
26
+ check_go() {
27
+ if ! command -v go &> /dev/null; then
28
+ echo -e "${RED}❌ Go 未安装,请先安装 Go 1.21 或更高版本${NC}"
29
+ echo -e "${YELLOW}💡 安装方法: https://golang.org/dl/${NC}"
30
+ exit 1
31
+ fi
32
+
33
+ GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
34
+ REQUIRED_VERSION="1.21"
35
+
36
+ if [ "$(printf '%s\n' "$REQUIRED_VERSION" "$GO_VERSION" | sort -V | head -n1)" != "$REQUIRED_VERSION" ]; then
37
+ echo -e "${RED}❌ Go 版本 $GO_VERSION 过低,请安装 Go $REQUIRED_VERSION 或更高版本${NC}"
38
+ exit 1
39
+ fi
40
+
41
+ echo -e "${GREEN}✅ Go 版本检查通过: $GO_VERSION${NC}"
42
+ }
43
+
44
+ # 检查Node.js环境
45
+ check_nodejs() {
46
+ if ! command -v node &> /dev/null; then
47
+ echo -e "${RED}❌ Node.js 未安装,请先安装 Node.js 18 或更高版本${NC}"
48
+ echo -e "${YELLOW}💡 安装方法: https://nodejs.org/${NC}"
49
+ exit 1
50
+ fi
51
+
52
+ NODE_VERSION=$(node --version | sed 's/v//')
53
+ REQUIRED_VERSION="18.0.0"
54
+
55
+ if [ "$(printf '%s\n' "$REQUIRED_VERSION" "$NODE_VERSION" | sort -V | head -n1)" != "$REQUIRED_VERSION" ]; then
56
+ echo -e "${RED}❌ Node.js 版本 $NODE_VERSION 过低,请安装 Node.js $REQUIRED_VERSION 或更高版本${NC}"
57
+ exit 1
58
+ fi
59
+
60
+ echo -e "${GREEN}✅ Node.js 版本检查通过: $NODE_VERSION${NC}"
61
+ }
62
+
63
+ # 处理环境配置
64
+ setup_env() {
65
+ if [ ! -f .env ]; then
66
+ echo -e "${YELLOW}📝 创建默认 .env 配置文件...${NC}"
67
+ cat > .env << EOF
68
+ # 服务器配置
69
+ PORT=8002
70
+ DEBUG=false
71
+
72
+ # API配置
73
+ API_KEY=0000
74
+ MODELS=claude-sonnet-4.6
75
+ SYSTEM_PROMPT_INJECT=
76
+
77
+ # 请求配置
78
+ TIMEOUT=30
79
+ USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
80
+
81
+ # Cursor配置
82
+ SCRIPT_URL=https://cursor.com/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js?i=0&v=3&h=cursor.com
83
+ EOF
84
+ echo -e "${GREEN}✅ 默认 .env 文件已创建${NC}"
85
+ else
86
+ echo -e "${GREEN}✅ 配置文件 .env 已存在${NC}"
87
+ fi
88
+ }
89
+
90
+ # 构建应用
91
+ build_app() {
92
+ echo -e "${BLUE}📦 正在下载 Go 依赖...${NC}"
93
+ go mod download
94
+
95
+ echo -e "${BLUE}🔨 正在编译 Go 应用...${NC}"
96
+ go build -o cursor2api-go .
97
+
98
+ if [ ! -f cursor2api-go ]; then
99
+ echo -e "${RED}❌ 编译失败!${NC}"
100
+ exit 1
101
+ fi
102
+
103
+ echo -e "${GREEN}✅ 应用编译成功!${NC}"
104
+ }
105
+
106
+ # 显示服务信息
107
+ show_info() {
108
+ echo ""
109
+ echo -e "${GREEN}✅ 准备就绪,正在启动服务...${NC}"
110
+ echo ""
111
+ }
112
+
113
+ # 启动服务器
114
+ start_server() {
115
+ # 捕获中断信号
116
+ trap 'echo -e "\n${YELLOW}⏹️ 正在停止服务器...${NC}"; exit 0' INT
117
+
118
+ ./cursor2api-go
119
+ }
120
+
121
+ # 主函数
122
+ main() {
123
+ print_header
124
+ check_go
125
+ check_nodejs
126
+ setup_env
127
+ build_app
128
+ show_info
129
+ start_server
130
+ }
131
+
132
+ # 运行主函数
133
+ main
static/docs.html ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Cursor2API - 强大的AI模型API代理</title>
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
17
+ line-height: 1.6;
18
+ color: #333;
19
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
20
+ min-height: 100vh;
21
+ }
22
+
23
+ .container {
24
+ max-width: 1200px;
25
+ margin: 0 auto;
26
+ padding: 20px;
27
+ }
28
+
29
+ .header {
30
+ text-align: center;
31
+ color: white;
32
+ margin-bottom: 40px;
33
+ }
34
+
35
+ .header h1 {
36
+ font-size: 3rem;
37
+ font-weight: 700;
38
+ margin-bottom: 10px;
39
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
40
+ }
41
+
42
+ .header .subtitle {
43
+ font-size: 1.2rem;
44
+ opacity: 0.9;
45
+ }
46
+
47
+ .main-content {
48
+ background: white;
49
+ border-radius: 20px;
50
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
51
+ overflow: hidden;
52
+ }
53
+
54
+ .nav-tabs {
55
+ display: flex;
56
+ background: #f8f9fa;
57
+ border-bottom: 1px solid #e9ecef;
58
+ }
59
+
60
+ .nav-tab {
61
+ flex: 1;
62
+ padding: 20px;
63
+ text-align: center;
64
+ cursor: pointer;
65
+ font-weight: 600;
66
+ color: #6c757d;
67
+ transition: all 0.3s ease;
68
+ border: none;
69
+ background: none;
70
+ }
71
+
72
+ .nav-tab:hover {
73
+ background: #e9ecef;
74
+ color: #495057;
75
+ }
76
+
77
+ .nav-tab.active {
78
+ background: #007bff;
79
+ color: white;
80
+ }
81
+
82
+ .tab-content {
83
+ display: none;
84
+ padding: 40px;
85
+ }
86
+
87
+ .tab-content.active {
88
+ display: block;
89
+ }
90
+
91
+ .status-card {
92
+ background: linear-gradient(135deg, #28a745, #20c997);
93
+ color: white;
94
+ padding: 30px;
95
+ border-radius: 15px;
96
+ margin-bottom: 30px;
97
+ text-align: center;
98
+ }
99
+
100
+ .api-info {
101
+ display: grid;
102
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
103
+ gap: 20px;
104
+ margin-bottom: 30px;
105
+ }
106
+
107
+ .info-card {
108
+ background: #f8f9fa;
109
+ padding: 25px;
110
+ border-radius: 15px;
111
+ border-left: 4px solid #007bff;
112
+ }
113
+
114
+ .info-card h3 {
115
+ color: #007bff;
116
+ margin-bottom: 15px;
117
+ }
118
+
119
+ .info-card .value {
120
+ font-family: Monaco, monospace;
121
+ background: white;
122
+ padding: 10px;
123
+ border-radius: 8px;
124
+ font-weight: bold;
125
+ }
126
+
127
+ .models-grid {
128
+ display: grid;
129
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
130
+ gap: 20px;
131
+ margin-bottom: 30px;
132
+ }
133
+
134
+ .model-card {
135
+ background: white;
136
+ border: 2px solid #e9ecef;
137
+ border-radius: 15px;
138
+ padding: 20px;
139
+ cursor: pointer;
140
+ transition: all 0.3s ease;
141
+ text-align: center;
142
+ }
143
+
144
+ .model-card:hover {
145
+ border-color: #007bff;
146
+ transform: translateY(-5px);
147
+ box-shadow: 0 10px 25px rgba(0, 123, 255, 0.2);
148
+ }
149
+
150
+ .model-card.selected {
151
+ border-color: #007bff;
152
+ background: #f8f9ff;
153
+ }
154
+
155
+ .model-card .model-name {
156
+ font-size: 1.2rem;
157
+ font-weight: bold;
158
+ color: #007bff;
159
+ margin-bottom: 8px;
160
+ }
161
+
162
+ .model-card .provider {
163
+ font-size: 0.9rem;
164
+ color: #6c757d;
165
+ opacity: 0.8;
166
+ }
167
+
168
+ .code-block {
169
+ background: #f8f9fa;
170
+ border: 1px solid #e9ecef;
171
+ border-radius: 10px;
172
+ padding: 20px;
173
+ margin: 20px 0;
174
+ position: relative;
175
+ }
176
+
177
+ .copy-btn {
178
+ position: absolute;
179
+ top: 10px;
180
+ right: 10px;
181
+ background: #007bff;
182
+ color: white;
183
+ border: none;
184
+ padding: 8px 15px;
185
+ border-radius: 6px;
186
+ cursor: pointer;
187
+ font-size: 0.9rem;
188
+ transition: background 0.3s ease;
189
+ }
190
+
191
+ .copy-btn:hover {
192
+ background: #0056b3;
193
+ }
194
+
195
+ .code-block code {
196
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
197
+ font-size: 0.9rem;
198
+ line-height: 1.5;
199
+ display: block;
200
+ white-space: pre-wrap;
201
+ word-break: break-all;
202
+ }
203
+
204
+ .endpoint-card {
205
+ background: #f8f9fa;
206
+ border-radius: 10px;
207
+ padding: 20px;
208
+ margin: 15px 0;
209
+ border-left: 4px solid #007bff;
210
+ }
211
+
212
+ .method-badge {
213
+ display: inline-block;
214
+ padding: 4px 12px;
215
+ border-radius: 20px;
216
+ font-size: 0.8rem;
217
+ font-weight: bold;
218
+ margin-right: 10px;
219
+ }
220
+
221
+ .method-get {
222
+ background: #28a745;
223
+ color: white;
224
+ }
225
+
226
+ .method-post {
227
+ background: #007bff;
228
+ color: white;
229
+ }
230
+
231
+ .footer {
232
+ text-align: center;
233
+ padding: 30px;
234
+ color: #6c757d;
235
+ background: rgba(255, 255, 255, 0.9);
236
+ margin-top: 40px;
237
+ border-radius: 15px;
238
+ }
239
+
240
+ @media (max-width: 768px) {
241
+ .header h1 {
242
+ font-size: 2rem;
243
+ }
244
+
245
+ .models-grid {
246
+ grid-template-columns: 1fr;
247
+ }
248
+
249
+ .api-info {
250
+ grid-template-columns: 1fr;
251
+ }
252
+ }
253
+ </style>
254
+ </head>
255
+
256
+ <body>
257
+ <div class="container">
258
+ <div class="header">
259
+ <h1>🚀 Cursor2API</h1>
260
+ <p class="subtitle">强大的AI模型API代理服务 - OpenAI兼容接口</p>
261
+ </div>
262
+
263
+ <div class="main-content">
264
+ <!-- 导航标签 -->
265
+ <div class="nav-tabs">
266
+ <button class="nav-tab active" onclick="showTab('overview')">概览</button>
267
+ <button class="nav-tab" onclick="showTab('models')">模型列表</button>
268
+ <button class="nav-tab" onclick="showTab('api')">API文档</button>
269
+ </div>
270
+
271
+ <!-- 概览页面 -->
272
+ <div id="overview" class="tab-content active">
273
+ <div class="status-card">
274
+ <h2>✅ 服务运行正常</h2>
275
+ <p> Cursor2API已成功启动并运行中</p>
276
+ </div>
277
+
278
+ <div class="api-info">
279
+ <div class="info-card">
280
+ <h3>📍 服务地址</h3>
281
+ <div class="value">http://localhost:8002</div>
282
+ </div>
283
+ <div class="info-card">
284
+ <h3>🔑 API密钥</h3>
285
+ <div class="value">0000 (默认)</div>
286
+ </div>
287
+ <div class="info-card">
288
+ <h3>🎯 兼容性</h3>
289
+ <div class="value">OpenAI API 标准</div>
290
+ </div>
291
+ <div class="info-card">
292
+ <h3>🌊 流式支持</h3>
293
+ <div class="value">支持 SSE 流式响应</div>
294
+ </div>
295
+ <div class="info-card">
296
+ <h3>🧰 Tool Calls</h3>
297
+ <div class="value">支持 tools / tool_choice / tool_calls</div>
298
+ </div>
299
+ <div class="info-card">
300
+ <h3>🧠 Thinking</h3>
301
+ <div class="value">自动暴露 -thinking 模型(thinking 不对外透出)</div>
302
+ </div>
303
+ </div>
304
+
305
+ <div style="margin-top: 30px;">
306
+ <h3>🚀 快速开始</h3>
307
+
308
+ <div class="code-block">
309
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
310
+ <code># 获取模型列表
311
+ curl -H "Authorization: Bearer 0000" http://localhost:8002/v1/models</code>
312
+ </div>
313
+
314
+ <h4 style="margin-top: 15px; color: #555;">非流式聊天 (Non-Streaming)</h4>
315
+ <div class="code-block">
316
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
317
+ <code>curl -X POST http://localhost:8002/v1/chat/completions \
318
+ -H "Content-Type: application/json" \
319
+ -H "Authorization: Bearer 0000" \
320
+ -d '{
321
+ "model": "claude-sonnet-4.6",
322
+ "messages": [{"role": "user", "content": "Hello!"}],
323
+ "stream": false
324
+ }'</code>
325
+ </div>
326
+
327
+ <h4 style="margin-top: 15px; color: #555;">流式聊天 (Streaming)</h4>
328
+ <div class="code-block">
329
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
330
+ <code>curl -X POST http://localhost:8002/v1/chat/completions \
331
+ -H "Content-Type: application/json" \
332
+ -H "Authorization: Bearer 0000" \
333
+ -d '{
334
+ "model": "claude-sonnet-4.6",
335
+ "messages": [{"role": "user", "content": "Hello!"}],
336
+ "stream": true
337
+ }'</code>
338
+ </div>
339
+
340
+ <h4 style="margin-top: 15px; color: #555;">工具调用 (Tool Calls)</h4>
341
+ <div class="code-block">
342
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
343
+ <code>curl -X POST http://localhost:8002/v1/chat/completions \
344
+ -H "Content-Type: application/json" \
345
+ -H "Authorization: Bearer 0000" \
346
+ -d '{
347
+ "model": "claude-sonnet-4.6",
348
+ "messages": [{"role": "user", "content": "What is 2+2? Use the calculator tool."}],
349
+ "tools": [
350
+ {
351
+ "type": "function",
352
+ "function": {
353
+ "name": "calculator",
354
+ "description": "Evaluate a simple arithmetic expression.",
355
+ "parameters": {
356
+ "type": "object",
357
+ "properties": {
358
+ "expression": {"type": "string"}
359
+ },
360
+ "required": ["expression"]
361
+ }
362
+ }
363
+ }
364
+ ],
365
+ "tool_choice": {"type": "function", "function": {"name": "calculator"}},
366
+ "stream": false
367
+ }'</code>
368
+ </div>
369
+
370
+ <h4 style="margin-top: 15px; color: #555;">Kilo Code 兼容(可选)</h4>
371
+ <div class="code-block">
372
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
373
+ <code># 当上层强制“必须用工具”时,可在 .env 开启
374
+ KILO_TOOL_STRICT=true
375
+
376
+ # 非流式请求:若要求用工具但本轮未产出 tool_calls,会自动重试 1 次(流式不重试)</code>
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ <!-- 模型页面 -->
382
+ <div id="models" class="tab-content">
383
+ <h2>🤖 支持的AI模型</h2>
384
+ <p>点击模型卡片可查看详细信息和使用示例</p>
385
+
386
+ <div class="models-grid" id="modelsGrid">
387
+ <div class="model-card" onclick="selectModel('claude-sonnet-4.6')">
388
+ <div class="model-name">claude-sonnet-4.6</div>
389
+ <div class="provider">Anthropic Claude</div>
390
+ </div>
391
+ <div class="model-card" onclick="selectModel('claude-sonnet-4.6-thinking')">
392
+ <div class="model-name">claude-sonnet-4.6-thinking</div>
393
+ <div class="provider">Anthropic Claude + Thinking</div>
394
+ </div>
395
+ </div>
396
+
397
+ <div id="selectedModelInfo" style="display: none; margin-top: 30px;">
398
+ <h3>使用选中的模型</h3>
399
+ <div class="code-block">
400
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
401
+ <code id="modelExample"></code>
402
+ </div>
403
+ </div>
404
+ </div>
405
+
406
+ <!-- API页面 -->
407
+ <div id="api" class="tab-content">
408
+ <h2>📡 API端点文档</h2>
409
+
410
+ <div class="endpoint-card">
411
+ <div class="method-badge method-get">GET</div>
412
+ <strong>/v1/models</strong>
413
+ <p>获取所有可用的AI模型列表</p>
414
+ <div class="code-block" style="margin-top: 15px;">
415
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
416
+ <code>curl -H "Authorization: Bearer 0000" http://localhost:8002/v1/models</code>
417
+ </div>
418
+ </div>
419
+
420
+ <div class="endpoint-card">
421
+ <div class="method-badge method-post">POST</div>
422
+ <strong>/v1/chat/completions</strong>
423
+ <p>创建聊天完成请求,支持流式、非流式和 OpenAI 兼容 tool_calls</p>
424
+ <div class="code-block" style="margin-top: 15px;">
425
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
426
+ <code>curl -X POST http://localhost:8002/v1/chat/completions \
427
+ -H "Content-Type: application/json" \
428
+ -H "Authorization: Bearer 0000" \
429
+ -d '{
430
+ "model": "claude-sonnet-4.6",
431
+ "messages": [
432
+ {"role": "user", "content": "你好"}
433
+ ],
434
+ "stream": false
435
+ }'</code>
436
+ </div>
437
+ <p style="margin-top: 10px; color: #555;">
438
+ 说明:当响应包含工具调用时,非流式会返回 <code>message.tool_calls</code> 且 <code>finish_reason="tool_calls"</code>;
439
+ 流式会在 <code>delta.tool_calls</code> 中输出,并在最后一个 chunk 以 <code>finish_reason="tool_calls"</code> 收尾。
440
+ </p>
441
+ </div>
442
+
443
+ <div class="endpoint-card">
444
+ <div class="method-badge method-get">GET</div>
445
+ <strong>/health</strong>
446
+ <p>健康检查端点</p>
447
+ <div class="code-block" style="margin-top: 15px;">
448
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
449
+ <code>curl http://localhost:8002/health</code>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ </div>
454
+
455
+ <div class="footer">
456
+ <p>©2025 Cursor2API | 强大的AI模型API代理服务</p>
457
+ </div>
458
+ </div>
459
+
460
+ <script>
461
+ // 切换标签页
462
+ function showTab(tabName) {
463
+ const tabContents = document.getElementsByClassName('tab-content');
464
+ for (let i = 0; i < tabContents.length; i++) {
465
+ tabContents[i].classList.remove('active');
466
+ }
467
+
468
+ const tabBtns = document.getElementsByClassName('nav-tab');
469
+ for (let i = 0; i < tabBtns.length; i++) {
470
+ tabBtns[i].classList.remove('active');
471
+ }
472
+
473
+ document.getElementById(tabName).classList.add('active');
474
+ event.target.classList.add('active');
475
+ }
476
+
477
+ // 选择模型
478
+ function selectModel(modelId) {
479
+ const prevSelected = document.querySelector('.model-card.selected');
480
+ if (prevSelected) {
481
+ prevSelected.classList.remove('selected');
482
+ }
483
+
484
+ const modelCards = document.querySelectorAll('.model-card');
485
+ modelCards.forEach(card => {
486
+ if (card.onclick.toString().includes(modelId)) {
487
+ card.classList.add('selected');
488
+ }
489
+ });
490
+
491
+ const exampleCode = `curl -X POST http://localhost:8002/v1/chat/completions \\
492
+ -H "Content-Type: application/json" \\
493
+ -H "Authorization: Bearer 0000" \\
494
+ -d '{
495
+ "model": "${modelId}",
496
+ "messages": [
497
+ {
498
+ "role": "user",
499
+ "content": "你好,请用中文回答问题"
500
+ }
501
+ ],
502
+ "stream": false
503
+ }'`;
504
+
505
+ document.getElementById('modelExample').textContent = exampleCode;
506
+ document.getElementById('selectedModelInfo').style.display = 'block';
507
+ }
508
+
509
+ // 复制代码功能
510
+ function copyCode(btn) {
511
+ const codeBlock = btn.nextElementSibling;
512
+ const code = codeBlock.textContent;
513
+
514
+ navigator.clipboard.writeText(code).then(() => {
515
+ const originalText = btn.textContent;
516
+ btn.textContent = '已复制!';
517
+ btn.style.background = '#28a745';
518
+ setTimeout(() => {
519
+ btn.textContent = originalText;
520
+ btn.style.background = '#007bff';
521
+ }, 2000);
522
+ }).catch(() => {
523
+ alert('复制失败,请手动复制代码');
524
+ });
525
+ }
526
+ </script>
527
+ </body>
528
+
529
+ </html>
utils/cursor_protocol.go ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package utils
2
+
3
+ import (
4
+ "bytes"
5
+ "cursor2api-go/models"
6
+ "encoding/json"
7
+ "strings"
8
+ )
9
+
10
+ const (
11
+ thinkingStartTag = "<thinking>"
12
+ thinkingEndTag = "</thinking>"
13
+ invokeEndTag = "</invoke>"
14
+ )
15
+
16
+ // CursorProtocolParser 将 Cursor 的纯文本增量转换为内部文本/thinking/tool_call 事件
17
+ type CursorProtocolParser struct {
18
+ config models.CursorParseConfig
19
+ pending string
20
+ }
21
+
22
+ // NewCursorProtocolParser 创建新的协议解析器
23
+ func NewCursorProtocolParser(config models.CursorParseConfig) *CursorProtocolParser {
24
+ return &CursorProtocolParser{config: config}
25
+ }
26
+
27
+ // Feed 喂入一个上游增量片段
28
+ func (p *CursorProtocolParser) Feed(chunk string) []models.AssistantEvent {
29
+ if chunk == "" {
30
+ return nil
31
+ }
32
+ p.pending += chunk
33
+ return p.extract(false)
34
+ }
35
+
36
+ // Finish 在流结束时刷新剩余缓冲
37
+ func (p *CursorProtocolParser) Finish() []models.AssistantEvent {
38
+ events := p.extract(true)
39
+ if p.pending != "" {
40
+ events = append(events, models.AssistantEvent{
41
+ Kind: models.AssistantEventText,
42
+ Text: p.pending,
43
+ })
44
+ p.pending = ""
45
+ }
46
+ return events
47
+ }
48
+
49
+ func (p *CursorProtocolParser) extract(final bool) []models.AssistantEvent {
50
+ events := make([]models.AssistantEvent, 0, 4)
51
+
52
+ for len(p.pending) > 0 {
53
+ idx, kind := p.findNextSpecial()
54
+ if idx < 0 {
55
+ keep := 0
56
+ if !final {
57
+ keep = p.partialStartKeep()
58
+ }
59
+ if len(p.pending) <= keep {
60
+ break
61
+ }
62
+ text := p.pending[:len(p.pending)-keep]
63
+ p.pending = p.pending[len(p.pending)-keep:]
64
+ if text != "" {
65
+ events = append(events, models.AssistantEvent{
66
+ Kind: models.AssistantEventText,
67
+ Text: text,
68
+ })
69
+ }
70
+ continue
71
+ }
72
+
73
+ if idx > 0 {
74
+ text := p.pending[:idx]
75
+ p.pending = p.pending[idx:]
76
+ if text != "" {
77
+ events = append(events, models.AssistantEvent{
78
+ Kind: models.AssistantEventText,
79
+ Text: text,
80
+ })
81
+ }
82
+ continue
83
+ }
84
+
85
+ switch kind {
86
+ case models.AssistantEventThinking:
87
+ if event, ok := p.tryParseThinking(final); ok {
88
+ events = append(events, event)
89
+ continue
90
+ }
91
+ if !final {
92
+ return events
93
+ }
94
+ case models.AssistantEventToolCall:
95
+ if event, ok := p.tryParseToolCall(final); ok {
96
+ events = append(events, event)
97
+ continue
98
+ }
99
+ if !final {
100
+ return events
101
+ }
102
+ }
103
+
104
+ events = append(events, models.AssistantEvent{
105
+ Kind: models.AssistantEventText,
106
+ Text: p.pending,
107
+ })
108
+ p.pending = ""
109
+ }
110
+
111
+ return events
112
+ }
113
+
114
+ func (p *CursorProtocolParser) findNextSpecial() (int, models.AssistantEventKind) {
115
+ bestIdx := -1
116
+ bestKind := models.AssistantEventText
117
+
118
+ if p.config.ThinkingEnabled {
119
+ if idx := strings.Index(p.pending, thinkingStartTag); idx >= 0 {
120
+ bestIdx = idx
121
+ bestKind = models.AssistantEventThinking
122
+ }
123
+ }
124
+
125
+ if p.config.TriggerSignal != "" {
126
+ if idx := strings.Index(p.pending, p.config.TriggerSignal); idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
127
+ bestIdx = idx
128
+ bestKind = models.AssistantEventToolCall
129
+ }
130
+ }
131
+
132
+ return bestIdx, bestKind
133
+ }
134
+
135
+ func (p *CursorProtocolParser) partialStartKeep() int {
136
+ maxKeep := 0
137
+ if p.config.ThinkingEnabled {
138
+ maxKeep = max(maxKeep, longestPrefixSuffix(p.pending, thinkingStartTag))
139
+ }
140
+ if p.config.TriggerSignal != "" {
141
+ maxKeep = max(maxKeep, longestPrefixSuffix(p.pending, p.config.TriggerSignal))
142
+ }
143
+ return maxKeep
144
+ }
145
+
146
+ func (p *CursorProtocolParser) tryParseThinking(final bool) (models.AssistantEvent, bool) {
147
+ if !strings.HasPrefix(p.pending, thinkingStartTag) {
148
+ return models.AssistantEvent{}, false
149
+ }
150
+
151
+ endIdx := strings.Index(p.pending[len(thinkingStartTag):], thinkingEndTag)
152
+ if endIdx < 0 {
153
+ if final {
154
+ return models.AssistantEvent{
155
+ Kind: models.AssistantEventText,
156
+ Text: p.pending,
157
+ }, true
158
+ }
159
+ return models.AssistantEvent{}, false
160
+ }
161
+
162
+ start := len(thinkingStartTag)
163
+ end := start + endIdx
164
+ content := p.pending[start:end]
165
+ p.pending = p.pending[end+len(thinkingEndTag):]
166
+ return models.AssistantEvent{
167
+ Kind: models.AssistantEventThinking,
168
+ Thinking: content,
169
+ }, true
170
+ }
171
+
172
+ func (p *CursorProtocolParser) tryParseToolCall(final bool) (models.AssistantEvent, bool) {
173
+ if p.config.TriggerSignal == "" || !strings.HasPrefix(p.pending, p.config.TriggerSignal) {
174
+ return models.AssistantEvent{}, false
175
+ }
176
+
177
+ endIdx := strings.Index(p.pending, invokeEndTag)
178
+ if endIdx < 0 {
179
+ if final {
180
+ return models.AssistantEvent{
181
+ Kind: models.AssistantEventText,
182
+ Text: p.pending,
183
+ }, true
184
+ }
185
+ return models.AssistantEvent{}, false
186
+ }
187
+
188
+ segment := p.pending[:endIdx+len(invokeEndTag)]
189
+ call, ok := parseToolCallSegment(segment, p.config.TriggerSignal)
190
+ p.pending = p.pending[endIdx+len(invokeEndTag):]
191
+ if !ok {
192
+ return models.AssistantEvent{
193
+ Kind: models.AssistantEventText,
194
+ Text: segment,
195
+ }, true
196
+ }
197
+
198
+ return models.AssistantEvent{
199
+ Kind: models.AssistantEventToolCall,
200
+ ToolCall: call,
201
+ }, true
202
+ }
203
+
204
+ func parseToolCallSegment(segment, triggerSignal string) (*models.ToolCall, bool) {
205
+ body := strings.TrimSpace(strings.TrimPrefix(segment, triggerSignal))
206
+ if !strings.HasPrefix(body, "<invoke") {
207
+ return nil, false
208
+ }
209
+
210
+ openEnd := strings.Index(body, ">")
211
+ if openEnd < 0 {
212
+ return nil, false
213
+ }
214
+ openTag := body[:openEnd+1]
215
+ if !strings.HasSuffix(body, invokeEndTag) {
216
+ return nil, false
217
+ }
218
+
219
+ name := extractInvokeName(openTag)
220
+ if name == "" {
221
+ return nil, false
222
+ }
223
+
224
+ args := strings.TrimSpace(body[openEnd+1 : len(body)-len(invokeEndTag)])
225
+ if args == "" {
226
+ args = "{}"
227
+ }
228
+
229
+ var compact bytes.Buffer
230
+ if err := json.Compact(&compact, []byte(args)); err != nil {
231
+ return nil, false
232
+ }
233
+
234
+ return &models.ToolCall{
235
+ ID: "call_" + GenerateRandomString(24),
236
+ Type: "function",
237
+ Function: models.FunctionCall{
238
+ Name: name,
239
+ Arguments: compact.String(),
240
+ },
241
+ }, true
242
+ }
243
+
244
+ func extractInvokeName(openTag string) string {
245
+ nameIdx := strings.Index(openTag, `name="`)
246
+ if nameIdx < 0 {
247
+ return ""
248
+ }
249
+ value := openTag[nameIdx+len(`name="`):]
250
+ endIdx := strings.Index(value, `"`)
251
+ if endIdx < 0 {
252
+ return ""
253
+ }
254
+ return strings.TrimSpace(value[:endIdx])
255
+ }
256
+
257
+ func longestPrefixSuffix(text, token string) int {
258
+ maxLen := min(len(text), len(token)-1)
259
+ for size := maxLen; size > 0; size-- {
260
+ if strings.HasSuffix(text, token[:size]) {
261
+ return size
262
+ }
263
+ }
264
+ return 0
265
+ }
266
+
267
+ func min(a, b int) int {
268
+ if a < b {
269
+ return a
270
+ }
271
+ return b
272
+ }
273
+
274
+ func max(a, b int) int {
275
+ if a > b {
276
+ return a
277
+ }
278
+ return b
279
+ }
utils/cursor_protocol_test.go ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package utils
2
+
3
+ import (
4
+ "cursor2api-go/models"
5
+ "encoding/json"
6
+ "net/http/httptest"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ )
12
+
13
+ func TestCursorProtocolParserParsesThinkingAndToolCallsAcrossChunks(t *testing.T) {
14
+ parser := NewCursorProtocolParser(models.CursorParseConfig{
15
+ TriggerSignal: "<<CALL_test>>",
16
+ ThinkingEnabled: true,
17
+ })
18
+
19
+ var events []models.AssistantEvent
20
+ events = append(events, parser.Feed("Hello <think")...)
21
+ events = append(events, parser.Feed("ing>draft</thinking> world ")...)
22
+ events = append(events, parser.Feed("<<CALL_test>>\n<invoke name=\"lookup\">{\"q\":\"hel")...)
23
+ events = append(events, parser.Feed("lo\"}</invoke>!")...)
24
+ events = append(events, parser.Finish()...)
25
+
26
+ if len(events) != 5 {
27
+ t.Fatalf("event count = %v, want 5", len(events))
28
+ }
29
+ if events[0].Kind != models.AssistantEventText || events[0].Text != "Hello " {
30
+ t.Fatalf("event[0] = %#v, want text Hello", events[0])
31
+ }
32
+ if events[1].Kind != models.AssistantEventThinking || events[1].Thinking != "draft" {
33
+ t.Fatalf("event[1] = %#v, want thinking draft", events[1])
34
+ }
35
+ if events[2].Kind != models.AssistantEventText || events[2].Text != " world " {
36
+ t.Fatalf("event[2] = %#v, want text world", events[2])
37
+ }
38
+ if events[3].Kind != models.AssistantEventToolCall || events[3].ToolCall == nil {
39
+ t.Fatalf("event[3] = %#v, want tool call", events[3])
40
+ }
41
+ if events[3].ToolCall.Function.Name != "lookup" {
42
+ t.Fatalf("tool name = %v, want lookup", events[3].ToolCall.Function.Name)
43
+ }
44
+ if events[3].ToolCall.Function.Arguments != `{"q":"hello"}` {
45
+ t.Fatalf("tool arguments = %v, want compact json", events[3].ToolCall.Function.Arguments)
46
+ }
47
+ if events[4].Kind != models.AssistantEventText || events[4].Text != "!" {
48
+ t.Fatalf("event[4] = %#v, want trailing exclamation text", events[4])
49
+ }
50
+ }
51
+
52
+ func TestNonStreamChatCompletionReturnsToolCalls(t *testing.T) {
53
+ gin.SetMode(gin.TestMode)
54
+ recorder := httptest.NewRecorder()
55
+ ctx, _ := gin.CreateTestContext(recorder)
56
+ ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
57
+
58
+ ch := make(chan interface{}, 4)
59
+ ch <- models.AssistantEvent{Kind: models.AssistantEventText, Text: "Let me check."}
60
+ ch <- models.AssistantEvent{
61
+ Kind: models.AssistantEventToolCall,
62
+ ToolCall: &models.ToolCall{
63
+ ID: "call_1",
64
+ Type: "function",
65
+ Function: models.FunctionCall{
66
+ Name: "lookup",
67
+ Arguments: `{"q":"revivalquant"}`,
68
+ },
69
+ },
70
+ }
71
+ ch <- models.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}
72
+ close(ch)
73
+
74
+ NonStreamChatCompletion(ctx, ch, "claude-sonnet-4.6")
75
+
76
+ var response models.ChatCompletionResponse
77
+ if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
78
+ t.Fatalf("unmarshal response: %v", err)
79
+ }
80
+ if response.Choices[0].FinishReason != "tool_calls" {
81
+ t.Fatalf("finish reason = %v, want tool_calls", response.Choices[0].FinishReason)
82
+ }
83
+ if response.Choices[0].Message.ToolCalls[0].Function.Name != "lookup" {
84
+ t.Fatalf("tool call name = %v, want lookup", response.Choices[0].Message.ToolCalls[0].Function.Name)
85
+ }
86
+ if response.Choices[0].Message.Content != "Let me check." {
87
+ t.Fatalf("message content = %#v, want Let me check.", response.Choices[0].Message.Content)
88
+ }
89
+ }
90
+
91
+ func TestStreamChatCompletionEmitsToolCallChunks(t *testing.T) {
92
+ gin.SetMode(gin.TestMode)
93
+ recorder := httptest.NewRecorder()
94
+ ctx, _ := gin.CreateTestContext(recorder)
95
+ ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
96
+
97
+ ch := make(chan interface{}, 2)
98
+ ch <- models.AssistantEvent{
99
+ Kind: models.AssistantEventToolCall,
100
+ ToolCall: &models.ToolCall{
101
+ ID: "call_1",
102
+ Type: "function",
103
+ Function: models.FunctionCall{
104
+ Name: "lookup",
105
+ Arguments: `{"q":"revivalquant"}`,
106
+ },
107
+ },
108
+ }
109
+ close(ch)
110
+
111
+ StreamChatCompletion(ctx, ch, "claude-sonnet-4.6")
112
+
113
+ body := recorder.Body.String()
114
+ if !strings.Contains(body, `"tool_calls":[{"index":0,"id":"call_1","type":"function"`) {
115
+ t.Fatalf("stream body missing tool_calls delta: %s", body)
116
+ }
117
+ if !strings.Contains(body, `"finish_reason":"tool_calls"`) {
118
+ t.Fatalf("stream body missing tool_calls finish reason: %s", body)
119
+ }
120
+ if !strings.Contains(body, "[DONE]") {
121
+ t.Fatalf("stream body missing DONE marker: %s", body)
122
+ }
123
+ }
utils/headers.go ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package utils
22
+
23
+ import (
24
+ "fmt"
25
+ "math/rand"
26
+ "runtime"
27
+ "time"
28
+ )
29
+
30
+ // BrowserProfile 浏览器配置文件
31
+ type BrowserProfile struct {
32
+ Platform string
33
+ PlatformVersion string
34
+ Architecture string
35
+ Bitness string
36
+ ChromeVersion int
37
+ UserAgent string
38
+ Mobile bool
39
+ }
40
+
41
+ var (
42
+ // 常见的浏览器版本 (Chrome)
43
+ chromeVersions = []int{120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130}
44
+
45
+ // Windows 平台配置
46
+ windowsProfiles = []BrowserProfile{
47
+ {Platform: "Windows", PlatformVersion: "10.0.0", Architecture: "x86", Bitness: "64"},
48
+ {Platform: "Windows", PlatformVersion: "11.0.0", Architecture: "x86", Bitness: "64"},
49
+ {Platform: "Windows", PlatformVersion: "15.0.0", Architecture: "x86", Bitness: "64"},
50
+ }
51
+
52
+ // macOS 平台配置
53
+ macosProfiles = []BrowserProfile{
54
+ {Platform: "macOS", PlatformVersion: "13.0.0", Architecture: "arm", Bitness: "64"},
55
+ {Platform: "macOS", PlatformVersion: "14.0.0", Architecture: "arm", Bitness: "64"},
56
+ {Platform: "macOS", PlatformVersion: "15.0.0", Architecture: "arm", Bitness: "64"},
57
+ {Platform: "macOS", PlatformVersion: "13.0.0", Architecture: "x86", Bitness: "64"},
58
+ {Platform: "macOS", PlatformVersion: "14.0.0", Architecture: "x86", Bitness: "64"},
59
+ }
60
+
61
+ // Linux 平台配置
62
+ linuxProfiles = []BrowserProfile{
63
+ {Platform: "Linux", PlatformVersion: "", Architecture: "x86", Bitness: "64"},
64
+ }
65
+ )
66
+
67
+ // HeaderGenerator 动态 header 生成器
68
+ type HeaderGenerator struct {
69
+ profile BrowserProfile
70
+ chromeVersion int
71
+ rng *rand.Rand
72
+ }
73
+
74
+ // NewHeaderGenerator 创建新的 header 生成器
75
+ func NewHeaderGenerator() *HeaderGenerator {
76
+ // 使用当前时间作为随机种子
77
+ rng := rand.New(rand.NewSource(time.Now().UnixNano()))
78
+
79
+ // 根据当前操作系统选择合适的配置文件
80
+ var profiles []BrowserProfile
81
+ switch runtime.GOOS {
82
+ case "darwin":
83
+ profiles = macosProfiles
84
+ case "linux":
85
+ profiles = linuxProfiles
86
+ default:
87
+ profiles = windowsProfiles
88
+ }
89
+
90
+ // 随机选择一个配置文件
91
+ profile := profiles[rng.Intn(len(profiles))]
92
+
93
+ // 随机选择 Chrome 版本
94
+ chromeVersion := chromeVersions[rng.Intn(len(chromeVersions))]
95
+ profile.ChromeVersion = chromeVersion
96
+
97
+ // 生成 User-Agent
98
+ profile.UserAgent = generateUserAgent(profile)
99
+
100
+ return &HeaderGenerator{
101
+ profile: profile,
102
+ chromeVersion: chromeVersion,
103
+ rng: rng,
104
+ }
105
+ }
106
+
107
+ // generateUserAgent 生成 User-Agent 字符串
108
+ func generateUserAgent(profile BrowserProfile) string {
109
+ switch profile.Platform {
110
+ case "Windows":
111
+ return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", profile.ChromeVersion)
112
+ case "macOS":
113
+ if profile.Architecture == "arm" {
114
+ return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", profile.ChromeVersion)
115
+ }
116
+ return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", profile.ChromeVersion)
117
+ case "Linux":
118
+ return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", profile.ChromeVersion)
119
+ default:
120
+ return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", profile.ChromeVersion)
121
+ }
122
+ }
123
+
124
+ // GetChatHeaders 获取聊天请求的 headers
125
+ func (g *HeaderGenerator) GetChatHeaders(xIsHuman string) map[string]string {
126
+ // 随机选择语言
127
+ languages := []string{
128
+ "en-US,en;q=0.9",
129
+ "zh-CN,zh;q=0.9,en;q=0.8",
130
+ "en-GB,en;q=0.9",
131
+ }
132
+ lang := languages[g.rng.Intn(len(languages))]
133
+
134
+ // 随机选择 referer
135
+ referers := []string{
136
+ "https://cursor.com/en-US/learn/how-ai-models-work",
137
+ "https://cursor.com/cn/learn/how-ai-models-work",
138
+ "https://cursor.com/",
139
+ }
140
+ referer := referers[g.rng.Intn(len(referers))]
141
+
142
+ headers := map[string]string{
143
+ "sec-ch-ua-platform": fmt.Sprintf(`"%s"`, g.profile.Platform),
144
+ "x-path": "/api/chat",
145
+ "Referer": referer,
146
+ "sec-ch-ua": g.getSecChUa(),
147
+ "x-method": "POST",
148
+ "sec-ch-ua-mobile": "?0",
149
+ "x-is-human": xIsHuman,
150
+ "User-Agent": g.profile.UserAgent,
151
+ "content-type": "application/json",
152
+ "accept-language": lang,
153
+ }
154
+
155
+ // 添加可选的 headers
156
+ if g.profile.Architecture != "" {
157
+ headers["sec-ch-ua-arch"] = fmt.Sprintf(`"%s"`, g.profile.Architecture)
158
+ }
159
+ if g.profile.Bitness != "" {
160
+ headers["sec-ch-ua-bitness"] = fmt.Sprintf(`"%s"`, g.profile.Bitness)
161
+ }
162
+ if g.profile.PlatformVersion != "" {
163
+ headers["sec-ch-ua-platform-version"] = fmt.Sprintf(`"%s"`, g.profile.PlatformVersion)
164
+ }
165
+
166
+ return headers
167
+ }
168
+
169
+ // GetScriptHeaders 获取脚本请求的 headers
170
+ func (g *HeaderGenerator) GetScriptHeaders() map[string]string {
171
+ // 随机选择语言
172
+ languages := []string{
173
+ "en-US,en;q=0.9",
174
+ "zh-CN,zh;q=0.9,en;q=0.8",
175
+ "en-GB,en;q=0.9",
176
+ }
177
+ lang := languages[g.rng.Intn(len(languages))]
178
+
179
+ // 随机选择 referer
180
+ referers := []string{
181
+ "https://cursor.com/cn/learn/how-ai-models-work",
182
+ "https://cursor.com/en-US/learn/how-ai-models-work",
183
+ "https://cursor.com/",
184
+ }
185
+ referer := referers[g.rng.Intn(len(referers))]
186
+
187
+ headers := map[string]string{
188
+ "User-Agent": g.profile.UserAgent,
189
+ "sec-ch-ua-arch": fmt.Sprintf(`"%s"`, g.profile.Architecture),
190
+ "sec-ch-ua-platform": fmt.Sprintf(`"%s"`, g.profile.Platform),
191
+ "sec-ch-ua": g.getSecChUa(),
192
+ "sec-ch-ua-bitness": fmt.Sprintf(`"%s"`, g.profile.Bitness),
193
+ "sec-ch-ua-mobile": "?0",
194
+ "sec-fetch-site": "same-origin",
195
+ "sec-fetch-mode": "no-cors",
196
+ "sec-fetch-dest": "script",
197
+ "referer": referer,
198
+ "accept-language": lang,
199
+ }
200
+
201
+ if g.profile.PlatformVersion != "" {
202
+ headers["sec-ch-ua-platform-version"] = fmt.Sprintf(`"%s"`, g.profile.PlatformVersion)
203
+ }
204
+
205
+ return headers
206
+ }
207
+
208
+ // getSecChUa 生成 sec-ch-ua header
209
+ func (g *HeaderGenerator) getSecChUa() string {
210
+ // 生成随机的品牌版本
211
+ notABrand := 24 + g.rng.Intn(10) // 24-33
212
+
213
+ return fmt.Sprintf(`"Google Chrome";v="%d", "Chromium";v="%d", "Not(A:Brand";v="%d"`,
214
+ g.chromeVersion, g.chromeVersion, notABrand)
215
+ }
216
+
217
+ // GetUserAgent 获取 User-Agent
218
+ func (g *HeaderGenerator) GetUserAgent() string {
219
+ return g.profile.UserAgent
220
+ }
221
+
222
+ // GetProfile 获取浏览器配置文件
223
+ func (g *HeaderGenerator) GetProfile() BrowserProfile {
224
+ return g.profile
225
+ }
226
+
227
+ // Refresh 刷新配置文件(生成新的随机配置)
228
+ func (g *HeaderGenerator) Refresh() {
229
+ // 根据当前操作系统选择合适的配置文件
230
+ var profiles []BrowserProfile
231
+ switch runtime.GOOS {
232
+ case "darwin":
233
+ profiles = macosProfiles
234
+ case "linux":
235
+ profiles = linuxProfiles
236
+ default:
237
+ profiles = windowsProfiles
238
+ }
239
+
240
+ // 随机选择一个配置文件
241
+ profile := profiles[g.rng.Intn(len(profiles))]
242
+
243
+ // 随机选择 Chrome 版本
244
+ chromeVersion := chromeVersions[g.rng.Intn(len(chromeVersions))]
245
+ profile.ChromeVersion = chromeVersion
246
+
247
+ // 生成 User-Agent
248
+ profile.UserAgent = generateUserAgent(profile)
249
+
250
+ g.profile = profile
251
+ g.chromeVersion = chromeVersion
252
+ }
253
+
254
+ // GetRandomReferer 获取随机 referer
255
+ func GetRandomReferer() string {
256
+ referers := []string{
257
+ "https://cursor.com/en-US/learn/how-ai-models-work",
258
+ "https://cursor.com/cn/learn/how-ai-models-work",
259
+ "https://cursor.com/",
260
+ "https://cursor.com/features",
261
+ }
262
+ rng := rand.New(rand.NewSource(time.Now().UnixNano()))
263
+ return referers[rng.Intn(len(referers))]
264
+ }
265
+
266
+ // GetRandomLanguage 获取随机语言设置
267
+ func GetRandomLanguage() string {
268
+ languages := []string{
269
+ "en-US,en;q=0.9",
270
+ "zh-CN,zh;q=0.9,en;q=0.8",
271
+ "en-GB,en;q=0.9",
272
+ "ja-JP,ja;q=0.9,en;q=0.8",
273
+ }
274
+ rng := rand.New(rand.NewSource(time.Now().UnixNano()))
275
+ return languages[rng.Intn(len(languages))]
276
+ }
utils/utils.go ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026 libaxuan
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+
21
+ package utils
22
+
23
+ import (
24
+ "bufio"
25
+ "context"
26
+ "crypto/rand"
27
+ "cursor2api-go/middleware"
28
+ "cursor2api-go/models"
29
+ "encoding/hex"
30
+ "encoding/json"
31
+ "fmt"
32
+ "io"
33
+ "net/http"
34
+ "os/exec"
35
+ "strings"
36
+ "time"
37
+
38
+ "github.com/gin-gonic/gin"
39
+ "github.com/sirupsen/logrus"
40
+ )
41
+
42
+ // GenerateRandomString 生成指定长度的随机字符串
43
+ func GenerateRandomString(length int) string {
44
+ if length <= 0 {
45
+ return ""
46
+ }
47
+
48
+ byteLen := (length + 1) / 2
49
+ bytes := make([]byte, byteLen)
50
+ if _, err := rand.Read(bytes); err != nil {
51
+ fallback := fmt.Sprintf("%d", time.Now().UnixNano())
52
+ if len(fallback) >= length {
53
+ return fallback[:length]
54
+ }
55
+ return fallback
56
+ }
57
+
58
+ encoded := hex.EncodeToString(bytes)
59
+ if len(encoded) < length {
60
+ encoded += GenerateRandomString(length - len(encoded))
61
+ }
62
+
63
+ return encoded[:length]
64
+ }
65
+
66
+ // GenerateChatCompletionID 生成聊天完成ID
67
+ func GenerateChatCompletionID() string {
68
+ return "chatcmpl-" + GenerateRandomString(29)
69
+ }
70
+
71
+ // ParseSSELine 解析SSE数据行
72
+ func ParseSSELine(line string) string {
73
+ line = strings.TrimSpace(line)
74
+ if strings.HasPrefix(line, "data: ") {
75
+ return strings.TrimSpace(line[6:]) // 去掉 'data: ' 前缀并去除前导空格
76
+ }
77
+ return ""
78
+ }
79
+
80
+ // WriteSSEEvent 写入SSE事件
81
+ func WriteSSEEvent(w http.ResponseWriter, event, data string) error {
82
+ if event != "" {
83
+ if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
84
+ return err
85
+ }
86
+ }
87
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
88
+ return err
89
+ }
90
+
91
+ // 刷新缓冲区
92
+ if flusher, ok := w.(http.Flusher); ok {
93
+ flusher.Flush()
94
+ }
95
+
96
+ return nil
97
+ }
98
+
99
+ // StreamChatCompletion 处理流式聊天完成
100
+ // StreamChatCompletion 处理流式聊天完成
101
+ func StreamChatCompletion(c *gin.Context, chatGenerator <-chan interface{}, modelName string) {
102
+ // 设置SSE头
103
+ c.Header("Content-Type", "text/event-stream")
104
+ c.Header("Cache-Control", "no-cache")
105
+ c.Header("Connection", "keep-alive")
106
+ c.Header("Access-Control-Allow-Origin", "*")
107
+
108
+ // 生成响应ID
109
+ responseID := GenerateChatCompletionID()
110
+ started := false
111
+ toolCallIndex := 0
112
+
113
+ writeChunk := func(delta models.StreamDelta, finishReason *string) {
114
+ streamResp := models.NewChatCompletionStreamResponse(responseID, modelName, delta, finishReason)
115
+ if jsonData, err := json.Marshal(streamResp); err == nil {
116
+ WriteSSEEvent(c.Writer, "", string(jsonData))
117
+ }
118
+ }
119
+
120
+ // 处理流式数据
121
+ ctx := c.Request.Context()
122
+ for {
123
+ select {
124
+ case <-ctx.Done():
125
+ logrus.Debug("Client disconnected during streaming")
126
+ return
127
+
128
+ case data, ok := <-chatGenerator:
129
+ if !ok {
130
+ // 通道关闭,发送完成事件
131
+ reason := "stop"
132
+ if toolCallIndex > 0 {
133
+ reason = "tool_calls"
134
+ }
135
+ writeChunk(models.StreamDelta{}, stringPtr(reason))
136
+ WriteSSEEvent(c.Writer, "", "[DONE]")
137
+ return
138
+ }
139
+
140
+ switch v := data.(type) {
141
+ case models.AssistantEvent:
142
+ if !started {
143
+ writeChunk(models.StreamDelta{Role: "assistant"}, nil)
144
+ started = true
145
+ }
146
+
147
+ switch v.Kind {
148
+ case models.AssistantEventText:
149
+ if v.Text != "" {
150
+ writeChunk(models.StreamDelta{Content: v.Text}, nil)
151
+ }
152
+ case models.AssistantEventToolCall:
153
+ if v.ToolCall != nil {
154
+ writeChunk(models.StreamDelta{
155
+ ToolCalls: []models.ToolCallDelta{
156
+ {
157
+ Index: toolCallIndex,
158
+ ID: v.ToolCall.ID,
159
+ Type: v.ToolCall.Type,
160
+ Function: &models.FunctionCallDelta{
161
+ Name: v.ToolCall.Function.Name,
162
+ Arguments: v.ToolCall.Function.Arguments,
163
+ },
164
+ },
165
+ },
166
+ }, nil)
167
+ toolCallIndex++
168
+ }
169
+ }
170
+ case string:
171
+ if !started {
172
+ writeChunk(models.StreamDelta{Role: "assistant"}, nil)
173
+ started = true
174
+ }
175
+ if v != "" {
176
+ writeChunk(models.StreamDelta{Content: v}, nil)
177
+ }
178
+
179
+ case models.Usage:
180
+ // 使用统计 - 通常在最后发送
181
+ continue
182
+
183
+ case error:
184
+ logrus.WithError(v).Error("Stream generator error")
185
+ WriteSSEEvent(c.Writer, "", "[DONE]")
186
+ return
187
+
188
+ default:
189
+ logrus.Warnf("Unknown data type in stream: %T", v)
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // NonStreamChatCompletion 处理非流式聊天完成
196
+ func NonStreamChatCompletion(c *gin.Context, chatGenerator <-chan interface{}, modelName string) {
197
+ var fullContent strings.Builder
198
+ var usage models.Usage
199
+ toolCalls := make([]models.ToolCall, 0, 2)
200
+ finishReason := "stop"
201
+
202
+ // 收集所有数据
203
+ ctx := c.Request.Context()
204
+ for {
205
+ select {
206
+ case <-ctx.Done():
207
+ c.JSON(http.StatusRequestTimeout, models.NewErrorResponse(
208
+ "Request timeout",
209
+ "timeout_error",
210
+ "request_timeout",
211
+ ))
212
+ return
213
+
214
+ case data, ok := <-chatGenerator:
215
+ if !ok {
216
+ // 数据收集完成,返回响应
217
+ responseID := GenerateChatCompletionID()
218
+ message := models.Message{
219
+ Role: "assistant",
220
+ }
221
+ if fullContent.Len() > 0 || len(toolCalls) == 0 {
222
+ message.Content = fullContent.String()
223
+ }
224
+ if len(toolCalls) > 0 {
225
+ message.ToolCalls = toolCalls
226
+ finishReason = "tool_calls"
227
+ }
228
+ response := models.NewChatCompletionResponse(
229
+ responseID,
230
+ modelName,
231
+ message,
232
+ finishReason,
233
+ usage,
234
+ )
235
+ c.JSON(http.StatusOK, response)
236
+ return
237
+ }
238
+
239
+ switch v := data.(type) {
240
+ case models.AssistantEvent:
241
+ switch v.Kind {
242
+ case models.AssistantEventText:
243
+ fullContent.WriteString(v.Text)
244
+ case models.AssistantEventToolCall:
245
+ if v.ToolCall != nil {
246
+ toolCalls = append(toolCalls, *v.ToolCall)
247
+ }
248
+ }
249
+ case string:
250
+ fullContent.WriteString(v)
251
+ case models.Usage:
252
+ usage = v
253
+ case error:
254
+ middleware.HandleError(c, v)
255
+ return
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ // ErrorWrapper 错误包装器
262
+ func ErrorWrapper(handler func(*gin.Context) error) gin.HandlerFunc {
263
+ return func(c *gin.Context) {
264
+ if err := handler(c); err != nil {
265
+ logrus.WithError(err).Error("Handler error")
266
+
267
+ if !c.Writer.Written() {
268
+ c.JSON(http.StatusInternalServerError, models.NewErrorResponse(
269
+ "Internal server error",
270
+ "internal_error",
271
+ "",
272
+ ))
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ // SafeStreamWrapper 安全流式包装器
279
+ func SafeStreamWrapper(handler func(*gin.Context, <-chan interface{}, string), c *gin.Context, chatGenerator <-chan interface{}, modelName string) {
280
+ defer func() {
281
+ if r := recover(); r != nil {
282
+ logrus.WithField("panic", r).Error("Panic in stream handler")
283
+ if !c.Writer.Written() {
284
+ c.JSON(http.StatusInternalServerError, models.NewErrorResponse(
285
+ "Internal server error",
286
+ "panic_error",
287
+ "",
288
+ ))
289
+ }
290
+ }
291
+ }()
292
+
293
+ firstItem, ok := <-chatGenerator
294
+ if !ok {
295
+ middleware.HandleError(c, middleware.NewCursorWebError(http.StatusInternalServerError, "empty stream"))
296
+ return
297
+ }
298
+
299
+ if err, isErr := firstItem.(error); isErr {
300
+ middleware.HandleError(c, err)
301
+ return
302
+ }
303
+
304
+ buffered := make(chan interface{}, 1)
305
+ buffered <- firstItem
306
+ ctx := c.Request.Context()
307
+
308
+ go func() {
309
+ defer close(buffered)
310
+ for {
311
+ select {
312
+ case <-ctx.Done():
313
+ return
314
+ case item, ok := <-chatGenerator:
315
+ if !ok {
316
+ return
317
+ }
318
+ select {
319
+ case buffered <- item:
320
+ case <-ctx.Done():
321
+ return
322
+ }
323
+ }
324
+ }
325
+ }()
326
+
327
+ handler(c, buffered, modelName)
328
+ }
329
+
330
+ // CreateHTTPClient 创建HTTP客户端
331
+ func CreateHTTPClient(timeout time.Duration) *http.Client {
332
+ return &http.Client{
333
+ Timeout: timeout,
334
+ Transport: &http.Transport{
335
+ Proxy: http.ProxyFromEnvironment,
336
+ MaxIdleConns: 100,
337
+ MaxIdleConnsPerHost: 10,
338
+ IdleConnTimeout: 90 * time.Second,
339
+ },
340
+ }
341
+ }
342
+
343
+ // ReadSSEStream 读取SSE流
344
+ func ReadSSEStream(ctx context.Context, resp *http.Response, output chan<- interface{}) error {
345
+ scanner := bufio.NewScanner(resp.Body)
346
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
347
+ defer resp.Body.Close()
348
+
349
+ for scanner.Scan() {
350
+ select {
351
+ case <-ctx.Done():
352
+ return ctx.Err()
353
+ default:
354
+ }
355
+
356
+ line := scanner.Text()
357
+ data := ParseSSELine(line)
358
+ if data == "" {
359
+ continue
360
+ }
361
+
362
+ if data == "[DONE]" {
363
+ return nil
364
+ }
365
+
366
+ // 尝试解析JSON数据
367
+ var eventData models.CursorEventData
368
+ if err := json.Unmarshal([]byte(data), &eventData); err != nil {
369
+ logrus.WithError(err).Debugf("Failed to parse SSE data: %s", data)
370
+ continue
371
+ }
372
+
373
+ // 处理不同类型的事件
374
+ switch eventData.Type {
375
+ case "error":
376
+ if eventData.ErrorText != "" {
377
+ return fmt.Errorf("cursor API error: %s", eventData.ErrorText)
378
+ }
379
+
380
+ case "finish":
381
+ if eventData.MessageMetadata != nil && eventData.MessageMetadata.Usage != nil {
382
+ usage := models.Usage{
383
+ PromptTokens: eventData.MessageMetadata.Usage.InputTokens,
384
+ CompletionTokens: eventData.MessageMetadata.Usage.OutputTokens,
385
+ TotalTokens: eventData.MessageMetadata.Usage.TotalTokens,
386
+ }
387
+ output <- usage
388
+ }
389
+ return nil
390
+
391
+ default:
392
+ if eventData.Delta != "" {
393
+ output <- eventData.Delta
394
+ }
395
+ }
396
+ }
397
+
398
+ return scanner.Err()
399
+ }
400
+
401
+ // ValidateModel 验证模型名称
402
+ func ValidateModel(model string, validModels []string) bool {
403
+ for _, validModel := range validModels {
404
+ if validModel == model {
405
+ return true
406
+ }
407
+ }
408
+ return false
409
+ }
410
+
411
+ // SanitizeContent 清理内容
412
+ func SanitizeContent(content string) string {
413
+ // 移除可能的恶意内容
414
+ content = strings.ReplaceAll(content, "\x00", "")
415
+ return content
416
+ }
417
+
418
+ // stringPtr 返回字符串指针
419
+ func stringPtr(s string) *string {
420
+ return &s
421
+ }
422
+
423
+ // CopyHeaders 复制HTTP头
424
+ func CopyHeaders(dst, src http.Header, skipHeaders []string) {
425
+ skipMap := make(map[string]bool)
426
+ for _, header := range skipHeaders {
427
+ skipMap[strings.ToLower(header)] = true
428
+ }
429
+
430
+ for key, values := range src {
431
+ if skipMap[strings.ToLower(key)] {
432
+ continue
433
+ }
434
+ for _, value := range values {
435
+ dst.Add(key, value)
436
+ }
437
+ }
438
+ }
439
+
440
+ // IsJSONContentType 检查是否为JSON内容类型
441
+ func IsJSONContentType(contentType string) bool {
442
+ return strings.Contains(strings.ToLower(contentType), "application/json")
443
+ }
444
+
445
+ // ReadRequestBody 读取请求体
446
+ func ReadRequestBody(r *http.Request) ([]byte, error) {
447
+ if r.Body == nil {
448
+ return nil, nil
449
+ }
450
+
451
+ body, err := io.ReadAll(r.Body)
452
+ if err != nil {
453
+ return nil, fmt.Errorf("failed to read request body: %w", err)
454
+ }
455
+
456
+ return body, nil
457
+ }
458
+
459
+ // RunJS 执行JavaScript代码并返回标准输出内容
460
+ func RunJS(jsCode string) (string, error) {
461
+ // 添加crypto模块导入并设置为全局变量
462
+ // 注意:使用stdin时,我们需要确保代码是自包含的
463
+ finalJS := `const crypto = require('crypto').webcrypto;
464
+ global.crypto = crypto;
465
+ globalThis.crypto = crypto;
466
+ // 在Node.js环境中创建window对象
467
+ if (typeof window === 'undefined') { global.window = global; }
468
+ window.crypto = crypto;
469
+ this.crypto = crypto;
470
+ ` + jsCode
471
+
472
+ // 执行Node.js命令,使用stdin输入代码
473
+ cmd := exec.Command("node")
474
+
475
+ // 设置输入
476
+ cmd.Stdin = strings.NewReader(finalJS)
477
+
478
+ output, err := cmd.Output()
479
+ if err != nil {
480
+ if exitErr, ok := err.(*exec.ExitError); ok {
481
+ return "", fmt.Errorf("node.js execution failed (exit code: %d)\nSTDOUT:\n%s\nSTDERR:\n%s",
482
+ exitErr.ExitCode(), string(output), string(exitErr.Stderr))
483
+ }
484
+ return "", fmt.Errorf("failed to execute node.js: %w", err)
485
+ }
486
+
487
+ return strings.TrimSpace(string(output)), nil
488
+ }