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:
atogumo
2026-09-08 19:12:37 +08:00
parent 772a0382f0
commit 4e5d67df35
4 changed files with 69 additions and 1 deletions
@@ -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}))
}