cacode commited on
Commit
1766992
·
verified ·
1 Parent(s): 692cbe2

Upload 48 files

Browse files
.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 服务器配置
2
+ PORT=8002
3
+ DEBUG=false
4
+
5
+ # API配置
6
+ # ⚠️ 警告:生产环境请务必修改默认 API_KEY!
7
+ API_KEY=0000
8
+ MODELS=claude-sonnet-4.6
9
+ SYSTEM_PROMPT_INJECT=
10
+
11
+ # 请求配置
12
+ TIMEOUT=60 # 请求超时时间(秒)
13
+ MAX_INPUT_LENGTH=200000 # 最大输入长度
14
+
15
+ # 浏览器指纹配置
16
+ USER_AGENT=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36
17
+ UNMASKED_VENDOR_WEBGL=Google Inc. (Intel)
18
+ UNMASKED_RENDERER_WEBGL=ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)
19
+
20
+ # Cursor配置
21
+ # SCRIPT_URL 需要根据实际情况获取,可能需要定期更新
22
+ 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
.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
.gitignore ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Binaries for programs and plugins
2
+ *.exe
3
+ *.exe~
4
+ *.dll
5
+ *.so
6
+ *.dylib
7
+
8
+ # Go build output
9
+ cursor2api-go
10
+ cursor2api-go.exe
11
+
12
+ # Test binary, built with `go test -c`
13
+ *.test
14
+
15
+ # Output of the go coverage tool
16
+ *.out
17
+
18
+ # Go workspace file
19
+ go.work
20
+
21
+ # Environment variables
22
+ .env
23
+ .env.local
24
+ .env.*.local
25
+
26
+ # IDE files
27
+ .vscode/
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+
32
+ # OS files
33
+ .DS_Store
34
+ Thumbs.db
35
+
36
+ # Logs
37
+ *.log
38
+ logs/
39
+
40
+ # Temporary files
41
+ *.tmp
42
+ *.temp
43
+
44
+ # Build artifacts
45
+ dist/
46
+ build/
47
+
48
+ # Python (if any remains)
49
+ __pycache__/
50
+ *.py[cod]
51
+ *$py.class
52
+ *.so
53
+ .Python
54
+ env/
55
+ venv/
56
+ ENV/
57
+ env.bak/
58
+ venv.bak/
59
+
60
+ # Node.js
61
+ node_modules/
62
+ npm-debug.log*
63
+ yarn-debug.log*
64
+ yarn-error.log*
65
+
66
+ # Docker
67
+ .dockerignore
.goreleaser.yml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build customization
2
+ builds:
3
+ - main: ./main.go
4
+ binary: cursor2api-go
5
+ goos:
6
+ - windows
7
+ - darwin
8
+ - linux
9
+ goarch:
10
+ - amd64
11
+ - arm64
12
+ env:
13
+ - CGO_ENABLED=0
14
+ ldflags:
15
+ - -s -w
16
+ - -X main.version={{.Version}}
17
+ - -X main.commit={{.Commit}}
18
+ - -X main.date={{.Date}}
19
+
20
+ # Archive customization
21
+ archives:
22
+ - name_template: >-
23
+ {{ .ProjectName }}_
24
+ {{- title .Os }}_
25
+ {{- if eq .Arch "amd64" }}x86_64
26
+ {{- else if eq .Arch "386" }}i386
27
+ {{- else }}{{ .Arch }}{{ end }}
28
+ {{- if .Arm }}v{{ .Arm }}{{ end }}
29
+ format: tar.gz
30
+ format_overrides:
31
+ - goos: windows
32
+ format: zip
33
+
34
+ # Checksum customization
35
+ checksum:
36
+ name_template: 'checksums.txt'
37
+ algorithm: sha256
38
+
39
+ # Changelog customization
40
+ changelog:
41
+ sort: asc
42
+ filters:
43
+ exclude:
44
+ - '^docs:'
45
+ - '^test:'
46
+
47
+ # Release customization
48
+ release:
49
+ name_template: "{{.ProjectName}}-v{{.Version}}"
50
+ github:
51
+ owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
52
+ name: "{{ .Env.GITHUB_REPOSITORY_NAME }}"
53
+
54
+ # GitHub Actions integration
55
+ github_urls:
56
+ api: https://api.github.com/
57
+ upload: https://uploads.github.com/
58
+ download: https://github.com/
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage
2
+ FROM golang:1.24-bookworm AS builder
3
+
4
+ ENV GO111MODULE=on
5
+
6
+ WORKDIR /app
7
+
8
+ COPY go.mod go.sum ./
9
+ RUN go mod download
10
+
11
+ COPY . .
12
+
13
+ # Build a static binary
14
+ RUN CGO_ENABLED=0 GOOS=linux go build -a -o cursor2api-go .
15
+
16
+ # Runtime stage
17
+ FROM node:20-bookworm-slim
18
+
19
+ RUN apt-get update \
20
+ && apt-get install -y --no-install-recommends ca-certificates \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ ENV PORT=7860
24
+ WORKDIR /app
25
+
26
+ COPY --from=builder /app/cursor2api-go /app/
27
+ COPY --from=builder /app/static /app/static
28
+ COPY --from=builder /app/jscode /app/jscode
29
+
30
+ # Non-root user (HF Spaces friendly)
31
+ RUN chown -R node:node /app
32
+ USER node
33
+
34
+ EXPOSE 7860
35
+
36
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
37
+ CMD node -e "const http=require('http');const port=process.env.PORT||7860;http.get('http://127.0.0.1:'+port+'/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1));"
38
+
39
+ 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.
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
+ "github.com/libaxuan/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,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Capabilities
2
+
3
+ ## Supported
4
+
5
+ - OpenAI-compatible `POST /v1/chat/completions`
6
+ - OpenAI-compatible `POST /v1/responses`
7
+ - OpenAI-compatible `GET /v1/models`
8
+ - Non-stream responses with:
9
+ - plain assistant text
10
+ - assistant `tool_calls`
11
+ - Responses API outputs with:
12
+ - `output_text`
13
+ - `function_call`, `apply_patch_call`, `shell_call`, `local_shell_call`
14
+ - Stream responses with:
15
+ - `delta.content`
16
+ - `delta.tool_calls`
17
+ - Responses API stream events:
18
+ - `response.output_text.delta`
19
+ - `response.function_call_arguments.delta`
20
+ - Multi-turn context via the `messages` array
21
+ - Tool request fields:
22
+ - `tools`
23
+ - `tool_choice`
24
+ - assistant history `tool_calls`
25
+ - tool role history `tool_call_id`
26
+ - Automatically derived `*-thinking` public models, for example:
27
+ - `claude-sonnet-4.6`
28
+ - `claude-sonnet-4.6-thinking`
29
+ - Bearer token auth via `Authorization: Bearer <API_KEY>`
30
+
31
+ ## Behavior Notes
32
+
33
+ - Tool support is implemented through an internal prompt-and-parser bridge, not Cursor-native tool calling.
34
+ - Base models keep the current model name and enable tool use.
35
+ - `*-thinking` models map back to the same upstream base model, but also enable the internal thinking protocol.
36
+ - Thinking is an internal bridge capability only. The public OpenAI response does not expose a separate reasoning field.
37
+ - `previous_response_id` is supported with in-memory state only; restart clears history. For stateless use, include prior output items in `input`.
38
+
39
+ ## Not Supported
40
+
41
+ - Anthropic `/v1/messages`
42
+ - MCP orchestration
43
+ - Native upstream OpenAI tool execution
44
+ - Exposed reasoning/thinking response fields
45
+ - Local filesystem or OS command execution through the API
46
+ - Persisted Responses API storage or server-side conversations across restarts
47
+
48
+ ## Example: Non-Stream Tool Call
49
+
50
+ ```bash
51
+ curl -X POST http://127.0.0.1:8002/v1/chat/completions \
52
+ -H "Content-Type: application/json" \
53
+ -H "Authorization: Bearer 0000" \
54
+ -d '{
55
+ "model": "claude-sonnet-4.6",
56
+ "stream": false,
57
+ "messages": [
58
+ {"role": "user", "content": "帮我查询北京天气"}
59
+ ],
60
+ "tools": [
61
+ {
62
+ "type": "function",
63
+ "function": {
64
+ "name": "get_weather",
65
+ "description": "Get current weather",
66
+ "parameters": {
67
+ "type": "object",
68
+ "properties": {
69
+ "city": {"type": "string"}
70
+ },
71
+ "required": ["city"]
72
+ }
73
+ }
74
+ }
75
+ ]
76
+ }'
77
+ ```
78
+
79
+ ## Example: Thinking Model
80
+
81
+ ```bash
82
+ curl -X POST http://127.0.0.1:8002/v1/chat/completions \
83
+ -H "Content-Type: application/json" \
84
+ -H "Authorization: Bearer 0000" \
85
+ -d '{
86
+ "model": "claude-sonnet-4.6-thinking",
87
+ "stream": true,
88
+ "messages": [
89
+ {"role": "user", "content": "先思考,再决定是否需要工具"}
90
+ ],
91
+ "tools": [
92
+ {
93
+ "type": "function",
94
+ "function": {
95
+ "name": "lookup",
96
+ "parameters": {
97
+ "type": "object",
98
+ "properties": {
99
+ "q": {"type": "string"}
100
+ },
101
+ "required": ["q"]
102
+ }
103
+ }
104
+ }
105
+ ]
106
+ }'
107
+ ```
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/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ This directory holds README screenshots (e.g. home.png, tool-calls.png).
docs/images/home.png ADDED

Git LFS Details

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

Git LFS Details

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

Git LFS Details

  • SHA256: 8e917060e04526c8d371011d12b731afded678d3be1ad13c8af22ce6bfee2297
  • Pointer size: 131 Bytes
  • Size of remote file: 125 kB
go.md ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cursor2API Go 实现说明:OpenAI `chat/completions` 下的 thinking + tools
2
+
3
+ 本文档只描述当前 Go 仓库已经实现或明确约束的行为,不再沿用旧的 Deno `/v1/messages` 迁移口径。
4
+
5
+ ## 1. 当前范围
6
+
7
+ 当前仓库只支持两个公开接口:
8
+
9
+ - `GET /v1/models`
10
+ - `POST /v1/chat/completions`
11
+
12
+ 不实现 `/v1/messages`,也不承诺 Anthropic 原生 block/SSE 兼容。
13
+
14
+ ## 2. 真实能力边界
15
+
16
+ ### 2.1 公开模型目录
17
+
18
+ 配置项 `MODELS` 只填写基础模型,例如:
19
+
20
+ ```env
21
+ MODELS=claude-sonnet-4.6
22
+ ```
23
+
24
+ 服务会自动向外暴露两类模型:
25
+
26
+ - 基础模型:`claude-sonnet-4.6`
27
+ - thinking 派生模型:`claude-sonnet-4.6-thinking`
28
+
29
+ 约定如下:
30
+
31
+ - 基础模型:保留现有模型名,启用 tool 能力。
32
+ - `-thinking` 模型:自动映射回基础模型出站,同时启用 thinking 和 tool 能力。
33
+ - 不允许继续派生 `-thinking-thinking`。
34
+
35
+ ### 2.2 外部接口契约
36
+
37
+ 当前实现的是 OpenAI `chat/completions` 兼容面:
38
+
39
+ - 请求支持 `messages`
40
+ - 请求支持 `tools`
41
+ - 请求支持 `tool_choice`
42
+ - assistant 历史消息支持 `tool_calls`
43
+ - tool 历史消息支持 `tool_call_id`
44
+ - 响应支持非流式 `message.tool_calls`
45
+ - 响应支持流式 `delta.tool_calls`
46
+
47
+ thinking 不作为 OpenAI 独立字段对外暴露。它只作为内部 prompt 协议与解析协议使用。
48
+
49
+ ## 3. 请求侧链路
50
+
51
+ ### 3.1 入口文件
52
+
53
+ - `handlers/handler.go`
54
+ - `services/cursor.go`
55
+ - `services/cursor_protocol.go`
56
+
57
+ ### 3.2 处理步骤
58
+
59
+ 1. `handlers.ChatCompletions` 绑定并校验 OpenAI 请求。
60
+ 2. `CursorService.buildCursorRequest(...)` 解析模型能力:
61
+ - `claude-sonnet-4.6` -> 基础模型
62
+ - `claude-sonnet-4.6-thinking` -> 基础模型 + thinking 开启
63
+ 3. `tool_choice` 被解析为三类模式:
64
+ - `auto`
65
+ - `none`
66
+ - `required`
67
+ - 或 `{"type":"function","function":{"name":"..."}}`
68
+ 4. 工具定义被统一校验:
69
+ - 仅支持 `type=function`
70
+ - `function.name` 必填
71
+ - 不允许重名
72
+ 5. 构造单次请求专用 `TriggerSignal`,格式:
73
+
74
+ ```text
75
+ <<CALL_xxxxxxxx>>
76
+ ```
77
+
78
+ 6. 构造发往 Cursor 的纯文本消息。
79
+
80
+ ## 4. 内部 prompt 协议
81
+
82
+ 上游 Cursor 仍然只收到文本消息,不使用原生 OpenAI tool calling。
83
+
84
+ ### 4.1 tool 协议
85
+
86
+ 当请求含有 `tools` 且 `tool_choice != "none"` 时,system message 会注入:
87
+
88
+ - 工具桥接说明
89
+ - tool 调用格式约束
90
+ - `<function_list>` 工具清单
91
+ - `required` / 指定 function 的额外约束
92
+
93
+ 模型被要求按如下格式输出工具调用:
94
+
95
+ ```text
96
+ <<CALL_xxxxxxxx>>
97
+ <invoke name="tool_name">{"arg":"value"}</invoke>
98
+ ```
99
+
100
+ ### 4.2 thinking 协议
101
+
102
+ 当请求命中 `*-thinking` 模型时:
103
+
104
+ - system prompt 注入 thinking 规则
105
+ - 每条 user message 追加固定 thinking hint
106
+
107
+ hint 为:
108
+
109
+ ```text
110
+ Use <thinking>...</thinking> for hidden reasoning when it helps. Keep your final visible answer outside the thinking tags.
111
+ ```
112
+
113
+ ### 4.3 历史消息回放
114
+
115
+ 为了让多轮 tool loop 继续工作,OpenAI 历史消息会被回放成内部文本协议:
116
+
117
+ - assistant `tool_calls` -> `TriggerSignal + <invoke ...>`
118
+ - tool message -> `<tool_result id="...">...</tool_result>`
119
+
120
+ 普通 `system/user/assistant` 文本则继续按 Cursor `parts[].text` 发送。
121
+
122
+ ## 5. 响应解析链路
123
+
124
+ ### 5.1 入口文件
125
+
126
+ - `services/cursor.go`
127
+ - `utils/cursor_protocol.go`
128
+ - `utils/utils.go`
129
+
130
+ ### 5.2 解析步骤
131
+
132
+ 1. `consumeSSE(...)` 读取 Cursor SSE。
133
+ 2. 每个 `delta` 文本片段进入 `CursorProtocolParser`。
134
+ 3. 解析器增量产出三类内部事件:
135
+ - `text`
136
+ - `thinking`
137
+ - `tool_call`
138
+ 4. `thinking` 只保留在内部事件层,不直接回写给客户端。
139
+
140
+ ### 5.3 解析规则
141
+
142
+ - `<thinking>...</thinking>` 在 thinking 模型下会被消费为内部 thinking 事件。
143
+ - `TriggerSignal + <invoke ...>` 会被消费为工具调用事件。
144
+ - 不完整标签会在流结束时降级为普通文本,避免丢内容。
145
+ - 多个连续 `<invoke>` 会逐个产出,不再只保留第一个。
146
+
147
+ ## 6. OpenAI 响应写回
148
+
149
+ ### 6.1 非流式
150
+
151
+ 非流式响应会在 `services.CursorService.ChatCompletionNonStream(...)` 中聚合内部事件:
152
+
153
+ - 只有文本时,返回普通 `message.content`
154
+ - 有工具调用时,返回 assistant `tool_calls`
155
+ - 若本轮出现工具调用,`finish_reason = "tool_calls"`
156
+ - 否则 `finish_reason = "stop"`
157
+
158
+ 为提升与部分编排器(例如 Kilo Code)在“必须用工具”场景下的兼容性:
159
+
160
+ - 当请求含 `tools` 且被判定为“必须至少调用一次工具”(`tool_choice=required/指定函数`,或启用 `KILO_TOOL_STRICT`)时,
161
+ 如果第一轮没有产出任何 `tool_calls`,服务会自动重试 1 次(仅非流式)。
162
+
163
+ ### 6.2 流式
164
+
165
+ `utils.StreamChatCompletion(...)` 会输出:
166
+
167
+ - 首个 role chunk:`assistant`
168
+ - 文本 chunk:`delta.content`
169
+ - 工具调用 chunk:`delta.tool_calls`
170
+ - 收尾 chunk:
171
+ - 有工具调用 -> `finish_reason = "tool_calls"`
172
+ - 无工具调用 -> `finish_reason = "stop"`
173
+
174
+ thinking 内容不会作为独立 OpenAI 字段透出。
175
+
176
+ ## 7. 代码落点
177
+
178
+ 本次能力集中在以下模块:
179
+
180
+ - `models/`
181
+ - OpenAI request/response/tool 类型
182
+ - thinking 模型派生规则
183
+ - 基础模型到 Cursor 模型的映射
184
+ - `config/`
185
+ - `MODELS` 基础模型配置
186
+ - 自动扩展 `*-thinking` 公开模型目录
187
+ - `services/cursor_protocol.go`
188
+ - `tool_choice` 解析
189
+ - 工具定义校验
190
+ - OpenAI 历史消息到 Cursor 文本协议的转换
191
+ - `utils/cursor_protocol.go`
192
+ - Cursor 文本增量到 `text/thinking/tool_call` 事件的解析
193
+ - `utils/utils.go`
194
+ - OpenAI 流式/非流式响应写回
195
+
196
+ ## 8. 测试基线
197
+
198
+ 当前测试覆盖以下关键路径:
199
+
200
+ - `MODELS` 自动扩展基础模型和 `-thinking` 模型
201
+ - thinking 模型回落到基础出站模型
202
+ - tool prompt 注入与 tool history 回放
203
+ - `<thinking>` / `<invoke>` 的增量解析
204
+ - 流式 `delta.tool_calls`
205
+ - 非流式 `message.tool_calls`
206
+ - 纯文本聊天回归不破坏
207
+
208
+ ## 9. 非目标
209
+
210
+ 当前不做以下内容:
211
+
212
+ - Anthropic `/v1/messages`
213
+ - MCP
214
+ - 原生 OpenAI tool execution
215
+ - 可见的 reasoning/thinking 对外字段
216
+ - 图像、文档、文件等多模态 block 协议
go.mod ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module github.com/libaxuan/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,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "github.com/libaxuan/cursor2api-go/config"
25
+ "github.com/libaxuan/cursor2api-go/middleware"
26
+ "github.com/libaxuan/cursor2api-go/models"
27
+ "github.com/libaxuan/cursor2api-go/services"
28
+ "github.com/libaxuan/cursor2api-go/utils"
29
+ "net/http"
30
+ "os"
31
+ "strings"
32
+ "time"
33
+
34
+ "github.com/gin-gonic/gin"
35
+ "github.com/sirupsen/logrus"
36
+ )
37
+
38
+ // Handler 处理器结构
39
+ type Handler struct {
40
+ config *config.Config
41
+ cursorService *services.CursorService
42
+ docsContent []byte
43
+ responseStore *services.ResponseStore
44
+ }
45
+
46
+ // NewHandler 创建新的处理器
47
+ func NewHandler(cfg *config.Config) *Handler {
48
+ cursorService := services.NewCursorService(cfg)
49
+
50
+ // 预加载文档内容
51
+ docsPath := "static/docs.html"
52
+ var docsContent []byte
53
+
54
+ if data, err := os.ReadFile(docsPath); err == nil {
55
+ docsContent = data
56
+ } else {
57
+ // 如果文件不存在,使用默认的简单HTML页面
58
+ simpleHTML := `
59
+ <!DOCTYPE html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
64
+ <title>Cursor2API - Go Version</title>
65
+ <style>
66
+ body {
67
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
68
+ max-width: 800px;
69
+ margin: 50px auto;
70
+ padding: 20px;
71
+ background-color: #f5f5f5;
72
+ }
73
+ .container {
74
+ background: white;
75
+ padding: 30px;
76
+ border-radius: 10px;
77
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
78
+ }
79
+ h1 {
80
+ color: #333;
81
+ border-bottom: 2px solid #007bff;
82
+ padding-bottom: 10px;
83
+ }
84
+ .info {
85
+ background: #f8f9fa;
86
+ padding: 20px;
87
+ border-radius: 8px;
88
+ margin: 20px 0;
89
+ border-left: 4px solid #007bff;
90
+ }
91
+ code {
92
+ background: #e9ecef;
93
+ padding: 2px 6px;
94
+ border-radius: 4px;
95
+ font-family: 'Courier New', monospace;
96
+ }
97
+ .endpoint {
98
+ background: #e3f2fd;
99
+ padding: 10px;
100
+ margin: 10px 0;
101
+ border-radius: 5px;
102
+ border-left: 3px solid #2196f3;
103
+ }
104
+ .status-ok {
105
+ color: #28a745;
106
+ font-weight: bold;
107
+ }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <div class="container">
112
+ <h1>🚀 Cursor2API - Go Version</h1>
113
+
114
+ <div class="info">
115
+ <p><strong>Status:</strong> <span class="status-ok">✅ Running</span></p>
116
+ <p><strong>Version:</strong> Go Implementation</p>
117
+ <p><strong>Description:</strong> OpenAI-compatible chat completions proxy for Cursor AI</p>
118
+ </div>
119
+
120
+ <div class="info">
121
+ <h3>📡 Available Endpoints:</h3>
122
+ <div class="endpoint">
123
+ <strong>GET</strong> <code>/v1/models</code><br>
124
+ <small>List available AI models</small>
125
+ </div>
126
+ <div class="endpoint">
127
+ <strong>POST</strong> <code>/v1/chat/completions</code><br>
128
+ <small>Create chat completion (supports streaming and tool calls)</small>
129
+ </div>
130
+ <div class="endpoint">
131
+ <strong>POST</strong> <code>/v1/responses</code><br>
132
+ <small>Create response (Responses API compatible)</small>
133
+ </div>
134
+ <div class="endpoint">
135
+ <strong>GET</strong> <code>/health</code><br>
136
+ <small>Health check endpoint</small>
137
+ </div>
138
+ </div>
139
+
140
+ <div class="info">
141
+ <h3>🔐 Authentication:</h3>
142
+ <p>Use Bearer token authentication:</p>
143
+ <code>Authorization: Bearer YOUR_API_KEY</code>
144
+ <p><small>Default API key: <code>0000</code> (change via API_KEY environment variable)</small></p>
145
+ </div>
146
+
147
+ <div class="info">
148
+ <h3>💻 Example Usage:</h3>
149
+ <pre><code>curl -X POST http://localhost:8002/v1/chat/completions \
150
+ -H "Content-Type: application/json" \
151
+ -H "Authorization: Bearer 0000" \
152
+ -d '{
153
+ "model": "claude-sonnet-4.6-thinking",
154
+ "messages": [
155
+ {"role": "user", "content": "Plan first, then decide whether a tool is needed."}
156
+ ],
157
+ "stream": true
158
+ }'</code></pre>
159
+ </div>
160
+
161
+ <div class="info">
162
+ <p><strong>Repository:</strong> <a href="https://github.com/cursor2api/cursor2api-go">cursor2api-go</a></p>
163
+ <p><strong>Documentation:</strong> OpenAI API compatible</p>
164
+ </div>
165
+ </div>
166
+ </body>
167
+ </html>`
168
+ docsContent = []byte(simpleHTML)
169
+ }
170
+
171
+ return &Handler{
172
+ config: cfg,
173
+ cursorService: cursorService,
174
+ docsContent: docsContent,
175
+ responseStore: services.NewResponseStore(0, 0),
176
+ }
177
+
178
+ }
179
+
180
+ // ListModels 列出可用模型
181
+ func (h *Handler) ListModels(c *gin.Context) {
182
+ modelNames := h.config.GetModels()
183
+ modelList := make([]models.Model, 0, len(modelNames))
184
+
185
+ for _, modelID := range modelNames {
186
+ // 获取模型配置信息
187
+ modelConfig, exists := models.GetModelConfig(modelID)
188
+
189
+ model := models.Model{
190
+ ID: modelID,
191
+ Object: "model",
192
+ Created: time.Now().Unix(),
193
+ OwnedBy: "cursor2api",
194
+ }
195
+
196
+ // 如果找到模型配置,添加max_tokens和context_window信息
197
+ if exists {
198
+ model.MaxTokens = modelConfig.MaxTokens
199
+ model.ContextWindow = modelConfig.ContextWindow
200
+ }
201
+
202
+ modelList = append(modelList, model)
203
+ }
204
+
205
+ response := models.ModelsResponse{
206
+ Object: "list",
207
+ Data: modelList,
208
+ }
209
+
210
+ c.JSON(http.StatusOK, response)
211
+ }
212
+
213
+ // ChatCompletions 处理聊天完成请求
214
+ func (h *Handler) ChatCompletions(c *gin.Context) {
215
+ var request models.ChatCompletionRequest
216
+ if err := c.ShouldBindJSON(&request); err != nil {
217
+ logrus.WithError(err).Error("Failed to bind request")
218
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
219
+ "Invalid request format",
220
+ "invalid_request_error",
221
+ "invalid_json",
222
+ ))
223
+ return
224
+ }
225
+
226
+ // 验证模型
227
+ if !h.config.IsValidModel(request.Model) {
228
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
229
+ "Invalid model specified",
230
+ "invalid_request_error",
231
+ "model_not_found",
232
+ ))
233
+ return
234
+ }
235
+
236
+ // 验证消息
237
+ if len(request.Messages) == 0 {
238
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
239
+ "Messages cannot be empty",
240
+ "invalid_request_error",
241
+ "missing_messages",
242
+ ))
243
+ return
244
+ }
245
+
246
+ // 验证并调整max_tokens参数
247
+ request.MaxTokens = models.ValidateMaxTokens(request.Model, request.MaxTokens)
248
+
249
+ // 调用Cursor服务
250
+ // 根据是否流式返回不同响应
251
+ if request.Stream {
252
+ chatGenerator, err := h.cursorService.ChatCompletion(c.Request.Context(), &request)
253
+ if err != nil {
254
+ logrus.WithError(err).Error("Failed to create chat completion")
255
+ middleware.HandleError(c, err)
256
+ return
257
+ }
258
+ utils.SafeStreamWrapper(utils.StreamChatCompletion, c, chatGenerator, request.Model)
259
+ } else {
260
+ resp, err := h.cursorService.ChatCompletionNonStream(c.Request.Context(), &request)
261
+ if err != nil {
262
+ logrus.WithError(err).Error("Failed to create non-stream chat completion")
263
+ middleware.HandleError(c, err)
264
+ return
265
+ }
266
+ c.JSON(http.StatusOK, resp)
267
+ }
268
+ }
269
+
270
+ // Responses 处理 Responses API 请求
271
+ func (h *Handler) Responses(c *gin.Context) {
272
+ var request models.ResponseRequest
273
+ if err := c.ShouldBindJSON(&request); err != nil {
274
+ logrus.WithError(err).Error("Failed to bind response request")
275
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
276
+ "Invalid request format",
277
+ "invalid_request_error",
278
+ "invalid_json",
279
+ ))
280
+ return
281
+ }
282
+
283
+ // 验证模型
284
+ if !h.config.IsValidModel(request.Model) {
285
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
286
+ "Invalid model specified",
287
+ "invalid_request_error",
288
+ "model_not_found",
289
+ ))
290
+ return
291
+ }
292
+
293
+ var previousMessages []models.Message
294
+ previousID := ""
295
+ if request.PreviousResponseID != nil {
296
+ previousID = strings.TrimSpace(*request.PreviousResponseID)
297
+ }
298
+ if previousID != "" {
299
+ stored, ok := h.responseStore.Get(previousID)
300
+ if !ok {
301
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
302
+ "previous_response_id not found; include prior output items in input instead",
303
+ "invalid_request_error",
304
+ "previous_response_id",
305
+ ))
306
+ return
307
+ }
308
+ previousMessages = stored
309
+ }
310
+
311
+ chatRequest, adapter, err := services.BuildChatCompletionRequestFromResponse(&request)
312
+ if err != nil {
313
+ c.JSON(http.StatusBadRequest, models.NewErrorResponse(
314
+ err.Error(),
315
+ "invalid_request_error",
316
+ "invalid_input",
317
+ ))
318
+ return
319
+ }
320
+
321
+ chatRequest.MaxTokens = models.ValidateMaxTokens(chatRequest.Model, chatRequest.MaxTokens)
322
+ if len(previousMessages) > 0 {
323
+ chatRequest.Messages = mergeStoredMessages(chatRequest.Messages, previousMessages)
324
+ }
325
+
326
+ if chatRequest.Stream {
327
+ chatGenerator, err := h.cursorService.ChatCompletion(c.Request.Context(), chatRequest)
328
+ if err != nil {
329
+ logrus.WithError(err).Error("Failed to create response stream")
330
+ middleware.HandleError(c, err)
331
+ return
332
+ }
333
+ responseID := utils.GenerateResponseID()
334
+ accumulator := newStreamAccumulator()
335
+ wrapped := teeChatGenerator(c.Request.Context(), chatGenerator, accumulator)
336
+ utils.SafeStreamWrapper(func(ctx *gin.Context, gen <-chan interface{}, model string) {
337
+ services.StreamResponse(ctx, gen, &request, adapter, responseID)
338
+ }, c, wrapped, chatRequest.Model)
339
+ if h.responseStore != nil && accumulator.okToStore() {
340
+ assistant := accumulator.message()
341
+ conversation := buildConversationForStore(chatRequest.Messages, assistant)
342
+ h.responseStore.Put(responseID, conversation)
343
+ }
344
+ return
345
+ }
346
+
347
+ resp, err := h.cursorService.ChatCompletionNonStream(c.Request.Context(), chatRequest)
348
+ if err != nil {
349
+ logrus.WithError(err).Error("Failed to create non-stream response")
350
+ middleware.HandleError(c, err)
351
+ return
352
+ }
353
+
354
+ if len(resp.Choices) == 0 {
355
+ c.JSON(http.StatusInternalServerError, models.NewErrorResponse(
356
+ "Empty response from upstream",
357
+ "internal_error",
358
+ "empty_response",
359
+ ))
360
+ return
361
+ }
362
+ output, outputText := services.BuildResponseOutputFromMessage(resp.Choices[0].Message, adapter)
363
+ usage := services.BuildResponseUsage(resp.Usage)
364
+ response := services.BuildCompletedResponse(&request, output, outputText, usage)
365
+ if h.responseStore != nil {
366
+ conversation := buildConversationForStore(chatRequest.Messages, resp.Choices[0].Message)
367
+ h.responseStore.Put(response.ID, conversation)
368
+ }
369
+ c.JSON(http.StatusOK, response)
370
+ }
371
+
372
+ // ServeDocs 服务API文档页面
373
+ func (h *Handler) ServeDocs(c *gin.Context) {
374
+ c.Data(http.StatusOK, "text/html; charset=utf-8", h.docsContent)
375
+ }
376
+
377
+ // Health 健康检查
378
+ func (h *Handler) Health(c *gin.Context) {
379
+ c.JSON(http.StatusOK, gin.H{
380
+ "status": "ok",
381
+ "timestamp": time.Now().Unix(),
382
+ "version": "go-1.0.0",
383
+ })
384
+ }
handlers/response_helpers.go ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package handlers
2
+
3
+ import (
4
+ "context"
5
+ "github.com/libaxuan/cursor2api-go/models"
6
+ "strings"
7
+ )
8
+
9
+ type streamAccumulator struct {
10
+ content strings.Builder
11
+ toolCalls []models.ToolCall
12
+ hadError bool
13
+ aborted bool
14
+ hasOutput bool
15
+ }
16
+
17
+ func newStreamAccumulator() *streamAccumulator {
18
+ return &streamAccumulator{}
19
+ }
20
+
21
+ func (a *streamAccumulator) feed(item interface{}) {
22
+ switch v := item.(type) {
23
+ case models.AssistantEvent:
24
+ switch v.Kind {
25
+ case models.AssistantEventText:
26
+ if v.Text != "" {
27
+ a.content.WriteString(v.Text)
28
+ a.hasOutput = true
29
+ }
30
+ case models.AssistantEventToolCall:
31
+ if v.ToolCall != nil {
32
+ a.toolCalls = append(a.toolCalls, *v.ToolCall)
33
+ a.hasOutput = true
34
+ }
35
+ case models.AssistantEventThinking:
36
+ return
37
+ }
38
+ case string:
39
+ if v != "" {
40
+ a.content.WriteString(v)
41
+ a.hasOutput = true
42
+ }
43
+ case error:
44
+ a.hadError = true
45
+ default:
46
+ return
47
+ }
48
+ }
49
+
50
+ func (a *streamAccumulator) markAborted() {
51
+ a.aborted = true
52
+ }
53
+
54
+ func (a *streamAccumulator) okToStore() bool {
55
+ return a.hasOutput && !a.hadError && !a.aborted
56
+ }
57
+
58
+ func (a *streamAccumulator) message() models.Message {
59
+ msg := models.Message{Role: "assistant"}
60
+ if a.content.Len() > 0 || len(a.toolCalls) == 0 {
61
+ msg.Content = a.content.String()
62
+ }
63
+ if len(a.toolCalls) > 0 {
64
+ msg.ToolCalls = a.toolCalls
65
+ }
66
+ return msg
67
+ }
68
+
69
+ func teeChatGenerator(ctx context.Context, in <-chan interface{}, acc *streamAccumulator) <-chan interface{} {
70
+ out := make(chan interface{}, 32)
71
+ go func() {
72
+ defer close(out)
73
+ for {
74
+ select {
75
+ case <-ctx.Done():
76
+ acc.markAborted()
77
+ return
78
+ case item, ok := <-in:
79
+ if !ok {
80
+ return
81
+ }
82
+ acc.feed(item)
83
+ select {
84
+ case out <- item:
85
+ case <-ctx.Done():
86
+ acc.markAborted()
87
+ return
88
+ }
89
+ }
90
+ }
91
+ }()
92
+ return out
93
+ }
94
+
95
+ func mergeStoredMessages(messages, stored []models.Message) []models.Message {
96
+ if len(stored) == 0 {
97
+ return messages
98
+ }
99
+
100
+ var system *models.Message
101
+ rest := messages
102
+ if len(messages) > 0 && strings.EqualFold(messages[0].Role, "system") {
103
+ system = &messages[0]
104
+ rest = messages[1:]
105
+ }
106
+
107
+ merged := make([]models.Message, 0, len(messages)+len(stored))
108
+ if system != nil {
109
+ merged = append(merged, *system)
110
+ }
111
+ merged = append(merged, stored...)
112
+ merged = append(merged, rest...)
113
+ return merged
114
+ }
115
+
116
+ func buildConversationForStore(messages []models.Message, assistant models.Message) []models.Message {
117
+ conversation := make([]models.Message, 0, len(messages)+1)
118
+ for _, msg := range messages {
119
+ if strings.EqualFold(msg.Role, "system") {
120
+ continue
121
+ }
122
+ conversation = append(conversation, msg)
123
+ }
124
+ conversation = append(conversation, assistant)
125
+ return conversation
126
+ }
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,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "github.com/libaxuan/cursor2api-go/config"
6
+ "github.com/libaxuan/cursor2api-go/handlers"
7
+ "github.com/libaxuan/cursor2api-go/middleware"
8
+ "github.com/libaxuan/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
+ // Responses API
112
+ v1.POST("/responses", middleware.AuthRequired(), handler.Responses)
113
+ }
114
+
115
+ // 静态文件服务(如果需要)
116
+ router.Static("/static", "./static")
117
+ }
118
+
119
+ // printStartupBanner 打印启动横幅
120
+ func printStartupBanner(cfg *config.Config) {
121
+ banner := `
122
+ ╔══════════════════════════════════════════════════════════════╗
123
+ ║ Cursor2API Server ║
124
+ ╚══════════════════════════════════════════════════════════════╝
125
+ `
126
+ fmt.Println(banner)
127
+
128
+ fmt.Printf("🚀 服务地址: http://localhost:%d\n", cfg.Port)
129
+ fmt.Printf("📚 API 文档: http://localhost:%d/\n", cfg.Port)
130
+ fmt.Printf("💊 健康检查: http://localhost:%d/health\n", cfg.Port)
131
+ fmt.Printf("🔑 API 密钥: %s\n", maskAPIKey(cfg.APIKey))
132
+
133
+ modelList := cfg.GetModels()
134
+ fmt.Printf("\n🤖 支持模型 (%d 个):\n", len(modelList))
135
+
136
+ // 按类别分组显示模型
137
+ providers := make(map[string][]string)
138
+ for _, modelID := range modelList {
139
+ if config, exists := models.GetModelConfig(modelID); exists {
140
+ providers[config.Provider] = append(providers[config.Provider], modelID)
141
+ } else {
142
+ providers["Other"] = append(providers["Other"], modelID)
143
+ }
144
+ }
145
+
146
+ // 按Provider排序并显示
147
+ for _, provider := range []string{"Anthropic", "OpenAI", "Google", "Other"} {
148
+ if models, ok := providers[provider]; ok && len(models) > 0 {
149
+ fmt.Printf(" %s: %s\n", provider, strings.Join(models, ", "))
150
+ }
151
+ }
152
+
153
+ if cfg.Debug {
154
+ fmt.Println("\n🐛 调试模式: 已启用")
155
+ }
156
+
157
+ fmt.Println("\n✨ 服务已启动,按 Ctrl+C 停止")
158
+ fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
159
+ }
160
+
161
+ // maskAPIKey 掩码 API 密钥,只显示前 4 位
162
+ func maskAPIKey(key string) string {
163
+ if len(key) <= 4 {
164
+ return "****"
165
+ }
166
+ return key[:4] + "****"
167
+ }
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
+ "github.com/libaxuan/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
+ "github.com/libaxuan/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
+ }
models/responses.go ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 "encoding/json"
24
+
25
+ // ResponseRequest OpenAI Responses API request
26
+ type ResponseRequest struct {
27
+ Model string `json:"model" binding:"required"`
28
+ Input json.RawMessage `json:"input,omitempty"`
29
+ Instructions json.RawMessage `json:"instructions,omitempty"`
30
+ Stream bool `json:"stream,omitempty"`
31
+ Tools []map[string]interface{} `json:"tools,omitempty"`
32
+ ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
33
+ Temperature *float64 `json:"temperature,omitempty"`
34
+ TopP *float64 `json:"top_p,omitempty"`
35
+ MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
36
+ User *string `json:"user,omitempty"`
37
+ PreviousResponseID *string `json:"previous_response_id,omitempty"`
38
+ Store *bool `json:"store,omitempty"`
39
+ Metadata map[string]string `json:"metadata,omitempty"`
40
+ ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
41
+ Text json.RawMessage `json:"text,omitempty"`
42
+ Reasoning json.RawMessage `json:"reasoning,omitempty"`
43
+ MaxToolCalls *int `json:"max_tool_calls,omitempty"`
44
+ Background *bool `json:"background,omitempty"`
45
+ Conversation json.RawMessage `json:"conversation,omitempty"`
46
+ Truncation string `json:"truncation,omitempty"`
47
+ }
48
+
49
+ // Response OpenAI Responses API response
50
+ type Response struct {
51
+ ID string `json:"id"`
52
+ Object string `json:"object"`
53
+ CreatedAt int64 `json:"created_at"`
54
+ Status string `json:"status"`
55
+ CompletedAt *int64 `json:"completed_at,omitempty"`
56
+ Background *bool `json:"background,omitempty"`
57
+ Error *ResponseError `json:"error,omitempty"`
58
+ IncompleteDetails *ResponseIncompleteInfo `json:"incomplete_details,omitempty"`
59
+ Instructions interface{} `json:"instructions,omitempty"`
60
+ MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
61
+ MaxToolCalls *int `json:"max_tool_calls,omitempty"`
62
+ Model string `json:"model"`
63
+ Output []interface{} `json:"output"`
64
+ OutputText string `json:"output_text,omitempty"`
65
+ ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
66
+ PreviousResponseID *string `json:"previous_response_id,omitempty"`
67
+ Reasoning *ResponseReasoning `json:"reasoning,omitempty"`
68
+ Store *bool `json:"store,omitempty"`
69
+ Temperature *float64 `json:"temperature,omitempty"`
70
+ Text interface{} `json:"text,omitempty"`
71
+ ToolChoice interface{} `json:"tool_choice,omitempty"`
72
+ Tools []map[string]interface{} `json:"tools,omitempty"`
73
+ TopP *float64 `json:"top_p,omitempty"`
74
+ Truncation string `json:"truncation,omitempty"`
75
+ Usage *ResponseUsage `json:"usage,omitempty"`
76
+ User *string `json:"user,omitempty"`
77
+ Metadata map[string]string `json:"metadata,omitempty"`
78
+ }
79
+
80
+ // ResponseError mirrors the Responses API error object
81
+ type ResponseError struct {
82
+ Message string `json:"message"`
83
+ Type string `json:"type,omitempty"`
84
+ Code string `json:"code,omitempty"`
85
+ }
86
+
87
+ // ResponseIncompleteInfo mirrors the incomplete_details object
88
+ type ResponseIncompleteInfo struct {
89
+ Reason string `json:"reason,omitempty"`
90
+ }
91
+
92
+ // ResponseReasoning mirrors the reasoning config on responses
93
+ type ResponseReasoning struct {
94
+ Effort *string `json:"effort,omitempty"`
95
+ Summary interface{} `json:"summary,omitempty"`
96
+ }
97
+
98
+ // ResponseUsage mirrors the usage object on responses
99
+ type ResponseUsage struct {
100
+ InputTokens int `json:"input_tokens"`
101
+ OutputTokens int `json:"output_tokens"`
102
+ TotalTokens int `json:"total_tokens"`
103
+ OutputTokensDetails *ResponseOutputTokensDetails `json:"output_tokens_details,omitempty"`
104
+ }
105
+
106
+ // ResponseOutputTokensDetails mirrors output_tokens_details
107
+ type ResponseOutputTokensDetails struct {
108
+ ReasoningTokens int `json:"reasoning_tokens,omitempty"`
109
+ }
110
+
111
+ // ResponseOutputTextContent is an output_text content part
112
+ type ResponseOutputTextContent struct {
113
+ Type string `json:"type"`
114
+ Text string `json:"text"`
115
+ Annotations []interface{} `json:"annotations,omitempty"`
116
+ Logprobs []interface{} `json:"logprobs,omitempty"`
117
+ }
118
+
119
+ // ResponseOutputMessage is a message output item
120
+ type ResponseOutputMessage struct {
121
+ ID string `json:"id"`
122
+ Type string `json:"type"`
123
+ Status string `json:"status,omitempty"`
124
+ Role string `json:"role"`
125
+ Content []ResponseOutputTextContent `json:"content"`
126
+ }
127
+
128
+ // ResponseFunctionCall is a function_call output item
129
+ type ResponseFunctionCall struct {
130
+ ID string `json:"id"`
131
+ Type string `json:"type"`
132
+ Status string `json:"status,omitempty"`
133
+ CallID string `json:"call_id"`
134
+ Name string `json:"name"`
135
+ Arguments string `json:"arguments"`
136
+ }
137
+
138
+ // ResponseApplyPatchCall is an apply_patch_call output item
139
+ type ResponseApplyPatchCall struct {
140
+ ID string `json:"id"`
141
+ Type string `json:"type"`
142
+ Status string `json:"status,omitempty"`
143
+ CallID string `json:"call_id"`
144
+ Operation map[string]interface{} `json:"operation"`
145
+ }
146
+
147
+ // ResponseShellCall is a shell_call output item
148
+ type ResponseShellCall struct {
149
+ ID string `json:"id"`
150
+ Type string `json:"type"`
151
+ Status string `json:"status,omitempty"`
152
+ CallID string `json:"call_id"`
153
+ Action map[string]interface{} `json:"action"`
154
+ Environment interface{} `json:"environment,omitempty"`
155
+ }
156
+
157
+ // ResponseLocalShellCall is a local_shell_call output item
158
+ type ResponseLocalShellCall struct {
159
+ ID string `json:"id"`
160
+ Type string `json:"type"`
161
+ Status string `json:"status,omitempty"`
162
+ CallID string `json:"call_id"`
163
+ Action map[string]interface{} `json:"action"`
164
+ }
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
+ "github.com/libaxuan/cursor2api-go/config"
27
+ "github.com/libaxuan/cursor2api-go/middleware"
28
+ "github.com/libaxuan/cursor2api-go/models"
29
+ "github.com/libaxuan/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
+ "github.com/libaxuan/cursor2api-go/middleware"
6
+ "github.com/libaxuan/cursor2api-go/models"
7
+ "github.com/libaxuan/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
+ "github.com/libaxuan/cursor2api-go/config"
5
+ "github.com/libaxuan/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
+ }
services/response_adapter.go ADDED
@@ -0,0 +1,664 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "bytes"
5
+ "github.com/libaxuan/cursor2api-go/models"
6
+ "encoding/json"
7
+ "fmt"
8
+ "strings"
9
+ )
10
+
11
+ // ResponseToolAdapter records tool mappings for Responses API bridging.
12
+ type ResponseToolAdapter struct {
13
+ ToolTypesByName map[string]string
14
+ ShellEnvironment interface{}
15
+ }
16
+
17
+ // BuildChatCompletionRequestFromResponse converts a Responses API request into a chat/completions request.
18
+ func BuildChatCompletionRequestFromResponse(req *models.ResponseRequest) (*models.ChatCompletionRequest, *ResponseToolAdapter, error) {
19
+ instructionMessages, err := parseResponseMessages(req.Instructions, "system")
20
+ if err != nil {
21
+ return nil, nil, err
22
+ }
23
+
24
+ inputMessages, err := parseResponseMessages(req.Input, "user")
25
+ if err != nil {
26
+ return nil, nil, err
27
+ }
28
+
29
+ messages := append(instructionMessages, inputMessages...)
30
+ if len(messages) == 0 {
31
+ return nil, nil, fmt.Errorf("input is required")
32
+ }
33
+
34
+ tools, adapter, err := convertResponseTools(req.Tools)
35
+ if err != nil {
36
+ return nil, nil, err
37
+ }
38
+
39
+ toolChoice, err := convertResponseToolChoice(req.ToolChoice)
40
+ if err != nil {
41
+ return nil, nil, err
42
+ }
43
+
44
+ chatReq := &models.ChatCompletionRequest{
45
+ Model: req.Model,
46
+ Messages: messages,
47
+ Stream: req.Stream,
48
+ Temperature: req.Temperature,
49
+ MaxTokens: req.MaxOutputTokens,
50
+ TopP: req.TopP,
51
+ Tools: tools,
52
+ ToolChoice: toolChoice,
53
+ }
54
+
55
+ if req.User != nil {
56
+ chatReq.User = *req.User
57
+ }
58
+
59
+ return chatReq, adapter, nil
60
+ }
61
+
62
+ func parseResponseMessages(raw json.RawMessage, defaultRole string) ([]models.Message, error) {
63
+ trimmed := bytes.TrimSpace(raw)
64
+ if len(trimmed) == 0 || string(trimmed) == "null" {
65
+ return nil, nil
66
+ }
67
+
68
+ switch trimmed[0] {
69
+ case '"':
70
+ var text string
71
+ if err := json.Unmarshal(trimmed, &text); err != nil {
72
+ return nil, fmt.Errorf("invalid input text: %w", err)
73
+ }
74
+ if strings.TrimSpace(text) == "" {
75
+ return nil, nil
76
+ }
77
+ return []models.Message{{Role: defaultRole, Content: text}}, nil
78
+ case '{':
79
+ var obj map[string]interface{}
80
+ if err := json.Unmarshal(trimmed, &obj); err != nil {
81
+ return nil, fmt.Errorf("invalid input object: %w", err)
82
+ }
83
+ msgs := parseResponseItem(obj, defaultRole)
84
+ return msgs, nil
85
+ case '[':
86
+ var arr []interface{}
87
+ if err := json.Unmarshal(trimmed, &arr); err != nil {
88
+ return nil, fmt.Errorf("invalid input array: %w", err)
89
+ }
90
+ var result []models.Message
91
+ for _, item := range arr {
92
+ switch typed := item.(type) {
93
+ case map[string]interface{}:
94
+ result = append(result, parseResponseItem(typed, defaultRole)...)
95
+ case string:
96
+ if strings.TrimSpace(typed) != "" {
97
+ result = append(result, models.Message{Role: defaultRole, Content: typed})
98
+ }
99
+ }
100
+ }
101
+ return result, nil
102
+ default:
103
+ return nil, fmt.Errorf("unsupported input format")
104
+ }
105
+ }
106
+
107
+ func parseResponseItem(item map[string]interface{}, defaultRole string) []models.Message {
108
+ itemType := strings.TrimSpace(asString(item["type"]))
109
+ role := strings.TrimSpace(asString(item["role"]))
110
+
111
+ if itemType == "" && role != "" {
112
+ itemType = "message"
113
+ }
114
+
115
+ switch itemType {
116
+ case "message":
117
+ if role == "" {
118
+ role = defaultRole
119
+ }
120
+ role = mapRole(role)
121
+ content := extractContentText(item["content"])
122
+ if strings.TrimSpace(content) == "" {
123
+ return nil
124
+ }
125
+ return []models.Message{{Role: role, Content: content}}
126
+ case "function_call":
127
+ callID := firstNonEmpty(asString(item["call_id"]), asString(item["id"]))
128
+ name := strings.TrimSpace(asString(item["name"]))
129
+ args := strings.TrimSpace(stringifyJSON(item["arguments"]))
130
+ if args == "" {
131
+ args = "{}"
132
+ }
133
+ toolCall := models.ToolCall{
134
+ ID: callID,
135
+ Type: "function",
136
+ Function: models.FunctionCall{
137
+ Name: name,
138
+ Arguments: args,
139
+ },
140
+ }
141
+ return []models.Message{{Role: "assistant", ToolCalls: []models.ToolCall{toolCall}}}
142
+ case "tool_call":
143
+ callID := firstNonEmpty(asString(item["call_id"]), asString(item["id"]))
144
+ name := strings.TrimSpace(asString(item["tool_name"]))
145
+ if name == "" {
146
+ name = strings.TrimSpace(asString(item["name"]))
147
+ }
148
+ if name == "" {
149
+ return nil
150
+ }
151
+ args := strings.TrimSpace(stringifyJSON(item["arguments"]))
152
+ if args == "" {
153
+ if action, ok := item["action"]; ok {
154
+ args = wrapJSONArg("action", action)
155
+ } else if input := strings.TrimSpace(asString(item["input"])); input != "" {
156
+ args = wrapInputAsJSON(input)
157
+ } else {
158
+ args = "{}"
159
+ }
160
+ }
161
+ toolCall := models.ToolCall{
162
+ ID: callID,
163
+ Type: "function",
164
+ Function: models.FunctionCall{
165
+ Name: name,
166
+ Arguments: args,
167
+ },
168
+ }
169
+ return []models.Message{{Role: "assistant", ToolCalls: []models.ToolCall{toolCall}}}
170
+ case "custom_tool_call":
171
+ callID := firstNonEmpty(asString(item["call_id"]), asString(item["id"]))
172
+ name := strings.TrimSpace(asString(item["name"]))
173
+ input := asString(item["input"])
174
+ args := wrapInputAsJSON(input)
175
+ toolCall := models.ToolCall{
176
+ ID: callID,
177
+ Type: "function",
178
+ Function: models.FunctionCall{
179
+ Name: name,
180
+ Arguments: args,
181
+ },
182
+ }
183
+ return []models.Message{{Role: "assistant", ToolCalls: []models.ToolCall{toolCall}}}
184
+ case "apply_patch_call":
185
+ callID := firstNonEmpty(asString(item["call_id"]), asString(item["id"]))
186
+ op := item["operation"]
187
+ args := wrapJSONArg("operation", op)
188
+ toolCall := models.ToolCall{
189
+ ID: callID,
190
+ Type: "function",
191
+ Function: models.FunctionCall{
192
+ Name: "apply_patch",
193
+ Arguments: args,
194
+ },
195
+ }
196
+ return []models.Message{{Role: "assistant", ToolCalls: []models.ToolCall{toolCall}}}
197
+ case "shell_call":
198
+ callID := firstNonEmpty(asString(item["call_id"]), asString(item["id"]))
199
+ action := item["action"]
200
+ args := wrapJSONArg("action", action)
201
+ toolCall := models.ToolCall{
202
+ ID: callID,
203
+ Type: "function",
204
+ Function: models.FunctionCall{
205
+ Name: "shell",
206
+ Arguments: args,
207
+ },
208
+ }
209
+ return []models.Message{{Role: "assistant", ToolCalls: []models.ToolCall{toolCall}}}
210
+ case "local_shell_call":
211
+ callID := firstNonEmpty(asString(item["call_id"]), asString(item["id"]))
212
+ action := item["action"]
213
+ args := ""
214
+ if action != nil {
215
+ args = wrapJSONArg("action", action)
216
+ }
217
+ if args == "" {
218
+ args = strings.TrimSpace(stringifyJSON(item["arguments"]))
219
+ }
220
+ if args == "" {
221
+ args = "{}"
222
+ }
223
+ toolCall := models.ToolCall{
224
+ ID: callID,
225
+ Type: "function",
226
+ Function: models.FunctionCall{
227
+ Name: "local_shell",
228
+ Arguments: args,
229
+ },
230
+ }
231
+ return []models.Message{{Role: "assistant", ToolCalls: []models.ToolCall{toolCall}}}
232
+ case "function_call_output":
233
+ callID := asString(item["call_id"])
234
+ output := normalizeToolOutput(item["output"])
235
+ if strings.TrimSpace(output) == "" {
236
+ return nil
237
+ }
238
+ return []models.Message{{Role: "tool", Content: output, ToolCallID: callID}}
239
+ case "apply_patch_call_output":
240
+ callID := asString(item["call_id"])
241
+ output := normalizeToolOutput(item["output"])
242
+ if strings.TrimSpace(output) == "" {
243
+ return nil
244
+ }
245
+ return []models.Message{{Role: "tool", Content: output, ToolCallID: callID, Name: "apply_patch"}}
246
+ case "shell_call_output":
247
+ callID := asString(item["call_id"])
248
+ output := normalizeToolOutput(item["output"])
249
+ if strings.TrimSpace(output) == "" {
250
+ return nil
251
+ }
252
+ return []models.Message{{Role: "tool", Content: output, ToolCallID: callID, Name: "shell"}}
253
+ case "local_shell_call_output":
254
+ callID := asString(item["call_id"])
255
+ output := normalizeToolOutput(item["output"])
256
+ if strings.TrimSpace(output) == "" {
257
+ return nil
258
+ }
259
+ return []models.Message{{Role: "tool", Content: output, ToolCallID: callID, Name: "local_shell"}}
260
+ case "tool_call_output":
261
+ callID := asString(item["call_id"])
262
+ output := normalizeToolOutput(item["output"])
263
+ if strings.TrimSpace(output) == "" {
264
+ return nil
265
+ }
266
+ name := strings.TrimSpace(asString(item["tool_name"]))
267
+ if name == "" {
268
+ name = strings.TrimSpace(asString(item["name"]))
269
+ }
270
+ return []models.Message{{Role: "tool", Content: output, ToolCallID: callID, Name: name}}
271
+ default:
272
+ return nil
273
+ }
274
+ }
275
+
276
+ func convertResponseTools(rawTools []map[string]interface{}) ([]models.Tool, *ResponseToolAdapter, error) {
277
+ adapter := &ResponseToolAdapter{ToolTypesByName: make(map[string]string)}
278
+ tools := make([]models.Tool, 0, len(rawTools))
279
+
280
+ for _, raw := range rawTools {
281
+ toolType := strings.TrimSpace(asString(raw["type"]))
282
+ if toolType == "" {
283
+ continue
284
+ }
285
+
286
+ switch toolType {
287
+ case "function":
288
+ name := strings.TrimSpace(asString(raw["name"]))
289
+ if name == "" {
290
+ return nil, nil, fmt.Errorf("function tool name is required")
291
+ }
292
+ desc := strings.TrimSpace(asString(raw["description"]))
293
+ params, _ := raw["parameters"].(map[string]interface{})
294
+ tools = append(tools, models.Tool{
295
+ Type: "function",
296
+ Function: models.FunctionDefinition{
297
+ Name: name,
298
+ Description: desc,
299
+ Parameters: params,
300
+ },
301
+ })
302
+ case "apply_patch":
303
+ tools = append(tools, applyPatchToolDefinition())
304
+ adapter.ToolTypesByName["apply_patch"] = "apply_patch"
305
+ case "shell":
306
+ tools = append(tools, shellToolDefinition())
307
+ adapter.ToolTypesByName["shell"] = "shell"
308
+ if env, ok := raw["environment"]; ok {
309
+ adapter.ShellEnvironment = env
310
+ }
311
+ case "local_shell":
312
+ tools = append(tools, localShellToolDefinition())
313
+ adapter.ToolTypesByName["local_shell"] = "local_shell"
314
+ default:
315
+ // Fallback: expose built-in tools as custom function tools for compatibility.
316
+ name := toolType
317
+ desc := strings.TrimSpace(asString(raw["description"]))
318
+ if desc == "" {
319
+ desc = fmt.Sprintf("Built-in tool: %s (proxied).", toolType)
320
+ }
321
+ tools = append(tools, models.Tool{
322
+ Type: "function",
323
+ Function: models.FunctionDefinition{
324
+ Name: name,
325
+ Description: desc,
326
+ Parameters: map[string]interface{}{
327
+ "type": "object",
328
+ "additionalProperties": true,
329
+ },
330
+ },
331
+ })
332
+ }
333
+ }
334
+
335
+ return tools, adapter, nil
336
+ }
337
+
338
+ func applyPatchToolDefinition() models.Tool {
339
+ return models.Tool{
340
+ Type: "function",
341
+ Function: models.FunctionDefinition{
342
+ Name: "apply_patch",
343
+ Description: "Apply a patch to the local workspace. Return JSON with operation {type, path, diff}.",
344
+ Parameters: map[string]interface{}{
345
+ "type": "object",
346
+ "properties": map[string]interface{}{
347
+ "operation": map[string]interface{}{
348
+ "type": "object",
349
+ "properties": map[string]interface{}{
350
+ "type": map[string]interface{}{
351
+ "type": "string",
352
+ "enum": []string{"create_file", "update_file", "delete_file"},
353
+ },
354
+ "path": map[string]interface{}{
355
+ "type": "string",
356
+ },
357
+ "diff": map[string]interface{}{
358
+ "type": "string",
359
+ },
360
+ },
361
+ "required": []string{"type", "path"},
362
+ },
363
+ },
364
+ "required": []string{"operation"},
365
+ },
366
+ },
367
+ }
368
+ }
369
+
370
+ func shellToolDefinition() models.Tool {
371
+ return models.Tool{
372
+ Type: "function",
373
+ Function: models.FunctionDefinition{
374
+ Name: "shell",
375
+ Description: "Run shell commands locally. Return JSON with commands (array), timeout_ms, max_output_length.",
376
+ Parameters: map[string]interface{}{
377
+ "type": "object",
378
+ "properties": map[string]interface{}{
379
+ "commands": map[string]interface{}{
380
+ "type": "array",
381
+ "items": map[string]interface{}{"type": "string"},
382
+ },
383
+ "timeout_ms": map[string]interface{}{
384
+ "type": "integer",
385
+ },
386
+ "max_output_length": map[string]interface{}{
387
+ "type": "integer",
388
+ },
389
+ "action": map[string]interface{}{
390
+ "type": "object",
391
+ "properties": map[string]interface{}{
392
+ "commands": map[string]interface{}{
393
+ "type": "array",
394
+ "items": map[string]interface{}{"type": "string"},
395
+ },
396
+ "timeout_ms": map[string]interface{}{
397
+ "type": "integer",
398
+ },
399
+ "max_output_length": map[string]interface{}{
400
+ "type": "integer",
401
+ },
402
+ },
403
+ },
404
+ },
405
+ },
406
+ },
407
+ }
408
+ }
409
+
410
+ func localShellToolDefinition() models.Tool {
411
+ return models.Tool{
412
+ Type: "function",
413
+ Function: models.FunctionDefinition{
414
+ Name: "local_shell",
415
+ Description: "Run local shell commands. Return JSON with command, timeout_ms, working_directory, env, max_output_length.",
416
+ Parameters: map[string]interface{}{
417
+ "type": "object",
418
+ "properties": map[string]interface{}{
419
+ "command": map[string]interface{}{
420
+ "type": "string",
421
+ },
422
+ "commands": map[string]interface{}{
423
+ "type": "array",
424
+ "items": map[string]interface{}{"type": "string"},
425
+ },
426
+ "timeout_ms": map[string]interface{}{
427
+ "type": "integer",
428
+ },
429
+ "working_directory": map[string]interface{}{
430
+ "type": "string",
431
+ },
432
+ "env": map[string]interface{}{
433
+ "type": "object",
434
+ "additionalProperties": true,
435
+ },
436
+ "max_output_length": map[string]interface{}{
437
+ "type": "integer",
438
+ },
439
+ "action": map[string]interface{}{
440
+ "type": "object",
441
+ "properties": map[string]interface{}{
442
+ "command": map[string]interface{}{
443
+ "type": "string",
444
+ },
445
+ "commands": map[string]interface{}{
446
+ "type": "array",
447
+ "items": map[string]interface{}{"type": "string"},
448
+ },
449
+ "timeout_ms": map[string]interface{}{
450
+ "type": "integer",
451
+ },
452
+ "working_directory": map[string]interface{}{
453
+ "type": "string",
454
+ },
455
+ "env": map[string]interface{}{
456
+ "type": "object",
457
+ "additionalProperties": true,
458
+ },
459
+ "max_output_length": map[string]interface{}{
460
+ "type": "integer",
461
+ },
462
+ },
463
+ },
464
+ },
465
+ },
466
+ },
467
+ }
468
+ }
469
+
470
+ func convertResponseToolChoice(raw json.RawMessage) (json.RawMessage, error) {
471
+ trimmed := bytes.TrimSpace(raw)
472
+ if len(trimmed) == 0 || string(trimmed) == "null" {
473
+ return nil, nil
474
+ }
475
+
476
+ var choiceString string
477
+ if err := json.Unmarshal(trimmed, &choiceString); err == nil {
478
+ switch choiceString {
479
+ case "auto", "none", "required":
480
+ return trimmed, nil
481
+ default:
482
+ return nil, fmt.Errorf("unsupported tool_choice value %q", choiceString)
483
+ }
484
+ }
485
+
486
+ var choiceObj map[string]interface{}
487
+ if err := json.Unmarshal(trimmed, &choiceObj); err != nil {
488
+ return nil, fmt.Errorf("tool_choice must be a string or object")
489
+ }
490
+
491
+ choiceType := strings.TrimSpace(asString(choiceObj["type"]))
492
+ switch choiceType {
493
+ case "function", "custom":
494
+ name := strings.TrimSpace(asString(choiceObj["name"]))
495
+ if name == "" {
496
+ return nil, fmt.Errorf("tool_choice.name is required")
497
+ }
498
+ return json.Marshal(models.ToolChoiceObject{
499
+ Type: "function",
500
+ Function: &models.ToolChoiceFunction{
501
+ Name: name,
502
+ },
503
+ })
504
+ case "apply_patch":
505
+ return json.Marshal(models.ToolChoiceObject{
506
+ Type: "function",
507
+ Function: &models.ToolChoiceFunction{
508
+ Name: "apply_patch",
509
+ },
510
+ })
511
+ case "shell":
512
+ return json.Marshal(models.ToolChoiceObject{
513
+ Type: "function",
514
+ Function: &models.ToolChoiceFunction{
515
+ Name: "shell",
516
+ },
517
+ })
518
+ case "local_shell":
519
+ return json.Marshal(models.ToolChoiceObject{
520
+ Type: "function",
521
+ Function: &models.ToolChoiceFunction{
522
+ Name: "local_shell",
523
+ },
524
+ })
525
+ default:
526
+ return nil, fmt.Errorf("unsupported tool_choice type %q", choiceType)
527
+ }
528
+ }
529
+
530
+ func extractContentText(content interface{}) string {
531
+ switch v := content.(type) {
532
+ case string:
533
+ return v
534
+ case []interface{}:
535
+ var b strings.Builder
536
+ for _, part := range v {
537
+ if partMap, ok := part.(map[string]interface{}); ok {
538
+ partType := strings.TrimSpace(asString(partMap["type"]))
539
+ switch partType {
540
+ case "input_text", "output_text", "text":
541
+ b.WriteString(asString(partMap["text"]))
542
+ case "refusal":
543
+ b.WriteString(asString(partMap["refusal"]))
544
+ }
545
+ }
546
+ }
547
+ return b.String()
548
+ case map[string]interface{}:
549
+ partType := strings.TrimSpace(asString(v["type"]))
550
+ switch partType {
551
+ case "input_text", "output_text", "text":
552
+ return asString(v["text"])
553
+ case "refusal":
554
+ return asString(v["refusal"])
555
+ }
556
+ }
557
+
558
+ if data, err := json.Marshal(content); err == nil {
559
+ return string(data)
560
+ }
561
+ return ""
562
+ }
563
+
564
+ func normalizeToolOutput(output interface{}) string {
565
+ switch v := output.(type) {
566
+ case string:
567
+ return v
568
+ case []interface{}:
569
+ var b strings.Builder
570
+ for _, part := range v {
571
+ if partMap, ok := part.(map[string]interface{}); ok {
572
+ partType := strings.TrimSpace(asString(partMap["type"]))
573
+ switch partType {
574
+ case "output_text", "input_text", "text":
575
+ b.WriteString(asString(partMap["text"]))
576
+ case "refusal":
577
+ b.WriteString(asString(partMap["refusal"]))
578
+ default:
579
+ if data, err := json.Marshal(partMap); err == nil {
580
+ b.WriteString(string(data))
581
+ }
582
+ }
583
+ }
584
+ }
585
+ if b.Len() > 0 {
586
+ return b.String()
587
+ }
588
+ case map[string]interface{}:
589
+ if data, err := json.Marshal(v); err == nil {
590
+ return string(data)
591
+ }
592
+ }
593
+
594
+ if data, err := json.Marshal(output); err == nil {
595
+ return string(data)
596
+ }
597
+ return ""
598
+ }
599
+
600
+ func wrapInputAsJSON(input string) string {
601
+ payload := map[string]interface{}{
602
+ "input": input,
603
+ }
604
+ if data, err := json.Marshal(payload); err == nil {
605
+ return string(data)
606
+ }
607
+ return "{}"
608
+ }
609
+
610
+ func wrapJSONArg(key string, value interface{}) string {
611
+ payload := map[string]interface{}{
612
+ key: value,
613
+ }
614
+ if data, err := json.Marshal(payload); err == nil {
615
+ return string(data)
616
+ }
617
+ return "{}"
618
+ }
619
+
620
+ func stringifyJSON(value interface{}) string {
621
+ switch v := value.(type) {
622
+ case string:
623
+ return v
624
+ case json.RawMessage:
625
+ return string(v)
626
+ default:
627
+ if data, err := json.Marshal(v); err == nil {
628
+ return string(data)
629
+ }
630
+ }
631
+ return ""
632
+ }
633
+
634
+ func asString(value interface{}) string {
635
+ switch v := value.(type) {
636
+ case string:
637
+ return v
638
+ case json.Number:
639
+ return v.String()
640
+ }
641
+ return ""
642
+ }
643
+
644
+ func firstNonEmpty(values ...string) string {
645
+ for _, v := range values {
646
+ if strings.TrimSpace(v) != "" {
647
+ return v
648
+ }
649
+ }
650
+ return ""
651
+ }
652
+
653
+ func mapRole(role string) string {
654
+ role = strings.ToLower(strings.TrimSpace(role))
655
+ switch role {
656
+ case "developer":
657
+ return "system"
658
+ default:
659
+ if role == "" {
660
+ return "user"
661
+ }
662
+ return role
663
+ }
664
+ }
services/response_output.go ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "github.com/libaxuan/cursor2api-go/models"
5
+ "github.com/libaxuan/cursor2api-go/utils"
6
+ "encoding/json"
7
+ "strings"
8
+ )
9
+
10
+ // BuildResponseOutputFromMessage converts a chat completion message into response output items.
11
+ func BuildResponseOutputFromMessage(message models.Message, adapter *ResponseToolAdapter) ([]interface{}, string) {
12
+ output := make([]interface{}, 0, 1+len(message.ToolCalls))
13
+ var outputText string
14
+
15
+ text := message.GetStringContent()
16
+ if text != "" {
17
+ msgItem := buildResponseMessageItem(text)
18
+ output = append(output, msgItem)
19
+ outputText = text
20
+ }
21
+
22
+ for _, toolCall := range message.ToolCalls {
23
+ output = append(output, buildResponseToolCallItem(toolCall, adapter))
24
+ }
25
+
26
+ return output, outputText
27
+ }
28
+
29
+ func buildResponseMessageItem(text string) models.ResponseOutputMessage {
30
+ return models.ResponseOutputMessage{
31
+ ID: utils.GenerateResponseItemID("msg_"),
32
+ Type: "message",
33
+ Status: "completed",
34
+ Role: "assistant",
35
+ Content: []models.ResponseOutputTextContent{
36
+ {
37
+ Type: "output_text",
38
+ Text: text,
39
+ Annotations: []interface{}{},
40
+ },
41
+ },
42
+ }
43
+ }
44
+
45
+ func buildResponseToolCallItem(toolCall models.ToolCall, adapter *ResponseToolAdapter) interface{} {
46
+ name := strings.TrimSpace(toolCall.Function.Name)
47
+ if name == "" {
48
+ name = "tool"
49
+ }
50
+
51
+ if adapter == nil || adapter.ToolTypesByName == nil {
52
+ return buildFunctionCallItem(toolCall, name)
53
+ }
54
+
55
+ switch adapterType := adapter.ToolTypesByName[name]; adapterType {
56
+ case "apply_patch":
57
+ return buildApplyPatchCallItem(toolCall)
58
+ case "shell":
59
+ return buildShellCallItem(toolCall, adapter.ShellEnvironment)
60
+ case "local_shell":
61
+ return buildLocalShellCallItem(toolCall)
62
+ default:
63
+ return buildFunctionCallItem(toolCall, name)
64
+ }
65
+ }
66
+
67
+ func buildFunctionCallItem(toolCall models.ToolCall, name string) models.ResponseFunctionCall {
68
+ args := strings.TrimSpace(toolCall.Function.Arguments)
69
+ if args == "" {
70
+ args = "{}"
71
+ }
72
+ callID := toolCall.ID
73
+ if callID == "" {
74
+ callID = utils.GenerateResponseItemID("call_")
75
+ }
76
+ return models.ResponseFunctionCall{
77
+ ID: utils.GenerateResponseItemID("fc_"),
78
+ Type: "function_call",
79
+ Status: "completed",
80
+ CallID: callID,
81
+ Name: name,
82
+ Arguments: args,
83
+ }
84
+ }
85
+
86
+ func buildApplyPatchCallItem(toolCall models.ToolCall) models.ResponseApplyPatchCall {
87
+ op := parseOperation(toolCall.Function.Arguments)
88
+ callID := toolCall.ID
89
+ if callID == "" {
90
+ callID = utils.GenerateResponseItemID("call_")
91
+ }
92
+ return models.ResponseApplyPatchCall{
93
+ ID: utils.GenerateResponseItemID("apc_"),
94
+ Type: "apply_patch_call",
95
+ Status: "completed",
96
+ CallID: callID,
97
+ Operation: op,
98
+ }
99
+ }
100
+
101
+ func buildShellCallItem(toolCall models.ToolCall, environment interface{}) models.ResponseShellCall {
102
+ action := parseShellAction(toolCall.Function.Arguments)
103
+ callID := toolCall.ID
104
+ if callID == "" {
105
+ callID = utils.GenerateResponseItemID("call_")
106
+ }
107
+ return models.ResponseShellCall{
108
+ ID: utils.GenerateResponseItemID("sc_"),
109
+ Type: "shell_call",
110
+ Status: "completed",
111
+ CallID: callID,
112
+ Action: action,
113
+ Environment: environment,
114
+ }
115
+ }
116
+
117
+ func buildLocalShellCallItem(toolCall models.ToolCall) models.ResponseLocalShellCall {
118
+ action := parseLocalShellAction(toolCall.Function.Arguments)
119
+ callID := toolCall.ID
120
+ if callID == "" {
121
+ callID = utils.GenerateResponseItemID("call_")
122
+ }
123
+ return models.ResponseLocalShellCall{
124
+ ID: utils.GenerateResponseItemID("lsc_"),
125
+ Type: "local_shell_call",
126
+ Status: "completed",
127
+ CallID: callID,
128
+ Action: action,
129
+ }
130
+ }
131
+
132
+ func parseOperation(arguments string) map[string]interface{} {
133
+ args := strings.TrimSpace(arguments)
134
+ if args == "" {
135
+ return map[string]interface{}{}
136
+ }
137
+
138
+ var payload map[string]interface{}
139
+ if err := json.Unmarshal([]byte(args), &payload); err == nil {
140
+ if op, ok := payload["operation"].(map[string]interface{}); ok {
141
+ return op
142
+ }
143
+ if hasKeys(payload, "type", "path") {
144
+ return payload
145
+ }
146
+ return payload
147
+ }
148
+
149
+ return map[string]interface{}{
150
+ "diff": args,
151
+ }
152
+ }
153
+
154
+ func parseShellAction(arguments string) map[string]interface{} {
155
+ args := strings.TrimSpace(arguments)
156
+ if args == "" {
157
+ return map[string]interface{}{"commands": []string{}}
158
+ }
159
+
160
+ var payload map[string]interface{}
161
+ if err := json.Unmarshal([]byte(args), &payload); err == nil {
162
+ if action, ok := payload["action"].(map[string]interface{}); ok {
163
+ return normalizeShellAction(action)
164
+ }
165
+ return normalizeShellAction(payload)
166
+ }
167
+
168
+ return map[string]interface{}{
169
+ "commands": []string{args},
170
+ }
171
+ }
172
+
173
+ func parseLocalShellAction(arguments string) map[string]interface{} {
174
+ args := strings.TrimSpace(arguments)
175
+ if args == "" {
176
+ return map[string]interface{}{"command": ""}
177
+ }
178
+
179
+ var payload map[string]interface{}
180
+ if err := json.Unmarshal([]byte(args), &payload); err == nil {
181
+ if action, ok := payload["action"].(map[string]interface{}); ok {
182
+ return normalizeLocalShellAction(action)
183
+ }
184
+ return normalizeLocalShellAction(payload)
185
+ }
186
+
187
+ return map[string]interface{}{
188
+ "command": args,
189
+ }
190
+ }
191
+
192
+ func normalizeShellAction(payload map[string]interface{}) map[string]interface{} {
193
+ action := map[string]interface{}{}
194
+
195
+ if cmds, ok := payload["commands"]; ok {
196
+ action["commands"] = normalizeStringSlice(cmds)
197
+ } else if cmd, ok := payload["command"]; ok {
198
+ action["commands"] = normalizeStringSlice(cmd)
199
+ }
200
+
201
+ if timeout, ok := payload["timeout_ms"]; ok {
202
+ action["timeout_ms"] = timeout
203
+ }
204
+ if maxOut, ok := payload["max_output_length"]; ok {
205
+ action["max_output_length"] = maxOut
206
+ }
207
+
208
+ if _, exists := action["commands"]; !exists {
209
+ action["commands"] = []string{}
210
+ }
211
+
212
+ return action
213
+ }
214
+
215
+ func normalizeLocalShellAction(payload map[string]interface{}) map[string]interface{} {
216
+ action := map[string]interface{}{}
217
+
218
+ if cmd, ok := payload["command"]; ok {
219
+ action["command"] = cmd
220
+ } else if cmds, ok := payload["commands"]; ok {
221
+ action["command"] = cmds
222
+ }
223
+
224
+ if timeout, ok := payload["timeout_ms"]; ok {
225
+ action["timeout_ms"] = timeout
226
+ }
227
+ if wd, ok := payload["working_directory"]; ok {
228
+ action["working_directory"] = wd
229
+ }
230
+ if env, ok := payload["env"]; ok {
231
+ action["env"] = env
232
+ }
233
+ if maxOut, ok := payload["max_output_length"]; ok {
234
+ action["max_output_length"] = maxOut
235
+ }
236
+
237
+ if _, exists := action["command"]; !exists {
238
+ action["command"] = ""
239
+ }
240
+
241
+ return action
242
+ }
243
+
244
+ func normalizeStringSlice(value interface{}) []string {
245
+ switch v := value.(type) {
246
+ case []interface{}:
247
+ result := make([]string, 0, len(v))
248
+ for _, item := range v {
249
+ if text, ok := item.(string); ok {
250
+ result = append(result, text)
251
+ }
252
+ }
253
+ return result
254
+ case []string:
255
+ return v
256
+ case string:
257
+ return []string{v}
258
+ default:
259
+ return []string{}
260
+ }
261
+ }
262
+
263
+ func hasKeys(payload map[string]interface{}, keys ...string) bool {
264
+ for _, key := range keys {
265
+ if _, ok := payload[key]; !ok {
266
+ return false
267
+ }
268
+ }
269
+ return true
270
+ }
services/response_response.go ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "bytes"
5
+ "github.com/libaxuan/cursor2api-go/models"
6
+ "github.com/libaxuan/cursor2api-go/utils"
7
+ "encoding/json"
8
+ "time"
9
+ )
10
+
11
+ // BuildResponseUsage converts chat-completions usage into responses usage.
12
+ func BuildResponseUsage(usage models.Usage) *models.ResponseUsage {
13
+ if usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.TotalTokens == 0 {
14
+ return nil
15
+ }
16
+
17
+ return &models.ResponseUsage{
18
+ InputTokens: usage.PromptTokens,
19
+ OutputTokens: usage.CompletionTokens,
20
+ TotalTokens: usage.TotalTokens,
21
+ OutputTokensDetails: &models.ResponseOutputTokensDetails{
22
+ ReasoningTokens: 0,
23
+ },
24
+ }
25
+ }
26
+
27
+ // NewResponseFromRequest builds a Responses API response with shared request metadata.
28
+ func NewResponseFromRequest(req *models.ResponseRequest, id string, createdAt int64, status string, output []interface{}, outputText string, usage *models.ResponseUsage, completedAt *int64) models.Response {
29
+ if output == nil {
30
+ output = []interface{}{}
31
+ }
32
+
33
+ resp := models.Response{
34
+ ID: id,
35
+ Object: "response",
36
+ CreatedAt: createdAt,
37
+ Status: status,
38
+ CompletedAt: completedAt,
39
+ Background: req.Background,
40
+ Instructions: decodeRaw(req.Instructions),
41
+ MaxOutputTokens: req.MaxOutputTokens,
42
+ MaxToolCalls: req.MaxToolCalls,
43
+ Model: req.Model,
44
+ Output: output,
45
+ OutputText: outputText,
46
+ ParallelToolCalls: resolveParallelToolCalls(req.ParallelToolCalls),
47
+ PreviousResponseID: req.PreviousResponseID,
48
+ Reasoning: decodeReasoning(req.Reasoning),
49
+ Store: req.Store,
50
+ Temperature: req.Temperature,
51
+ Text: decodeRaw(req.Text),
52
+ ToolChoice: decodeRaw(req.ToolChoice),
53
+ Tools: req.Tools,
54
+ TopP: req.TopP,
55
+ Truncation: req.Truncation,
56
+ Usage: usage,
57
+ User: req.User,
58
+ Metadata: req.Metadata,
59
+ }
60
+
61
+ return resp
62
+ }
63
+
64
+ // BuildCompletedResponse builds a completed response with a generated ID.
65
+ func BuildCompletedResponse(req *models.ResponseRequest, output []interface{}, outputText string, usage *models.ResponseUsage) models.Response {
66
+ now := time.Now().Unix()
67
+ complete := now
68
+ return NewResponseFromRequest(req, utils.GenerateResponseID(), now, "completed", output, outputText, usage, &complete)
69
+ }
70
+
71
+ func resolveParallelToolCalls(raw *bool) bool {
72
+ if raw == nil {
73
+ return false
74
+ }
75
+ return *raw
76
+ }
77
+
78
+ func decodeRaw(raw json.RawMessage) interface{} {
79
+ trimmed := bytes.TrimSpace(raw)
80
+ if len(trimmed) == 0 || string(trimmed) == "null" {
81
+ return nil
82
+ }
83
+ var val interface{}
84
+ if err := json.Unmarshal(trimmed, &val); err != nil {
85
+ return nil
86
+ }
87
+ return val
88
+ }
89
+
90
+ func decodeReasoning(raw json.RawMessage) *models.ResponseReasoning {
91
+ trimmed := bytes.TrimSpace(raw)
92
+ if len(trimmed) == 0 || string(trimmed) == "null" {
93
+ return nil
94
+ }
95
+ var val map[string]interface{}
96
+ if err := json.Unmarshal(trimmed, &val); err != nil {
97
+ return nil
98
+ }
99
+ reasoning := &models.ResponseReasoning{}
100
+ if effort, ok := val["effort"].(string); ok {
101
+ reasoning.Effort = &effort
102
+ }
103
+ if summary, ok := val["summary"]; ok {
104
+ reasoning.Summary = summary
105
+ }
106
+ return reasoning
107
+ }
services/response_store.go ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "github.com/libaxuan/cursor2api-go/models"
5
+ "sync"
6
+ "time"
7
+ )
8
+
9
+ const (
10
+ defaultResponseStoreTTL = 30 * time.Minute
11
+ defaultResponseStoreMaxEntries = 1000
12
+ )
13
+
14
+ type responseStoreEntry struct {
15
+ messages []models.Message
16
+ createdAt time.Time
17
+ }
18
+
19
+ // ResponseStore keeps recent Responses API conversation state in memory.
20
+ // It is best-effort and resets on process restart.
21
+ type ResponseStore struct {
22
+ mu sync.RWMutex
23
+ ttl time.Duration
24
+ maxEntries int
25
+ entries map[string]responseStoreEntry
26
+ order []string
27
+ }
28
+
29
+ func NewResponseStore(ttl time.Duration, maxEntries int) *ResponseStore {
30
+ if ttl <= 0 {
31
+ ttl = defaultResponseStoreTTL
32
+ }
33
+ if maxEntries <= 0 {
34
+ maxEntries = defaultResponseStoreMaxEntries
35
+ }
36
+
37
+ return &ResponseStore{
38
+ ttl: ttl,
39
+ maxEntries: maxEntries,
40
+ entries: make(map[string]responseStoreEntry),
41
+ order: make([]string, 0, maxEntries),
42
+ }
43
+ }
44
+
45
+ // Get returns stored messages for a response id if present and not expired.
46
+ func (s *ResponseStore) Get(id string) ([]models.Message, bool) {
47
+ if id == "" {
48
+ return nil, false
49
+ }
50
+
51
+ now := time.Now()
52
+ s.mu.Lock()
53
+ defer s.mu.Unlock()
54
+
55
+ s.cleanupLocked(now)
56
+
57
+ entry, ok := s.entries[id]
58
+ if !ok {
59
+ return nil, false
60
+ }
61
+
62
+ return cloneMessages(entry.messages), true
63
+ }
64
+
65
+ // Put stores messages for a response id, evicting older entries if needed.
66
+ func (s *ResponseStore) Put(id string, messages []models.Message) {
67
+ if id == "" || len(messages) == 0 {
68
+ return
69
+ }
70
+
71
+ now := time.Now()
72
+ s.mu.Lock()
73
+ defer s.mu.Unlock()
74
+
75
+ s.cleanupLocked(now)
76
+
77
+ if _, exists := s.entries[id]; !exists {
78
+ s.order = append(s.order, id)
79
+ }
80
+ s.entries[id] = responseStoreEntry{
81
+ messages: cloneMessages(messages),
82
+ createdAt: now,
83
+ }
84
+
85
+ for len(s.entries) > s.maxEntries {
86
+ s.evictOldestLocked()
87
+ }
88
+ }
89
+
90
+ func (s *ResponseStore) cleanupLocked(now time.Time) {
91
+ if s.ttl <= 0 {
92
+ return
93
+ }
94
+
95
+ for id, entry := range s.entries {
96
+ if now.Sub(entry.createdAt) > s.ttl {
97
+ delete(s.entries, id)
98
+ s.removeFromOrderLocked(id)
99
+ }
100
+ }
101
+ }
102
+
103
+ func (s *ResponseStore) evictOldestLocked() {
104
+ if len(s.order) == 0 {
105
+ return
106
+ }
107
+ oldest := s.order[0]
108
+ s.order = s.order[1:]
109
+ delete(s.entries, oldest)
110
+ }
111
+
112
+ func (s *ResponseStore) removeFromOrderLocked(id string) {
113
+ for i, key := range s.order {
114
+ if key == id {
115
+ s.order = append(s.order[:i], s.order[i+1:]...)
116
+ return
117
+ }
118
+ }
119
+ }
120
+
121
+ func cloneMessages(src []models.Message) []models.Message {
122
+ if len(src) == 0 {
123
+ return nil
124
+ }
125
+ dst := make([]models.Message, len(src))
126
+ copy(dst, src)
127
+ return dst
128
+ }
services/response_stream.go ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "github.com/libaxuan/cursor2api-go/models"
5
+ "github.com/libaxuan/cursor2api-go/utils"
6
+ "encoding/json"
7
+ "strings"
8
+ "time"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ "github.com/sirupsen/logrus"
12
+ )
13
+
14
+ type responseMessageState struct {
15
+ id string
16
+ outputIndex int
17
+ contentIdx int
18
+ text strings.Builder
19
+ }
20
+
21
+ // StreamResponse streams a Responses API SSE stream from the Cursor generator.
22
+ func StreamResponse(c *gin.Context, chatGenerator <-chan interface{}, req *models.ResponseRequest, adapter *ResponseToolAdapter, responseID string) {
23
+ c.Header("Content-Type", "text/event-stream")
24
+ c.Header("Cache-Control", "no-cache")
25
+ c.Header("Connection", "keep-alive")
26
+ c.Header("Access-Control-Allow-Origin", "*")
27
+
28
+ if strings.TrimSpace(responseID) == "" {
29
+ responseID = utils.GenerateResponseID()
30
+ }
31
+ createdAt := time.Now().Unix()
32
+ sequence := 0
33
+
34
+ outputItems := make([]interface{}, 0, 4)
35
+ outputText := strings.Builder{}
36
+ nextOutputIndex := 0
37
+ var usage *models.ResponseUsage
38
+ var currentMsg *responseMessageState
39
+
40
+ sendEvent := func(event string, payload map[string]interface{}) {
41
+ sequence++
42
+ payload["type"] = event
43
+ payload["sequence_number"] = sequence
44
+ if data, err := json.Marshal(payload); err == nil {
45
+ if err := utils.WriteSSEEvent(c.Writer, event, string(data)); err != nil {
46
+ logrus.WithError(err).Warn("failed to write response SSE event")
47
+ }
48
+ }
49
+ }
50
+
51
+ base := NewResponseFromRequest(req, responseID, createdAt, "in_progress", []interface{}{}, "", nil, nil)
52
+ sendEvent("response.created", map[string]interface{}{"response": base})
53
+ sendEvent("response.in_progress", map[string]interface{}{"response": base})
54
+
55
+ openMessage := func() {
56
+ if currentMsg != nil {
57
+ return
58
+ }
59
+ msgID := utils.GenerateResponseItemID("msg_")
60
+ idx := nextOutputIndex
61
+ nextOutputIndex++
62
+ currentMsg = &responseMessageState{
63
+ id: msgID,
64
+ outputIndex: idx,
65
+ contentIdx: 0,
66
+ }
67
+
68
+ item := models.ResponseOutputMessage{
69
+ ID: msgID,
70
+ Type: "message",
71
+ Status: "in_progress",
72
+ Role: "assistant",
73
+ Content: []models.ResponseOutputTextContent{},
74
+ }
75
+ sendEvent("response.output_item.added", map[string]interface{}{
76
+ "output_index": idx,
77
+ "item": item,
78
+ })
79
+
80
+ part := models.ResponseOutputTextContent{
81
+ Type: "output_text",
82
+ Text: "",
83
+ Annotations: []interface{}{},
84
+ }
85
+ sendEvent("response.content_part.added", map[string]interface{}{
86
+ "output_index": idx,
87
+ "content_index": currentMsg.contentIdx,
88
+ "part": part,
89
+ })
90
+ }
91
+
92
+ closeMessage := func() {
93
+ if currentMsg == nil {
94
+ return
95
+ }
96
+ text := currentMsg.text.String()
97
+ part := models.ResponseOutputTextContent{
98
+ Type: "output_text",
99
+ Text: text,
100
+ Annotations: []interface{}{},
101
+ }
102
+
103
+ sendEvent("response.output_text.done", map[string]interface{}{
104
+ "output_index": currentMsg.outputIndex,
105
+ "content_index": currentMsg.contentIdx,
106
+ "text": text,
107
+ })
108
+ sendEvent("response.content_part.done", map[string]interface{}{
109
+ "output_index": currentMsg.outputIndex,
110
+ "content_index": currentMsg.contentIdx,
111
+ "part": part,
112
+ })
113
+
114
+ item := models.ResponseOutputMessage{
115
+ ID: currentMsg.id,
116
+ Type: "message",
117
+ Status: "completed",
118
+ Role: "assistant",
119
+ Content: []models.ResponseOutputTextContent{part},
120
+ }
121
+
122
+ sendEvent("response.output_item.done", map[string]interface{}{
123
+ "output_index": currentMsg.outputIndex,
124
+ "item": item,
125
+ })
126
+
127
+ outputItems = append(outputItems, item)
128
+ currentMsg = nil
129
+ }
130
+
131
+ emitToolCall := func(toolCall models.ToolCall) {
132
+ closeMessage()
133
+ item := buildResponseToolCallItem(toolCall, adapter)
134
+ idx := nextOutputIndex
135
+ nextOutputIndex++
136
+
137
+ added := withOutputItemStatus(item, "in_progress")
138
+ sendEvent("response.output_item.added", map[string]interface{}{
139
+ "output_index": idx,
140
+ "item": added,
141
+ })
142
+
143
+ if callInfo, ok := getFunctionCallInfo(item); ok {
144
+ sendEvent("response.function_call_arguments.delta", map[string]interface{}{
145
+ "output_index": idx,
146
+ "item_id": callInfo.ID,
147
+ "delta": callInfo.Arguments,
148
+ })
149
+ sendEvent("response.function_call_arguments.done", map[string]interface{}{
150
+ "output_index": idx,
151
+ "item_id": callInfo.ID,
152
+ "arguments": callInfo.Arguments,
153
+ "name": callInfo.Name,
154
+ })
155
+ }
156
+
157
+ done := withOutputItemStatus(item, "completed")
158
+ sendEvent("response.output_item.done", map[string]interface{}{
159
+ "output_index": idx,
160
+ "item": done,
161
+ })
162
+
163
+ outputItems = append(outputItems, done)
164
+ }
165
+
166
+ ctx := c.Request.Context()
167
+ for {
168
+ select {
169
+ case <-ctx.Done():
170
+ logrus.Debug("client disconnected during responses streaming")
171
+ return
172
+ case data, ok := <-chatGenerator:
173
+ if !ok {
174
+ closeMessage()
175
+ completedAt := time.Now().Unix()
176
+ final := NewResponseFromRequest(req, responseID, createdAt, "completed", outputItems, outputText.String(), usage, &completedAt)
177
+ sendEvent("response.completed", map[string]interface{}{"response": final})
178
+ return
179
+ }
180
+
181
+ switch v := data.(type) {
182
+ case models.AssistantEvent:
183
+ switch v.Kind {
184
+ case models.AssistantEventText:
185
+ if v.Text != "" {
186
+ openMessage()
187
+ currentMsg.text.WriteString(v.Text)
188
+ outputText.WriteString(v.Text)
189
+ sendEvent("response.output_text.delta", map[string]interface{}{
190
+ "output_index": currentMsg.outputIndex,
191
+ "content_index": currentMsg.contentIdx,
192
+ "delta": v.Text,
193
+ })
194
+ }
195
+ case models.AssistantEventToolCall:
196
+ if v.ToolCall != nil {
197
+ emitToolCall(*v.ToolCall)
198
+ }
199
+ }
200
+ case string:
201
+ if v != "" {
202
+ openMessage()
203
+ currentMsg.text.WriteString(v)
204
+ outputText.WriteString(v)
205
+ sendEvent("response.output_text.delta", map[string]interface{}{
206
+ "output_index": currentMsg.outputIndex,
207
+ "content_index": currentMsg.contentIdx,
208
+ "delta": v,
209
+ })
210
+ }
211
+ case models.Usage:
212
+ usage = BuildResponseUsage(v)
213
+ case error:
214
+ logrus.WithError(v).Error("responses stream generator error")
215
+ return
216
+ default:
217
+ continue
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ type functionCallInfo struct {
224
+ ID string
225
+ Name string
226
+ Arguments string
227
+ }
228
+
229
+ func getFunctionCallInfo(item interface{}) (*functionCallInfo, bool) {
230
+ switch v := item.(type) {
231
+ case models.ResponseFunctionCall:
232
+ return &functionCallInfo{ID: v.ID, Name: v.Name, Arguments: v.Arguments}, true
233
+ default:
234
+ return nil, false
235
+ }
236
+ }
237
+
238
+ func withOutputItemStatus(item interface{}, status string) interface{} {
239
+ switch v := item.(type) {
240
+ case models.ResponseOutputMessage:
241
+ v.Status = status
242
+ return v
243
+ case models.ResponseFunctionCall:
244
+ v.Status = status
245
+ return v
246
+ case models.ResponseApplyPatchCall:
247
+ v.Status = status
248
+ return v
249
+ case models.ResponseShellCall:
250
+ v.Status = status
251
+ return v
252
+ case models.ResponseLocalShellCall:
253
+ v.Status = status
254
+ return v
255
+ default:
256
+ return item
257
+ }
258
+ }
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,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-post">POST</div>
445
+ <strong>/v1/responses</strong>
446
+ <p>Responses API 兼容端点,支持输入项、工具调用与流式输出</p>
447
+ <div class="code-block" style="margin-top: 15px;">
448
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
449
+ <code>curl -X POST http://localhost:8002/v1/responses \
450
+ -H "Content-Type: application/json" \
451
+ -H "Authorization: Bearer 0000" \
452
+ -d '{
453
+ "model": "claude-sonnet-4.6",
454
+ "input": "你好",
455
+ "stream": false
456
+ }'</code>
457
+ </div>
458
+ <p style="margin-top: 10px; color: #555;">
459
+ 说明:当响应包含工具调用时,会返回 <code>output</code> 数组中的 <code>function_call</code>、
460
+ <code>apply_patch_call</code>、<code>shell_call</code> 或 <code>local_shell_call</code> 等输出项。
461
+ </p>
462
+ </div>
463
+
464
+ <div class="endpoint-card">
465
+ <div class="method-badge method-get">GET</div>
466
+ <strong>/health</strong>
467
+ <p>健康检查端点</p>
468
+ <div class="code-block" style="margin-top: 15px;">
469
+ <button class="copy-btn" onclick="copyCode(this)">复制</button>
470
+ <code>curl http://localhost:8002/health</code>
471
+ </div>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <div class="footer">
477
+ <p>©2025 Cursor2API | 强大的AI模型API代理服务</p>
478
+ </div>
479
+ </div>
480
+
481
+ <script>
482
+ // 切换标签页
483
+ function showTab(tabName) {
484
+ const tabContents = document.getElementsByClassName('tab-content');
485
+ for (let i = 0; i < tabContents.length; i++) {
486
+ tabContents[i].classList.remove('active');
487
+ }
488
+
489
+ const tabBtns = document.getElementsByClassName('nav-tab');
490
+ for (let i = 0; i < tabBtns.length; i++) {
491
+ tabBtns[i].classList.remove('active');
492
+ }
493
+
494
+ document.getElementById(tabName).classList.add('active');
495
+ event.target.classList.add('active');
496
+ }
497
+
498
+ // 选择模型
499
+ function selectModel(modelId) {
500
+ const prevSelected = document.querySelector('.model-card.selected');
501
+ if (prevSelected) {
502
+ prevSelected.classList.remove('selected');
503
+ }
504
+
505
+ const modelCards = document.querySelectorAll('.model-card');
506
+ modelCards.forEach(card => {
507
+ if (card.onclick.toString().includes(modelId)) {
508
+ card.classList.add('selected');
509
+ }
510
+ });
511
+
512
+ const exampleCode = `curl -X POST http://localhost:8002/v1/chat/completions \\
513
+ -H "Content-Type: application/json" \\
514
+ -H "Authorization: Bearer 0000" \\
515
+ -d '{
516
+ "model": "${modelId}",
517
+ "messages": [
518
+ {
519
+ "role": "user",
520
+ "content": "你好,请用中文回答问题"
521
+ }
522
+ ],
523
+ "stream": false
524
+ }'`;
525
+
526
+ document.getElementById('modelExample').textContent = exampleCode;
527
+ document.getElementById('selectedModelInfo').style.display = 'block';
528
+ }
529
+
530
+ // 复制代码功能
531
+ function copyCode(btn) {
532
+ const codeBlock = btn.nextElementSibling;
533
+ const code = codeBlock.textContent;
534
+
535
+ navigator.clipboard.writeText(code).then(() => {
536
+ const originalText = btn.textContent;
537
+ btn.textContent = '已复制!';
538
+ btn.style.background = '#28a745';
539
+ setTimeout(() => {
540
+ btn.textContent = originalText;
541
+ btn.style.background = '#007bff';
542
+ }, 2000);
543
+ }).catch(() => {
544
+ alert('复制失败,请手动复制代码');
545
+ });
546
+ }
547
+ </script>
548
+ </body>
549
+
550
+ </html>
utils/cursor_protocol.go ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package utils
2
+
3
+ import (
4
+ "bytes"
5
+ "github.com/libaxuan/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
+ "github.com/libaxuan/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,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "github.com/libaxuan/cursor2api-go/middleware"
28
+ "github.com/libaxuan/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
+ // GenerateResponseID 生成 Responses API 响应ID
72
+ func GenerateResponseID() string {
73
+ return "resp_" + GenerateRandomString(24)
74
+ }
75
+
76
+ // GenerateResponseItemID 生成 Responses API 输出项ID
77
+ func GenerateResponseItemID(prefix string) string {
78
+ if prefix == "" {
79
+ prefix = "item_"
80
+ }
81
+ return prefix + GenerateRandomString(24)
82
+ }
83
+
84
+ // ParseSSELine 解析SSE数据行
85
+ func ParseSSELine(line string) string {
86
+ line = strings.TrimSpace(line)
87
+ if strings.HasPrefix(line, "data: ") {
88
+ return strings.TrimSpace(line[6:]) // 去掉 'data: ' 前缀并去除前导空格
89
+ }
90
+ return ""
91
+ }
92
+
93
+ // WriteSSEEvent 写入SSE事件
94
+ func WriteSSEEvent(w http.ResponseWriter, event, data string) error {
95
+ if event != "" {
96
+ if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
97
+ return err
98
+ }
99
+ }
100
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
101
+ return err
102
+ }
103
+
104
+ // 刷新缓冲区
105
+ if flusher, ok := w.(http.Flusher); ok {
106
+ flusher.Flush()
107
+ }
108
+
109
+ return nil
110
+ }
111
+
112
+ // StreamChatCompletion 处理流式聊天完成
113
+ // StreamChatCompletion 处理流式聊天完成
114
+ func StreamChatCompletion(c *gin.Context, chatGenerator <-chan interface{}, modelName string) {
115
+ // 设置SSE头
116
+ c.Header("Content-Type", "text/event-stream")
117
+ c.Header("Cache-Control", "no-cache")
118
+ c.Header("Connection", "keep-alive")
119
+ c.Header("Access-Control-Allow-Origin", "*")
120
+
121
+ // 生成响应ID
122
+ responseID := GenerateChatCompletionID()
123
+ started := false
124
+ toolCallIndex := 0
125
+
126
+ writeChunk := func(delta models.StreamDelta, finishReason *string) {
127
+ streamResp := models.NewChatCompletionStreamResponse(responseID, modelName, delta, finishReason)
128
+ if jsonData, err := json.Marshal(streamResp); err == nil {
129
+ WriteSSEEvent(c.Writer, "", string(jsonData))
130
+ }
131
+ }
132
+
133
+ // 处理流式数据
134
+ ctx := c.Request.Context()
135
+ for {
136
+ select {
137
+ case <-ctx.Done():
138
+ logrus.Debug("Client disconnected during streaming")
139
+ return
140
+
141
+ case data, ok := <-chatGenerator:
142
+ if !ok {
143
+ // 通道关闭,发送完成事件
144
+ reason := "stop"
145
+ if toolCallIndex > 0 {
146
+ reason = "tool_calls"
147
+ }
148
+ writeChunk(models.StreamDelta{}, stringPtr(reason))
149
+ WriteSSEEvent(c.Writer, "", "[DONE]")
150
+ return
151
+ }
152
+
153
+ switch v := data.(type) {
154
+ case models.AssistantEvent:
155
+ if !started {
156
+ writeChunk(models.StreamDelta{Role: "assistant"}, nil)
157
+ started = true
158
+ }
159
+
160
+ switch v.Kind {
161
+ case models.AssistantEventText:
162
+ if v.Text != "" {
163
+ writeChunk(models.StreamDelta{Content: v.Text}, nil)
164
+ }
165
+ case models.AssistantEventToolCall:
166
+ if v.ToolCall != nil {
167
+ writeChunk(models.StreamDelta{
168
+ ToolCalls: []models.ToolCallDelta{
169
+ {
170
+ Index: toolCallIndex,
171
+ ID: v.ToolCall.ID,
172
+ Type: v.ToolCall.Type,
173
+ Function: &models.FunctionCallDelta{
174
+ Name: v.ToolCall.Function.Name,
175
+ Arguments: v.ToolCall.Function.Arguments,
176
+ },
177
+ },
178
+ },
179
+ }, nil)
180
+ toolCallIndex++
181
+ }
182
+ }
183
+ case string:
184
+ if !started {
185
+ writeChunk(models.StreamDelta{Role: "assistant"}, nil)
186
+ started = true
187
+ }
188
+ if v != "" {
189
+ writeChunk(models.StreamDelta{Content: v}, nil)
190
+ }
191
+
192
+ case models.Usage:
193
+ // 使用统计 - 通常在最后发送
194
+ continue
195
+
196
+ case error:
197
+ logrus.WithError(v).Error("Stream generator error")
198
+ WriteSSEEvent(c.Writer, "", "[DONE]")
199
+ return
200
+
201
+ default:
202
+ logrus.Warnf("Unknown data type in stream: %T", v)
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // NonStreamChatCompletion 处理非流式聊天完成
209
+ func NonStreamChatCompletion(c *gin.Context, chatGenerator <-chan interface{}, modelName string) {
210
+ var fullContent strings.Builder
211
+ var usage models.Usage
212
+ toolCalls := make([]models.ToolCall, 0, 2)
213
+ finishReason := "stop"
214
+
215
+ // 收集所有数据
216
+ ctx := c.Request.Context()
217
+ for {
218
+ select {
219
+ case <-ctx.Done():
220
+ c.JSON(http.StatusRequestTimeout, models.NewErrorResponse(
221
+ "Request timeout",
222
+ "timeout_error",
223
+ "request_timeout",
224
+ ))
225
+ return
226
+
227
+ case data, ok := <-chatGenerator:
228
+ if !ok {
229
+ // 数据收集完成,返回响应
230
+ responseID := GenerateChatCompletionID()
231
+ message := models.Message{
232
+ Role: "assistant",
233
+ }
234
+ if fullContent.Len() > 0 || len(toolCalls) == 0 {
235
+ message.Content = fullContent.String()
236
+ }
237
+ if len(toolCalls) > 0 {
238
+ message.ToolCalls = toolCalls
239
+ finishReason = "tool_calls"
240
+ }
241
+ response := models.NewChatCompletionResponse(
242
+ responseID,
243
+ modelName,
244
+ message,
245
+ finishReason,
246
+ usage,
247
+ )
248
+ c.JSON(http.StatusOK, response)
249
+ return
250
+ }
251
+
252
+ switch v := data.(type) {
253
+ case models.AssistantEvent:
254
+ switch v.Kind {
255
+ case models.AssistantEventText:
256
+ fullContent.WriteString(v.Text)
257
+ case models.AssistantEventToolCall:
258
+ if v.ToolCall != nil {
259
+ toolCalls = append(toolCalls, *v.ToolCall)
260
+ }
261
+ }
262
+ case string:
263
+ fullContent.WriteString(v)
264
+ case models.Usage:
265
+ usage = v
266
+ case error:
267
+ middleware.HandleError(c, v)
268
+ return
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ // ErrorWrapper 错误包装器
275
+ func ErrorWrapper(handler func(*gin.Context) error) gin.HandlerFunc {
276
+ return func(c *gin.Context) {
277
+ if err := handler(c); err != nil {
278
+ logrus.WithError(err).Error("Handler error")
279
+
280
+ if !c.Writer.Written() {
281
+ c.JSON(http.StatusInternalServerError, models.NewErrorResponse(
282
+ "Internal server error",
283
+ "internal_error",
284
+ "",
285
+ ))
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ // SafeStreamWrapper 安全流式包装器
292
+ func SafeStreamWrapper(handler func(*gin.Context, <-chan interface{}, string), c *gin.Context, chatGenerator <-chan interface{}, modelName string) {
293
+ defer func() {
294
+ if r := recover(); r != nil {
295
+ logrus.WithField("panic", r).Error("Panic in stream handler")
296
+ if !c.Writer.Written() {
297
+ c.JSON(http.StatusInternalServerError, models.NewErrorResponse(
298
+ "Internal server error",
299
+ "panic_error",
300
+ "",
301
+ ))
302
+ }
303
+ }
304
+ }()
305
+
306
+ firstItem, ok := <-chatGenerator
307
+ if !ok {
308
+ middleware.HandleError(c, middleware.NewCursorWebError(http.StatusInternalServerError, "empty stream"))
309
+ return
310
+ }
311
+
312
+ if err, isErr := firstItem.(error); isErr {
313
+ middleware.HandleError(c, err)
314
+ return
315
+ }
316
+
317
+ buffered := make(chan interface{}, 1)
318
+ buffered <- firstItem
319
+ ctx := c.Request.Context()
320
+
321
+ go func() {
322
+ defer close(buffered)
323
+ for {
324
+ select {
325
+ case <-ctx.Done():
326
+ return
327
+ case item, ok := <-chatGenerator:
328
+ if !ok {
329
+ return
330
+ }
331
+ select {
332
+ case buffered <- item:
333
+ case <-ctx.Done():
334
+ return
335
+ }
336
+ }
337
+ }
338
+ }()
339
+
340
+ handler(c, buffered, modelName)
341
+ }
342
+
343
+ // CreateHTTPClient 创建HTTP客户端
344
+ func CreateHTTPClient(timeout time.Duration) *http.Client {
345
+ return &http.Client{
346
+ Timeout: timeout,
347
+ Transport: &http.Transport{
348
+ Proxy: http.ProxyFromEnvironment,
349
+ MaxIdleConns: 100,
350
+ MaxIdleConnsPerHost: 10,
351
+ IdleConnTimeout: 90 * time.Second,
352
+ },
353
+ }
354
+ }
355
+
356
+ // ReadSSEStream 读取SSE流
357
+ func ReadSSEStream(ctx context.Context, resp *http.Response, output chan<- interface{}) error {
358
+ scanner := bufio.NewScanner(resp.Body)
359
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
360
+ defer resp.Body.Close()
361
+
362
+ for scanner.Scan() {
363
+ select {
364
+ case <-ctx.Done():
365
+ return ctx.Err()
366
+ default:
367
+ }
368
+
369
+ line := scanner.Text()
370
+ data := ParseSSELine(line)
371
+ if data == "" {
372
+ continue
373
+ }
374
+
375
+ if data == "[DONE]" {
376
+ return nil
377
+ }
378
+
379
+ // 尝试解析JSON数据
380
+ var eventData models.CursorEventData
381
+ if err := json.Unmarshal([]byte(data), &eventData); err != nil {
382
+ logrus.WithError(err).Debugf("Failed to parse SSE data: %s", data)
383
+ continue
384
+ }
385
+
386
+ // 处理不同类型的事件
387
+ switch eventData.Type {
388
+ case "error":
389
+ if eventData.ErrorText != "" {
390
+ return fmt.Errorf("cursor API error: %s", eventData.ErrorText)
391
+ }
392
+
393
+ case "finish":
394
+ if eventData.MessageMetadata != nil && eventData.MessageMetadata.Usage != nil {
395
+ usage := models.Usage{
396
+ PromptTokens: eventData.MessageMetadata.Usage.InputTokens,
397
+ CompletionTokens: eventData.MessageMetadata.Usage.OutputTokens,
398
+ TotalTokens: eventData.MessageMetadata.Usage.TotalTokens,
399
+ }
400
+ output <- usage
401
+ }
402
+ return nil
403
+
404
+ default:
405
+ if eventData.Delta != "" {
406
+ output <- eventData.Delta
407
+ }
408
+ }
409
+ }
410
+
411
+ return scanner.Err()
412
+ }
413
+
414
+ // ValidateModel 验证模型名称
415
+ func ValidateModel(model string, validModels []string) bool {
416
+ for _, validModel := range validModels {
417
+ if validModel == model {
418
+ return true
419
+ }
420
+ }
421
+ return false
422
+ }
423
+
424
+ // SanitizeContent 清理内容
425
+ func SanitizeContent(content string) string {
426
+ // 移除可能的恶意内容
427
+ content = strings.ReplaceAll(content, "\x00", "")
428
+ return content
429
+ }
430
+
431
+ // stringPtr 返回字符串指针
432
+ func stringPtr(s string) *string {
433
+ return &s
434
+ }
435
+
436
+ // CopyHeaders 复制HTTP头
437
+ func CopyHeaders(dst, src http.Header, skipHeaders []string) {
438
+ skipMap := make(map[string]bool)
439
+ for _, header := range skipHeaders {
440
+ skipMap[strings.ToLower(header)] = true
441
+ }
442
+
443
+ for key, values := range src {
444
+ if skipMap[strings.ToLower(key)] {
445
+ continue
446
+ }
447
+ for _, value := range values {
448
+ dst.Add(key, value)
449
+ }
450
+ }
451
+ }
452
+
453
+ // IsJSONContentType 检查是否为JSON内容类型
454
+ func IsJSONContentType(contentType string) bool {
455
+ return strings.Contains(strings.ToLower(contentType), "application/json")
456
+ }
457
+
458
+ // ReadRequestBody 读取请求体
459
+ func ReadRequestBody(r *http.Request) ([]byte, error) {
460
+ if r.Body == nil {
461
+ return nil, nil
462
+ }
463
+
464
+ body, err := io.ReadAll(r.Body)
465
+ if err != nil {
466
+ return nil, fmt.Errorf("failed to read request body: %w", err)
467
+ }
468
+
469
+ return body, nil
470
+ }
471
+
472
+ // RunJS 执行JavaScript代码并返回标准输出内容
473
+ func RunJS(jsCode string) (string, error) {
474
+ // 添加crypto模块导入并设置为全局变量
475
+ // 注意:使用stdin时,我们需要确保代码是自包含的
476
+ finalJS := `const crypto = require('crypto').webcrypto;
477
+ global.crypto = crypto;
478
+ globalThis.crypto = crypto;
479
+ // 在Node.js环境中创建window对象
480
+ if (typeof window === 'undefined') { global.window = global; }
481
+ window.crypto = crypto;
482
+ this.crypto = crypto;
483
+ ` + jsCode
484
+
485
+ // 执行Node.js命令,使用stdin输入代码
486
+ cmd := exec.Command("node")
487
+
488
+ // 设置输入
489
+ cmd.Stdin = strings.NewReader(finalJS)
490
+
491
+ output, err := cmd.Output()
492
+ if err != nil {
493
+ if exitErr, ok := err.(*exec.ExitError); ok {
494
+ return "", fmt.Errorf("node.js execution failed (exit code: %d)\nSTDOUT:\n%s\nSTDERR:\n%s",
495
+ exitErr.ExitCode(), string(output), string(exitErr.Stderr))
496
+ }
497
+ return "", fmt.Errorf("failed to execute node.js: %w", err)
498
+ }
499
+
500
+ return strings.TrimSpace(string(output)), nil
501
+ }