Skip to content

Commit 1d4278c

Browse files
systemimeclaude
andcommitted
feat: add mock_models config and improve remove_version_path with regex
- Add mock_models and mock_models_resp config options for mocking /models endpoint - Improve remove_version_path to use regex for flexible version matching - Regex pattern matches v1, v2, v3, v1beta, v1alpha, v2beta, v2alpha, etc. - Add tests for isModelsRequest and mock models response - Update README with new config options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 36d23ae commit 1d4278c

5 files changed

Lines changed: 280 additions & 20 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Variables
44
APP_NAME := mask-ctl
55
SERVICE_BINARY := coding-plan-mask
6-
VERSION := 0.8.1
6+
VERSION := 0.8.2
77
BUILD_DIR := build
88
BIN_DIR := $(BUILD_DIR)/bin
99
CMD_DIR := cmd/coding-plan-mask

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ claude_code_user_agent = "claude-cli/2.1.76 (external, cli)"
147147
# Optional: Remove version prefix (e.g., /v1) from request path when forwarding
148148
# Example: Request to /v1/models will be forwarded as /models only
149149
remove_version_path = false
150+
# Mock /models endpoint response (default: false)
151+
# When enabled, returns mock data instead of forwarding to upstream
152+
# Matches: /models, /v1/models, /v2/models, /v3/models
153+
mock_models = false
154+
# Mock /models response content (JSON string)
155+
mock_models_resp = '{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"organization"}]}'
150156
```
151157

152158
#### 4. Start
@@ -262,6 +268,8 @@ You can also configure via environment variables:
262268
| `OPENCLAW_USER_AGENT` | Override the compatibility UA used by `openclaw` mode |
263269
| `CUSTOM_USER_AGENT` | Override User-Agent directly |
264270
| `REMOVE_VERSION_PATH` | Remove version prefix (e.g., `/v1`) from request path when forwarding (true/false) |
271+
| `MOCK_MODELS` | Enable mock /models endpoint response (true/false) |
272+
| `MOCK_MODELS_RESP` | Mock /models response content (JSON string) |
265273

266274
### ⚠️ Risk Warning
267275

@@ -399,6 +407,12 @@ openclaw_user_agent = "OpenClaw-Gateway/1.0"
399407
# 可选:转发时移除请求路径中的版本前缀(如 /v1)
400408
# 例如:请求 /v1/models 时,转发时只拼接 /models 部分
401409
remove_version_path = false
410+
# 模拟 /models 端点响应 (默认: false)
411+
# 启用后返回模拟数据,不转发到上游
412+
# 匹配路径: /models, /v1/models, /v2/models, /v3/models
413+
mock_models = false
414+
# 模拟 /models 响应内容 (JSON 字符串)
415+
mock_models_resp = '{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"organization"}]}'
402416
```
403417

404418
#### 4. 启动
@@ -496,6 +510,8 @@ curl http://127.0.0.1:8787/stats
496510
| `OPENCLAW_USER_AGENT` | 覆盖 `openclaw` 模式兼容默认 User-Agent |
497511
| `CUSTOM_USER_AGENT` | 直接覆盖 User-Agent |
498512
| `REMOVE_VERSION_PATH` | 转发时移除请求路径中的版本前缀(如 `/v1`)(true/false) |
513+
| `MOCK_MODELS` | 启用模拟 /models 端点响应 (true/false) |
514+
| `MOCK_MODELS_RESP` | 模拟 /models 响应内容 (JSON 字符串) |
499515

500516
### ⚠️ 风险预警
501517

internal/config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ type APIConfig struct {
7373
// 删除代理伪装请求的版本控制路径
7474
// 例如:请求 /v1/models 时,转发时只拼接 /models 部分
7575
RemoveVersionPath bool `toml:"remove_version_path"`
76+
// 模拟 /models 响应
77+
MockModels bool `toml:"mock_models"`
78+
// 模拟 /models 响应内容 (JSON 字符串)
79+
MockModelsResp string `toml:"mock_models_resp"`
7680
}
7781

7882
// Config 应用配置(运行时使用)
@@ -101,6 +105,8 @@ type Config struct {
101105
CustomAuthHeader string
102106
CustomAuthPrefix string
103107
RemoveVersionPath bool
108+
MockModels bool
109+
MockModelsResp string
104110

105111
configPath string
106112
}
@@ -119,6 +125,9 @@ const (
119125
ClaudeCodeAppHeaderValue = "cli"
120126
)
121127

128+
// DefaultMockModelsResp 默认的 /models 模拟响应
129+
const DefaultMockModelsResp = `{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"organization"}]}`
130+
122131
// PredefinedDisguiseTools 预定义的伪装工具
123132
// User-Agent 来源说明:
124133
// - claudecode: 当前 Claude Code CLI 请求格式,默认值可通过配置覆盖
@@ -174,6 +183,7 @@ func DefaultConfig() *Config {
174183
RateLimitRequests: 100,
175184
Timeout: 120,
176185
MaxRequestBodySize: 10 * 1024 * 1024,
186+
MockModelsResp: DefaultMockModelsResp,
177187
}
178188
}
179189

@@ -294,6 +304,10 @@ func LoadConfig(path string) (*Config, error) {
294304
cfg.CustomAuthHeader = cfgFile.API.AuthHeader
295305
cfg.CustomAuthPrefix = cfgFile.API.AuthPrefix
296306
cfg.RemoveVersionPath = cfgFile.API.RemoveVersionPath
307+
cfg.MockModels = cfgFile.API.MockModels
308+
if cfgFile.API.MockModelsResp != "" {
309+
cfg.MockModelsResp = cfgFile.API.MockModelsResp
310+
}
297311

298312
cfg.loadFromEnv()
299313
return cfg, nil
@@ -342,6 +356,12 @@ func (c *Config) loadFromEnv() {
342356
if v := os.Getenv("REMOVE_VERSION_PATH"); strings.ToLower(v) == "true" {
343357
c.RemoveVersionPath = true
344358
}
359+
if v := os.Getenv("MOCK_MODELS"); strings.ToLower(v) == "true" {
360+
c.MockModels = true
361+
}
362+
if v := os.Getenv("MOCK_MODELS_RESP"); v != "" {
363+
c.MockModelsResp = v
364+
}
345365
}
346366

347367
// Set 设置配置项
@@ -388,6 +408,10 @@ func (c *Config) Set(key string, value string) error {
388408
c.CustomAuthPrefix = value
389409
case "remove_version_path":
390410
c.RemoveVersionPath = strings.ToLower(value) == "true"
411+
case "mock_models":
412+
c.MockModels = strings.ToLower(value) == "true"
413+
case "mock_models_resp":
414+
c.MockModelsResp = value
391415
default:
392416
return fmt.Errorf("未知配置项: %s", key)
393417
}
@@ -504,6 +528,8 @@ func (c *Config) GetSafe() map[string]interface{} {
504528
"api_base_url": c.CustomBaseURL,
505529
"api_coding_url": c.CustomCodingURL,
506530
"remove_version_path": c.RemoveVersionPath,
531+
"mock_models": c.MockModels,
532+
"mock_models_resp": c.MockModelsResp,
507533
}
508534
}
509535

@@ -608,6 +634,11 @@ auth_prefix = ""
608634
# 删除代理伪装请求的版本控制路径 (默认 false)
609635
# 例如:请求 /v1/models 时,转发时只拼接 /models 部分
610636
remove_version_path = false
637+
# 模拟 /models 响应 (默认 false)
638+
# 启用后,对 /models 或 /v1/models (取决于 remove_version_path) 返回模拟数据
639+
mock_models = false
640+
# 模拟 /models 响应内容 (JSON 字符串)
641+
mock_models_resp = '{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"organization"}]}'
611642
`
612643

613644
return os.WriteFile(path, []byte(defaultContent), 0644)

internal/proxy/proxy.go

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"net/textproto"
1212
"os"
13+
"regexp"
1314
"strings"
1415
"time"
1516
"unicode/utf8"
@@ -79,6 +80,12 @@ func (p *Proxy) Forward(w http.ResponseWriter, r *http.Request) {
7980
return
8081
}
8182

83+
// 检查是否需要模拟 /models 响应
84+
if p.cfg.MockModels && p.isModelsRequest(r.URL.Path) {
85+
p.handleMockModels(w, r, startTime, clientIP)
86+
return
87+
}
88+
8289
// 读取请求体
8390
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, p.cfg.MaxRequestBodySize))
8491
if err != nil {
@@ -160,6 +167,46 @@ func (p *Proxy) Embeddings(w http.ResponseWriter, r *http.Request) {
160167
p.Forward(w, r)
161168
}
162169

170+
// isModelsRequest 检查是否是 /models 请求
171+
// 匹配规则:
172+
// - 始终匹配 /models
173+
// - 始终匹配 /v1/models, /v2/models, /v3/models (版本前缀格式)
174+
func (p *Proxy) isModelsRequest(path string) bool {
175+
path = strings.TrimSuffix(path, "/")
176+
177+
// 匹配 /models
178+
if path == "/models" {
179+
return true
180+
}
181+
182+
// 匹配 /v1/models, /v2/models, /v3/models 等
183+
if strings.HasSuffix(path, "/models") {
184+
prefix := strings.TrimSuffix(path, "/models")
185+
return prefix == "/v1" || prefix == "/v2" || prefix == "/v3"
186+
}
187+
188+
return false
189+
}
190+
191+
// handleMockModels 处理模拟 /models 响应
192+
func (p *Proxy) handleMockModels(w http.ResponseWriter, r *http.Request, startTime time.Time, clientIP string) {
193+
duration := time.Since(startTime).Milliseconds()
194+
195+
// 验证本地 API Key
196+
if !p.validateLocalAPIKey(r) {
197+
p.writeError(w, http.StatusUnauthorized, "API Key 无效")
198+
return
199+
}
200+
201+
// 返回模拟响应
202+
w.Header().Set("Content-Type", "application/json")
203+
w.WriteHeader(http.StatusOK)
204+
w.Write([]byte(p.cfg.MockModelsResp))
205+
206+
// 打印日志
207+
p.logResponse(r.Method, r.URL.Path, "mock://models", http.StatusOK, duration, clientIP, p.cfg.MockModelsResp)
208+
}
209+
163210
// validateLocalAPIKey 验证本地 API Key
164211
func (p *Proxy) validateLocalAPIKey(r *http.Request) bool {
165212
localAPIKey := p.cfg.LocalAPIKey
@@ -437,27 +484,20 @@ func buildTargetURL(baseURL string, r *http.Request, removeVersionPath bool) str
437484
return targetURL
438485
}
439486

487+
// versionPrefixRegex 匹配版本前缀的正则表达式
488+
// 匹配: /v1, /v2, /v1beta, /v2alpha, /v3rc 等(可选带尾部斜杠)
489+
var versionPrefixRegex = regexp.MustCompile(`^/?v\d+[a-z]*(?:/|$)`)
490+
440491
// removeVersionPrefix 移除路径中的版本前缀(如 /v1, /v2 等)
441492
func removeVersionPrefix(path string) string {
442-
// 匹配 /v1, /v2, /v1beta, /v2alpha 等版本前缀
443-
// 正则匹配:/v 后面跟数字,可选跟 alpha/beta/rc 等
444-
path = strings.TrimLeft(path, "/")
445-
446-
// 常见版本前缀模式
447-
versionPatterns := []string{"v1/", "v2/", "v3/", "v1beta/", "v1alpha/", "v2beta/", "v2alpha/"}
448-
449-
for _, pattern := range versionPatterns {
450-
if strings.HasPrefix(path, pattern) {
451-
return strings.TrimPrefix(path, pattern)
452-
}
453-
}
454-
455-
// 如果是单纯的版本路径如 /v1 或 /v2(没有后续路径),返回空
456-
if path == "v1" || path == "v2" || path == "v3" {
457-
return ""
458-
}
459-
460-
return path
493+
// 使用正则匹配:/v 后面跟数字,可选跟 alpha/beta/rc 等后缀
494+
// 如果匹配到,移除版本前缀部分
495+
if versionPrefixRegex.MatchString(path) {
496+
// 移除开头的 / 和版本号部分
497+
path = versionPrefixRegex.ReplaceAllString(path, "")
498+
return strings.Trim(path, "/")
499+
}
500+
return strings.Trim(path, "/")
461501
}
462502

463503
func isHopByHopHeader(key string) bool {

0 commit comments

Comments
 (0)