mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(gateway): accept Claude Code max_tokens=1 probes for any model
The claude_code_only probe exemption only fired for models containing "haiku". Claude Code also sends max_tokens=1 requests without a system prompt to the model currently in use, e.g. when switching models or refreshing the context usage, so on sonnet/opus groups those requests failed the system-prompt check and were rejected as non-Claude-Code clients. Recognise the probe from the request body itself (max_tokens == 1) once the User-Agent check has passed, regardless of model, and carry max_tokens into the ParsedRequest projection so the hot path sees it too. The haiku context flag and the local haiku interception are unchanged; non-haiku probes reach the pool as ordinary one-token requests. One output token offers nothing to a client that already forges the CLI User-Agent, so this widens no attack surface. Fixes #6591
This commit is contained in:
@@ -89,6 +89,10 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st
|
||||
bodyMap := map[string]any{
|
||||
"model": parsedReq.Model,
|
||||
}
|
||||
// 探测识别(max_tokens=1)需要看到该字段,复用已解析请求时一并带上。
|
||||
if parsedReq.MaxTokens > 0 {
|
||||
bodyMap["max_tokens"] = parsedReq.MaxTokens
|
||||
}
|
||||
if parsedReq.HasSystem {
|
||||
if system, ok := parsedReq.SystemValue(); ok {
|
||||
bodyMap["system"] = system
|
||||
|
||||
@@ -500,3 +500,19 @@ type helperConcurrencyCacheStubWithError struct {
|
||||
func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
return false, s.err
|
||||
}
|
||||
|
||||
func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
|
||||
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
|
||||
// The hot path reuses ParsedRequest instead of re-parsing the body; the probe
|
||||
// marker must survive that projection or the exemption only works cold.
|
||||
parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
|
||||
SetClaudeCodeClientContext(c, nil, parsed)
|
||||
require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
|
||||
|
||||
c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
|
||||
c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
|
||||
require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
|
||||
}
|
||||
|
||||
@@ -104,6 +104,12 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
|
||||
if isMaxTokensOneHaiku, ok := IsMaxTokensOneHaikuRequestFromContext(r.Context()); ok && isMaxTokensOneHaiku {
|
||||
return true // 绕过 system prompt 检查,UA 已在 Step 1 验证
|
||||
}
|
||||
// 探测请求并不总是打到 haiku:CLI 切换模型、刷新上下文用量时会向当前模型发
|
||||
// max_tokens=1 的轻量请求,同样不携带 system。UA 已过 Step 1,且 1 个输出 token
|
||||
// 对滥用者没有价值,故按请求体放行,不再限定模型名。
|
||||
if isMaxTokensOneBody(body) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Step 4: messages 路径,进行严格验证
|
||||
|
||||
@@ -154,6 +160,24 @@ func isMessagesCountTokensPath(path string) bool {
|
||||
return strings.HasSuffix(path, "/messages/count_tokens")
|
||||
}
|
||||
|
||||
// isMaxTokensOneBody 判断请求体是否显式声明 max_tokens=1。
|
||||
// 兼容 JSON 反序列化出的 float64 与 ParsedRequest 复用时的 int。
|
||||
func isMaxTokensOneBody(body map[string]any) bool {
|
||||
if body == nil {
|
||||
return false
|
||||
}
|
||||
switch v := body["max_tokens"].(type) {
|
||||
case float64:
|
||||
return v == 1
|
||||
case int:
|
||||
return v == 1
|
||||
case int64:
|
||||
return v == 1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// hasClaudeCodeSystemPrompt 检查请求是否包含 Claude Code 系统提示词
|
||||
// 使用字符串相似度匹配(Dice coefficient)
|
||||
func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) bool {
|
||||
|
||||
@@ -45,9 +45,10 @@ func TestClaudeCodeValidator_MessagesWithoutProbeStillNeedStrictValidation(t *te
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
|
||||
|
||||
// max_tokens=2 不是探测请求:没有 system prompt 仍走严格校验并被拒。
|
||||
ok := validator.Validate(req, map[string]any{
|
||||
"model": "claude-haiku-4-5",
|
||||
"max_tokens": 1,
|
||||
"max_tokens": 2,
|
||||
})
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -574,3 +575,26 @@ func TestSetGetClaudeCodeVersion(t *testing.T) {
|
||||
ctx = SetClaudeCodeVersion(ctx, "2.1.63")
|
||||
require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx))
|
||||
}
|
||||
|
||||
func TestClaudeCodeValidator_MaxTokensOneProbeIsNotLimitedToHaiku(t *testing.T) {
|
||||
validator := NewClaudeCodeValidator()
|
||||
|
||||
for _, model := range []string{"claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5"} {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
// No context flag, no system prompt, no extra headers: the body alone marks the probe.
|
||||
for _, mt := range []any{float64(1), 1} {
|
||||
require.True(t, validator.Validate(req, map[string]any{"model": model, "max_tokens": mt}), "max_tokens=%v (%T)", mt, mt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCodeValidator_MaxTokensOneProbeStillRequiresClaudeCodeUA(t *testing.T) {
|
||||
validator := NewClaudeCodeValidator()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "python-requests/2.32")
|
||||
|
||||
require.False(t, validator.Validate(req, map[string]any{"model": "claude-sonnet-4-5", "max_tokens": 1}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user