From a4edda36d53a07a363f467c1d1922eba0dc58ed9 Mon Sep 17 00:00:00 2001 From: nekocode Date: Thu, 10 Sep 2026 15:48:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E4=B8=8D=E5=86=8D=E5=89=A5?= =?UTF-8?q?=E7=A6=BB=E5=AE=A2=E6=88=B7=E7=AB=AF=20system=20=E4=B8=8A?= =?UTF-8?q?=E7=9A=84=20cache=5Fcontrol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth mimic 路径无条件删除 system[*].cache_control,不打日志也不报错。 把稳定前缀锚在 system 上的客户端因此拿到 cache_creation 与 cache_read 双 0 —— 不是没命中,是连写都没写。 这个动作是 46e5ac9 引进时「system 必然被整个重写」的配套:客户端的 system 内容都搬进 messages 了,残留断点指着空气,删它是对的。后来 system 注入变成可配置,于是长出了原设计里不存在的组合 —— system 没被 重写,断点照删。剥离失去了赖以成立的前提,代码却还在。 三处调用点里 count_tokens 那处还是硬编码 true,与另两处的 !systemRewritten 语义不一致。 直接移除该选项而不是再加一个开关:注入开启时 system 已被整个替换、 留在上面的断点是我们自己拼进去的稳定锚点;注入关闭时断点就是客户端的 缓存意图。两种情形都没有删它的理由,而需要开关的是有取舍的行为。 顺带补上 count_tokens 的 4 块上限兜底:五条出口里只有它没在自己的转发 路径上调过 enforceCacheControlLimit,原先那句 strip 是这条路上唯一压制 断点数量的东西。拿掉之后「客户端 system + 客户端 messages + 刚注入的 tools[-1]」实测顶到 5 块,而上游对超限是 400。 messages 层的同类改写见 #2369,那次只落地了 messages 一半。 此前该行为零测试覆盖(stripSystemCacheControl 在 _test.go 里零命中), 本次补 4 个用例,其中上限兜底那条撤掉修复即红。 Claude-Session: https://claude.ai/code/session_01WnTW52pmELGy3g7KPEUXj2 --- .../service/gateway_claude_oauth_body.go | 27 ++-- .../internal/service/gateway_count_tokens.go | 10 +- backend/internal/service/gateway_forward.go | 7 +- .../gateway_system_cache_control_test.go | 124 ++++++++++++++++++ 4 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 backend/internal/service/gateway_system_cache_control_test.go diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index e98b967e2a..b879611dee 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -42,9 +42,8 @@ func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte } type claudeOAuthNormalizeOptions struct { - injectMetadata bool - metadataUserID string - stripSystemCacheControl bool + injectMetadata bool + metadataUserID string } // sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present). @@ -141,7 +140,14 @@ func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) { return next, true } -func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOptions) ([]byte, bool) { +// normalizeClaudeOAuthSystemBody 只做 system 文本的规范化,**不动 cache_control**。 +// +// 这里曾经按 opts 剥离客户端打在 system 上的断点。那个动作是「system 必然被整个 +// 重写」时代的配套:内容都搬进 messages 了,残留断点指着空气。system 注入变成 +// 可配置之后前提就没了——注入开启时留在 system 上的断点是我们自己拼的稳定锚点, +// 注入关闭时它是客户端的缓存意图,两种情形都没有删它的理由。 +// 4 块上限属于上游硬约束,由 enforceCacheControlLimit 在各条出口兜底。 +func normalizeClaudeOAuthSystemBody(body []byte) ([]byte, bool) { sys := gjson.GetBytes(body, "system") if !sys.Exists() { return body, false @@ -176,13 +182,6 @@ func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOption } } - if opts.stripSystemCacheControl && item.Get("cache_control").Exists() { - if next, ok := deleteJSONPathBytes(out, fmt.Sprintf("system.%d.cache_control", index)); ok { - out = next - modified = true - } - } - index++ return true }) @@ -229,7 +228,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu out := body modified := false - if next, changed := normalizeClaudeOAuthSystemBody(out, opts); changed { + if next, changed := normalizeClaudeOAuthSystemBody(out); changed { out = next modified = true } @@ -389,14 +388,12 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( } systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - systemRewritten := false if systemPromptInjectionEnabled { systemPromptBlocks = claudeOAuthSystemPromptBlocksForModel(model, systemPromptBlocks) body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) - systemRewritten = true } - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} + normalizeOpts := claudeOAuthNormalizeOptions{} if s.identityService != nil && c != nil && c.Request != nil { if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil { diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 5ff4f323a8..6a17f50b17 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -59,9 +59,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT if shouldMimicClaudeCode { - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} var normalizedBody []byte - normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) + normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, claudeOAuthNormalizeOptions{}) if err := replaceBody(normalizedBody); err != nil { return err } @@ -78,6 +77,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return err } } + + // 4 块上限的兜底:其余四条出口都在自己的转发路径上调过一次,只有这里没有。 + // 不再剥离客户端 system 断点之后,「客户端 system + 客户端 messages + + // 上面刚注入的 tools[-1]」可以直接顶到 5 块,而上游对超限是 400。 + if err := replaceBody(enforceCacheControlLimit(body)); err != nil { + return err + } } // Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。 diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index d6a454111c..3839198484 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -201,20 +201,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // Code..." system prompt 但缺少 billing attribution block,导致 Anthropic // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 - systemRewritten := false systemRaw, _ := parsed.SystemValue() systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) if systemPromptInjectionEnabled { if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { return nil, err } - systemRewritten = true } - // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); - // 未重写时(注入开关关闭)剥离客户端 cache_control,与原有行为一致。 - // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} + normalizeOpts := claudeOAuthNormalizeOptions{} if s.identityService != nil && c != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { diff --git a/backend/internal/service/gateway_system_cache_control_test.go b/backend/internal/service/gateway_system_cache_control_test.go new file mode 100644 index 0000000000..54ec1de255 --- /dev/null +++ b/backend/internal/service/gateway_system_cache_control_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 客户端打在 system 上的 cache_control 是它自己的缓存意图,网关不该替它删掉。 +// +// 这些用例守的是 #2369 里被漏掉的那一半:messages 层的同类改写已经收进 +// rewrite_message_cache_control 开关(默认不动客户端断点),system 层却一直没有 +// 对应的保护——mimic 路径无条件剥离,且不打日志、不报错。 +// +// 剥离在两种情形下都没有成立的前提:注入开启时 system 已被整个替换、客户端断点 +// 早就不在了;注入关闭时 system 是客户端原样,断点就是它的意图。 + +// 多块 system 各自带断点时,一块都不能少:Anthropic 允许最多 4 个, +// 真正的上限兜底是 enforceCacheControlLimit 的事,不该在这里提前砍。 +func TestNormalizeClaudeOAuthRequestBody_KeepsEveryClientSystemBreakpoint(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-6","system":[` + + `{"type":"text","text":"block one","cache_control":{"type":"ephemeral","ttl":"5m"}},` + + `{"type":"text","text":"block two"},` + + `{"type":"text","text":"block three","cache_control":{"type":"ephemeral","ttl":"1h"}}` + + `],"messages":[{"role":"user","content":"hi"}]}`) + + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{}) + + require.Equal(t, "5m", gjson.GetBytes(out, "system.0.cache_control.ttl").String(), + "客户端选的 TTL 要原样留着") + require.False(t, gjson.GetBytes(out, "system.1.cache_control").Exists(), "没有的不该凭空长出来") + require.Equal(t, "1h", gjson.GetBytes(out, "system.2.cache_control.ttl").String()) +} + +// 保留断点不等于放弃 system 文本的规范化:OpenCode 身份句该改的还得改。 +// 这条用例区分「拿掉剥离」与「把整段 normalize 关掉」——后者会让第三方指纹漏上去。 +func TestNormalizeClaudeOAuthRequestBody_StillSanitizesTextWhileKeepingBreakpoint(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-6","system":[{"type":"text","text":"You are OpenCode, the best coding agent on the planet.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hi"}]}`) + + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{}) + + require.Equal(t, strings.TrimSpace(claudeCodeSystemPrompt), gjson.GetBytes(out, "system.0.text").String()) + require.True(t, gjson.GetBytes(out, "system.0.cache_control").Exists(), + "文本被规范化,断点仍要留着") +} + +// 注入开启这条路径上,system 是我们自己拼的;blocks 配置里自带的 cache_control +// 是稳定缓存锚点(对齐真实 CLI 的形态),同样不能在 normalize 里掉。 +func TestNormalizeClaudeOAuthRequestBody_KeepsInjectedSystemBreakpoint(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-6","system":"client instructions","messages":[{"role":"user","content":"hi"}]}`) + rewritten := rewriteSystemForNonClaudeCode(body, "client instructions") + + out, _ := normalizeClaudeOAuthRequestBody(rewritten, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{}) + + // 只断言「注入后的 system 仍带着锚点」,不写死是第几块: + // 块数与布局由 blocks 配置决定,焊进断言会让配置一改就误报。 + _, _, _, systemPaths := collectCacheControlPaths(out) + require.NotEmpty(t, systemPaths, "注入路径自带的稳定锚点不该在 normalize 里掉") +} + +// count_tokens 是五条出口里唯一没有在自己转发路径上调过 enforceCacheControlLimit 的 +// 那条。不再剥离客户端 system 断点之后,客户端自带的断点会和 tools[-1] 上注入的那个 +// 叠加,可以直接顶破 4 块上限——而上游对超限是 400。 +// +// 用 setup-token 账号构造 mimic 分支:IsOAuth 认它,取 token 只读 credentials, +// 不碰 DB。UA 非 claude-cli 且无 metadata.user_id,于是 shouldMimicClaudeCode 成立。 +func TestForwardCountTokens_EnforcesCacheControlLimitOnMimicPath(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil) + c.Request.Header.Set("User-Agent", "third-party-client/1.0") + + // 客户端自己打满 4 个断点:system 一个、messages 三个。 + // 加上 mimic 注入的 tools[-1] 与 system blocks 自带的锚点,必然超过 4。 + body := []byte(`{"model":"claude-sonnet-4-6",` + + `"system":[{"type":"text","text":"stable client prefix","cache_control":{"type":"ephemeral","ttl":"5m"}}],` + + `"tools":[{"name":"probe","description":"d","input_schema":{"type":"object"}}],` + + `"messages":[` + + `{"role":"user","content":[{"type":"text","text":"one","cache_control":{"type":"ephemeral"}}]},` + + `{"role":"assistant","content":[{"type":"text","text":"two","cache_control":{"type":"ephemeral"}}]},` + + `{"role":"user","content":[{"type":"text","text":"three","cache_control":{"type":"ephemeral"}}]}` + + `]}`) + parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-6"} + + upstream := &anthropicHTTPUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"input_tokens":42}`)), + }, + } + svc := &GatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + rateLimitService: &RateLimitService{}, + } + account := &Account{ + ID: 401, + Name: "count-tokens-ceiling-test", + Platform: PlatformAnthropic, + Type: AccountTypeSetupToken, + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-oauth-token"}, + Status: StatusActive, + Schedulable: true, + } + + require.NoError(t, svc.ForwardCountTokens(context.Background(), c, account, parsed)) + + _, messagePaths, toolPaths, systemPaths := collectCacheControlPaths(upstream.lastBody) + total := len(messagePaths) + len(toolPaths) + len(systemPaths) + require.LessOrEqual(t, total, maxCacheControlBlocks, + "出站 body 的 cache_control 块数必须被砍到上限内,实测 %d 块", total) +}