Merge pull request #6943 from xiuai-labs/fix/keep-client-system-cache-control

fix(gateway): 不再剥离客户端 system 上的 cache_control / stop stripping client system cache_control
This commit is contained in:
Wesley Liddick
2026-09-11 09:28:55 +08:00
committed by GitHub
4 changed files with 145 additions and 23 deletions
@@ -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 {
@@ -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 到本地估算。
+1 -6
View File
@@ -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 {
@@ -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)
}