Files
sub2api/backend/internal/service/gateway_system_cache_control_test.go
T
nekocode a4edda36d5 fix(gateway): 不再剥离客户端 system 上的 cache_control
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
2026-09-10 15:48:17 +08:00

125 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}