diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go index abc11904d4..02c275e2b0 100644 --- a/backend/internal/handler/gateway_helper.go +++ b/backend/internal/handler/gateway_helper.go @@ -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 diff --git a/backend/internal/handler/gateway_helper_hotpath_test.go b/backend/internal/handler/gateway_helper_hotpath_test.go index 5e0697f083..208021b9ec 100644 --- a/backend/internal/handler/gateway_helper_hotpath_test.go +++ b/backend/internal/handler/gateway_helper_hotpath_test.go @@ -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())) +} diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index c37d444370..814ce35472 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -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 { diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index bf0e127e82..ffc9d52f46 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -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})) +}