Spaces:
Running
Running
Upload 38 files
Browse files- .gitattributes +3 -0
- Dockerfile +54 -0
- LICENSE +16 -0
- README_EN.md +400 -0
- TROUBLESHOOTING.md +114 -0
- config/config.go +200 -0
- config/config_test.go +203 -0
- docker-compose.yml +42 -0
- docs/API_CAPABILITIES.md +98 -0
- docs/DYNAMIC_HEADERS.md +180 -0
- docs/STARTUP_OPTIMIZATION.md +168 -0
- docs/images/home.png +3 -0
- docs/images/play1.png +3 -0
- docs/images/play2.png +3 -0
- go.md +216 -0
- go.mod +51 -0
- go.sum +124 -0
- handlers/handler.go +275 -0
- jscode/env.js +0 -0
- jscode/main.js +128 -0
- main.go +164 -0
- middleware/auth.go +79 -0
- middleware/cors.go +45 -0
- middleware/error.go +215 -0
- models/model_capabilities.go +78 -0
- models/model_config.go +99 -0
- models/models.go +388 -0
- models/models_test.go +202 -0
- services/cursor.go +575 -0
- services/cursor_protocol.go +369 -0
- services/cursor_protocol_test.go +254 -0
- start-go-utf8.bat +128 -0
- start-go.bat +128 -0
- start.sh +133 -0
- static/docs.html +529 -0
- utils/cursor_protocol.go +279 -0
- utils/cursor_protocol_test.go +123 -0
- utils/headers.go +276 -0
- utils/utils.go +488 -0
.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 |
+
[](https://golang.org)
|
| 8 |
+
[](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 |
+

|
| 26 |
+

|
| 27 |
+

|
| 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
|
docs/images/play1.png
ADDED
|
Git LFS Details
|
docs/images/play2.png
ADDED
|
Git LFS Details
|
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 |
+
}
|