mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 18:46:46 +08:00
feat: 抹除 Anthropic OAuth 请求中客户端 dateline 隐写指纹
对 /v1/messages 转发到 Anthropic OAuth/setup-token 账号的请求做 dateline 归一化,将 system prompt 与 <system-reminder> 块中 "Today's date is …" 语句里的 4 种撇号变体与 "/" 日期分隔符还原为 ASCII 撇号 + "-",抹除某些 客户端在检测到非官方 base URL 时注入的 3 bit 隐写指纹。API Key 账号不受 影响。新增系统设置开关 enable_client_dateline_normalization,默认开启。
This commit is contained in:
@@ -259,6 +259,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
|
||||
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
|
||||
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
|
||||
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
|
||||
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
|
||||
MinCodexVersion: settings.MinCodexVersion,
|
||||
@@ -598,6 +599,7 @@ type UpdateSettingsRequest struct {
|
||||
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
|
||||
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
|
||||
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
|
||||
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
|
||||
|
||||
@@ -1731,6 +1733,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.RewriteMessageCacheControl
|
||||
}(),
|
||||
EnableClientDatelineNormalization: func() bool {
|
||||
if req.EnableClientDatelineNormalization != nil {
|
||||
return *req.EnableClientDatelineNormalization
|
||||
}
|
||||
return previousSettings.EnableClientDatelineNormalization
|
||||
}(),
|
||||
AntigravityUserAgentVersion: func() string {
|
||||
if req.AntigravityUserAgentVersion != nil {
|
||||
return *req.AntigravityUserAgentVersion
|
||||
@@ -2143,6 +2151,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
|
||||
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
|
||||
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
|
||||
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
|
||||
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
|
||||
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
|
||||
MinCodexVersion: updatedSettings.MinCodexVersion,
|
||||
@@ -2644,6 +2653,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl {
|
||||
changed = append(changed, "rewrite_message_cache_control")
|
||||
}
|
||||
if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization {
|
||||
changed = append(changed, "enable_client_dateline_normalization")
|
||||
}
|
||||
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
|
||||
changed = append(changed, "antigravity_user_agent_version")
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ type SystemSettings struct {
|
||||
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
|
||||
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
|
||||
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
|
||||
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Package anthropicfp provides pure helpers for suppressing client-side
|
||||
// fingerprints that would otherwise be visible to upstream Anthropic when a
|
||||
// forwarding gateway sits between the client and api.anthropic.com.
|
||||
//
|
||||
// Currently exposes NormalizeDateline: it rewrites the "Today's date is
|
||||
// YYYY-MM-DD." sentence inside a request body back to a canonical ASCII form,
|
||||
// erasing three bits of steganographic signal (four apostrophe code points and
|
||||
// a date-separator variant) that some clients embed in that sentence when
|
||||
// they detect a non-official base URL.
|
||||
package anthropicfp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// datelineRegexes matches the fingerprinted sentence with any of the four
|
||||
// apostrophe code points seen in the wild and either separator. Two regexes
|
||||
// are used because Go's RE2-based regexp package does not support
|
||||
// backreferences: matching `-` and `/` in two passes keeps the two separators
|
||||
// inside YYYY?MM?DD forced to agree, so mixed-separator strings like
|
||||
// "Today's date is 2026-07/01." never match. This is what filters out
|
||||
// user-authored prose like "Today is foo." or "His date is 2026-06-30." from
|
||||
// being touched.
|
||||
var (
|
||||
datelineRegexHyphen = regexp.MustCompile("Today(['’ʼʹ])s date is (\\d{4})-(\\d{2})-(\\d{2})\\.")
|
||||
datelineRegexSlash = regexp.MustCompile("Today(['’ʼʹ])s date is (\\d{4})/(\\d{2})/(\\d{2})\\.")
|
||||
)
|
||||
|
||||
// systemReminderRegex matches a <system-reminder> block. The dateline lives in
|
||||
// this block once the conversation has advanced past the first turn (system
|
||||
// prompt caching hides the top-level system block for subsequent turns), so
|
||||
// the messages[].content[] scan is confined to what lives inside these tags.
|
||||
var systemReminderRegex = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
|
||||
|
||||
// DatelineHit records what a single rewrite normalized, for observability.
|
||||
type DatelineHit struct {
|
||||
// ApostropheVariant is one of "ascii" (U+0027), "u2019", "u02bc", "u02b9".
|
||||
ApostropheVariant string
|
||||
// DateSeparator is either "-" or "/" as seen before normalization.
|
||||
DateSeparator string
|
||||
}
|
||||
|
||||
// canonicalize returns the canonical form of a matched dateline sentence.
|
||||
// The output always uses ASCII apostrophe and hyphen separators.
|
||||
func canonicalize(year, month, day string) string {
|
||||
return fmt.Sprintf("Today's date is %s-%s-%s.", year, month, day)
|
||||
}
|
||||
|
||||
func apostropheVariant(r rune) string {
|
||||
switch r {
|
||||
case '’':
|
||||
return "u2019"
|
||||
case 'ʼ':
|
||||
return "u02bc"
|
||||
case 'ʹ':
|
||||
return "u02b9"
|
||||
default:
|
||||
return "ascii"
|
||||
}
|
||||
}
|
||||
|
||||
type datelineMatch struct {
|
||||
start, end int
|
||||
apoRune rune
|
||||
sep string
|
||||
year, month, day string
|
||||
}
|
||||
|
||||
func collectMatches(text string, re *regexp.Regexp, sep string) []datelineMatch {
|
||||
locs := re.FindAllStringSubmatchIndex(text, -1)
|
||||
if len(locs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]datelineMatch, 0, len(locs))
|
||||
for _, m := range locs {
|
||||
var apoRune rune
|
||||
for _, r := range text[m[2]:m[3]] {
|
||||
apoRune = r
|
||||
break
|
||||
}
|
||||
out = append(out, datelineMatch{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
apoRune: apoRune,
|
||||
sep: sep,
|
||||
year: text[m[4]:m[5]],
|
||||
month: text[m[6]:m[7]],
|
||||
day: text[m[8]:m[9]],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeText replaces every fingerprinted dateline sentence in text with
|
||||
// its canonical form. It returns the possibly-rewritten text and the list of
|
||||
// hits observed. When no match is found the original string is returned
|
||||
// verbatim (byte-identical), and the returned hit slice is nil.
|
||||
func NormalizeText(text string) (string, []DatelineHit) {
|
||||
if !strings.Contains(text, "date is ") {
|
||||
return text, nil
|
||||
}
|
||||
matches := collectMatches(text, datelineRegexHyphen, "-")
|
||||
matches = append(matches, collectMatches(text, datelineRegexSlash, "/")...)
|
||||
if len(matches) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool { return matches[i].start < matches[j].start })
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(text))
|
||||
prev := 0
|
||||
hits := make([]DatelineHit, 0, len(matches))
|
||||
changed := false
|
||||
for _, m := range matches {
|
||||
full := text[m.start:m.end]
|
||||
canonical := canonicalize(m.year, m.month, m.day)
|
||||
if canonical == full {
|
||||
// Already canonical: no rewrite, no hit.
|
||||
continue
|
||||
}
|
||||
b.WriteString(text[prev:m.start])
|
||||
b.WriteString(canonical)
|
||||
prev = m.end
|
||||
changed = true
|
||||
hits = append(hits, DatelineHit{
|
||||
ApostropheVariant: apostropheVariant(m.apoRune),
|
||||
DateSeparator: m.sep,
|
||||
})
|
||||
}
|
||||
if !changed {
|
||||
return text, nil
|
||||
}
|
||||
b.WriteString(text[prev:])
|
||||
return b.String(), hits
|
||||
}
|
||||
|
||||
// normalizeSystemReminderScopedText scans only the <system-reminder> blocks
|
||||
// inside text and normalizes datelines inside them. Text outside the blocks is
|
||||
// preserved byte-for-byte, so user prose, tool_result content, code blocks,
|
||||
// or shell commands that happen to contain an apostrophe or a slash date are
|
||||
// never touched.
|
||||
func normalizeSystemReminderScopedText(text string) (string, []DatelineHit) {
|
||||
if !strings.Contains(text, "<system-reminder>") {
|
||||
return text, nil
|
||||
}
|
||||
locs := systemReminderRegex.FindAllStringIndex(text, -1)
|
||||
if len(locs) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(text))
|
||||
prev := 0
|
||||
var hits []DatelineHit
|
||||
changed := false
|
||||
for _, loc := range locs {
|
||||
b.WriteString(text[prev:loc[0]])
|
||||
block := text[loc[0]:loc[1]]
|
||||
normalized, blockHits := NormalizeText(block)
|
||||
if normalized != block {
|
||||
changed = true
|
||||
}
|
||||
b.WriteString(normalized)
|
||||
hits = append(hits, blockHits...)
|
||||
prev = loc[1]
|
||||
}
|
||||
if !changed {
|
||||
return text, nil
|
||||
}
|
||||
b.WriteString(text[prev:])
|
||||
return b.String(), hits
|
||||
}
|
||||
|
||||
// NormalizeDateline scans an Anthropic /v1/messages request body and rewrites
|
||||
// every fingerprinted dateline sentence back to its canonical ASCII form.
|
||||
//
|
||||
// Scope (mirroring where genuine clients place the sentence):
|
||||
// - `system` string, or `.text` field of each text-typed block in `system`.
|
||||
// - Text bodies inside `messages[i].content` — but ONLY the substrings that
|
||||
// appear inside `<system-reminder>...</system-reminder>` tags. Free user
|
||||
// prose, tool_use.input, tool_result.content, and other block types are
|
||||
// never scanned, guaranteeing that legitimate text like a code block, a
|
||||
// shell command, or a chat message that mentions today's date is never
|
||||
// accidentally rewritten.
|
||||
//
|
||||
// The function is a pure transform: it never modifies the input slice, and if
|
||||
// no rewrite is needed it returns the original slice (identity), a nil hit
|
||||
// slice, and changed=false.
|
||||
func NormalizeDateline(body []byte) ([]byte, []DatelineHit, bool) {
|
||||
if len(body) == 0 {
|
||||
return body, nil, false
|
||||
}
|
||||
out := body
|
||||
var hits []DatelineHit
|
||||
changed := false
|
||||
|
||||
sys := gjson.GetBytes(out, "system")
|
||||
if sys.Exists() {
|
||||
switch {
|
||||
case sys.Type == gjson.String:
|
||||
normalized, sysHits := NormalizeText(sys.String())
|
||||
if normalized != sys.String() {
|
||||
if next, err := sjson.SetBytes(out, "system", normalized); err == nil {
|
||||
out = next
|
||||
changed = true
|
||||
hits = append(hits, sysHits...)
|
||||
}
|
||||
}
|
||||
case sys.IsArray():
|
||||
idx := 0
|
||||
sys.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "text" {
|
||||
t := item.Get("text")
|
||||
if t.Exists() && t.Type == gjson.String {
|
||||
normalized, textHits := NormalizeText(t.String())
|
||||
if normalized != t.String() {
|
||||
path := fmt.Sprintf("system.%d.text", idx)
|
||||
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
|
||||
out = next
|
||||
changed = true
|
||||
hits = append(hits, textHits...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
idx++
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
messages := gjson.GetBytes(out, "messages")
|
||||
if messages.IsArray() {
|
||||
msgIdx := -1
|
||||
messages.ForEach(func(_, msg gjson.Result) bool {
|
||||
msgIdx++
|
||||
content := msg.Get("content")
|
||||
if !content.Exists() {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case content.Type == gjson.String:
|
||||
normalized, contentHits := normalizeSystemReminderScopedText(content.String())
|
||||
if normalized != content.String() {
|
||||
path := fmt.Sprintf("messages.%d.content", msgIdx)
|
||||
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
|
||||
out = next
|
||||
changed = true
|
||||
hits = append(hits, contentHits...)
|
||||
}
|
||||
}
|
||||
case content.IsArray():
|
||||
contentIdx := -1
|
||||
content.ForEach(func(_, block gjson.Result) bool {
|
||||
contentIdx++
|
||||
if block.Get("type").String() != "text" {
|
||||
return true
|
||||
}
|
||||
t := block.Get("text")
|
||||
if !t.Exists() || t.Type != gjson.String {
|
||||
return true
|
||||
}
|
||||
normalized, textHits := normalizeSystemReminderScopedText(t.String())
|
||||
if normalized != t.String() {
|
||||
path := fmt.Sprintf("messages.%d.content.%d.text", msgIdx, contentIdx)
|
||||
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
|
||||
out = next
|
||||
changed = true
|
||||
hits = append(hits, textHits...)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return body, nil, false
|
||||
}
|
||||
return out, hits, true
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package anthropicfp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeText_ASCIIHyphenIsIdentity(t *testing.T) {
|
||||
in := "Today's date is 2026-07-01."
|
||||
out, hits := NormalizeText(in)
|
||||
if out != in {
|
||||
t.Fatalf("canonical form should be returned identity, got %q", out)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("no hits expected on canonical input, got %d", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_SlashSeparatorASCIIApostrophe(t *testing.T) {
|
||||
in := "Today's date is 2026/07/01."
|
||||
out, hits := NormalizeText(in)
|
||||
want := "Today's date is 2026-07-01."
|
||||
if out != want {
|
||||
t.Fatalf("want %q, got %q", want, out)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ApostropheVariant != "ascii" || hits[0].DateSeparator != "/" {
|
||||
t.Fatalf("unexpected hit: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_U2019Apostrophe(t *testing.T) {
|
||||
in := "Today’s date is 2026-07-01."
|
||||
out, hits := NormalizeText(in)
|
||||
want := "Today's date is 2026-07-01."
|
||||
if out != want {
|
||||
t.Fatalf("want %q, got %q", want, out)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ApostropheVariant != "u2019" || hits[0].DateSeparator != "-" {
|
||||
t.Fatalf("unexpected hit: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_U02BCApostropheWithSlash(t *testing.T) {
|
||||
in := "Todayʼs date is 2026/07/01."
|
||||
out, hits := NormalizeText(in)
|
||||
want := "Today's date is 2026-07-01."
|
||||
if out != want {
|
||||
t.Fatalf("want %q, got %q", want, out)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" || hits[0].DateSeparator != "/" {
|
||||
t.Fatalf("unexpected hit: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_U02B9Apostrophe(t *testing.T) {
|
||||
in := "Todayʹs date is 2026/07/01."
|
||||
out, hits := NormalizeText(in)
|
||||
want := "Today's date is 2026-07-01."
|
||||
if out != want {
|
||||
t.Fatalf("want %q, got %q", want, out)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ApostropheVariant != "u02b9" || hits[0].DateSeparator != "/" {
|
||||
t.Fatalf("unexpected hit: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_MixedSeparatorsNoMatch(t *testing.T) {
|
||||
// backreference \3 forces the two separators to agree
|
||||
in := "Today's date is 2026-07/01."
|
||||
out, hits := NormalizeText(in)
|
||||
if out != in {
|
||||
t.Fatalf("mixed-separator input should not be matched, got %q", out)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("expected no hits, got %d", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_NegativeLookalike(t *testing.T) {
|
||||
cases := []string{
|
||||
"Today is a great day.",
|
||||
"His date is 2026-07-01.",
|
||||
"Yesterday's date was 2026-06-30.",
|
||||
"'s date is 2026-07-01.",
|
||||
}
|
||||
for _, c := range cases {
|
||||
out, hits := NormalizeText(c)
|
||||
if out != c {
|
||||
t.Fatalf("input %q should not be modified, got %q", c, out)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("input %q should produce no hits", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_Idempotent(t *testing.T) {
|
||||
in := "Today’s date is 2026/07/01."
|
||||
out1, _ := NormalizeText(in)
|
||||
out2, hits2 := NormalizeText(out1)
|
||||
if out1 != out2 {
|
||||
t.Fatalf("normalization not idempotent: %q vs %q", out1, out2)
|
||||
}
|
||||
if len(hits2) != 0 {
|
||||
t.Fatalf("second pass should produce no hits, got %d", len(hits2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText_MultipleOccurrences(t *testing.T) {
|
||||
in := "First line.\nToday’s date is 2026/07/01.\nMore text.\nTodayʼs date is 2026-07-01.\nEnd."
|
||||
out, hits := NormalizeText(in)
|
||||
if strings.Count(out, "Today's date is") != 2 {
|
||||
t.Fatalf("expected two canonicalized sentences, got: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "’") || strings.Contains(out, "ʼ") || strings.Contains(out, "2026/07/01") {
|
||||
t.Fatalf("fingerprint characters must be gone, got: %q", out)
|
||||
}
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("expected 2 hits, got %d", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_SystemString(t *testing.T) {
|
||||
body := []byte(`{"system":"You are helpful.\nToday’s date is 2026/07/01.\nBe brief.","messages":[]}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("expected 1 hit, got %d", len(hits))
|
||||
}
|
||||
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
|
||||
t.Fatalf("output missing canonical dateline: %s", string(out))
|
||||
}
|
||||
if bytes.Contains(out, []byte("2026/07/01")) {
|
||||
t.Fatalf("output should not contain slash date: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_SystemBlocksArray(t *testing.T) {
|
||||
body := []byte(`{"system":[{"type":"text","text":"You are helpful."},{"type":"text","text":"Todayʼs date is 2026/07/01."}],"messages":[]}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" {
|
||||
t.Fatalf("unexpected hits: %+v", hits)
|
||||
}
|
||||
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
|
||||
t.Fatalf("output missing canonical dateline: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_MessagesContentStringInSystemReminder(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\n# currentDate\nToday’s date is 2026/07/01.\n</system-reminder>\nHello, please help."}]}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("expected 1 hit, got %d", len(hits))
|
||||
}
|
||||
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
|
||||
t.Fatalf("canonical dateline missing: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_MessagesContentBlocksInSystemReminder(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"},{"type":"text","text":"do X"}]}]}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("expected 1 hit, got %d", len(hits))
|
||||
}
|
||||
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
|
||||
t.Fatalf("canonical dateline missing: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_LeavesOutOfScopeUntouched(t *testing.T) {
|
||||
// User prose outside <system-reminder> that mentions today's date must
|
||||
// not be modified. tool_use.input / tool_result.content are never scanned.
|
||||
body := []byte(`{"messages":[` +
|
||||
`{"role":"user","content":"Today’s date is 2026/07/01. Please help."},` +
|
||||
`{"role":"assistant","content":[{"type":"tool_use","id":"x","name":"y","input":{"note":"Today’s date is 2026/07/01."}}]},` +
|
||||
`{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"log: Today’s date is 2026/07/01."}]}` +
|
||||
`]}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if changed {
|
||||
t.Fatalf("expected changed=false, hits=%v out=%s", hits, string(out))
|
||||
}
|
||||
if !bytes.Equal(out, body) {
|
||||
t.Fatalf("output should equal input byte-for-byte")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_Idempotent(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
|
||||
first, _, changed1 := NormalizeDateline(body)
|
||||
if !changed1 {
|
||||
t.Fatalf("expected first pass to change body")
|
||||
}
|
||||
second, _, changed2 := NormalizeDateline(first)
|
||||
if changed2 {
|
||||
t.Fatalf("second pass should not report changes")
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatalf("second pass diverged: %s vs %s", string(first), string(second))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_EmptyBody(t *testing.T) {
|
||||
out, hits, changed := NormalizeDateline(nil)
|
||||
if changed || out != nil || hits != nil {
|
||||
t.Fatalf("empty body should be no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_NoDateline(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"user","content":"hello"}],"system":"just a system prompt"}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if changed || len(hits) != 0 {
|
||||
t.Fatalf("expected no changes; changed=%v hits=%v", changed, hits)
|
||||
}
|
||||
if &out[0] != &body[0] {
|
||||
// Identity is a bonus but not strict; verify content equality at minimum
|
||||
if !bytes.Equal(out, body) {
|
||||
t.Fatalf("output must byte-match input when no changes needed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDateline_MultipleSystemReminderBlocksInSameText(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>\nsome prose\n<system-reminder>\nAlso Todayʼs date is 2026/07/01.\n</system-reminder>"}]}`)
|
||||
out, hits, changed := NormalizeDateline(body)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("expected 2 hits, got %d", len(hits))
|
||||
}
|
||||
if bytes.Contains(out, []byte("2026/07/01")) {
|
||||
t.Fatalf("slash separator must be gone: %s", string(out))
|
||||
}
|
||||
}
|
||||
@@ -846,6 +846,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"claude_oauth_system_prompt_blocks": "",
|
||||
"enable_anthropic_cache_ttl_1h_injection": false,
|
||||
"rewrite_message_cache_control": false,
|
||||
"enable_client_dateline_normalization": true,
|
||||
"antigravity_user_agent_version": "",
|
||||
"enable_fingerprint_unification": true,
|
||||
"enable_metadata_passthrough": false,
|
||||
@@ -1090,6 +1091,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"claude_oauth_system_prompt_blocks": "",
|
||||
"enable_anthropic_cache_ttl_1h_injection": false,
|
||||
"rewrite_message_cache_control": false,
|
||||
"enable_client_dateline_normalization": true,
|
||||
"antigravity_user_agent_version": "",
|
||||
"min_codex_version": "",
|
||||
"max_codex_version": "",
|
||||
|
||||
@@ -450,6 +450,13 @@ const (
|
||||
SettingKeyClaudeOAuthSystemPromptBlocks = "claude_oauth_system_prompt_blocks"
|
||||
// SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection"
|
||||
// SettingKeyEnableClientDatelineNormalization 是否对 Anthropic OAuth/SetupToken 账号
|
||||
// 的 /v1/messages 请求体做客户端 dateline 归一化(默认 true)。
|
||||
// 归一化把 system prompt / <system-reminder> 块中 "Today's date is …" 语句里的
|
||||
// 非 ASCII 撇号与 "/" 日期分隔符还原为 ASCII 撇号 + "-" 分隔符,抹除某些客户端
|
||||
// 在检测到非官方 base URL 时注入的 3 bit 隐写指纹。仅适用于 Anthropic OAuth/SetupToken
|
||||
// 账号;API Key 账号不受影响。
|
||||
SettingKeyEnableClientDatelineNormalization = "enable_client_dateline_normalization"
|
||||
// SettingKeyRewriteMessageCacheControl 是否改写 messages[*].content[*].cache_control(默认 false)
|
||||
SettingKeyRewriteMessageCacheControl = "rewrite_message_cache_control"
|
||||
// SettingKeyAntigravityUserAgentVersion Antigravity 上游 User-Agent 版本号(空值使用环境变量/默认值)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGatewayClientDatelineNormalization_Scope covers the account/switch matrix
|
||||
// for the shouldNormalizeClientDateline gate: Anthropic OAuth/SetupToken pass
|
||||
// only when the switch is on; API-Key and non-Anthropic platforms are excluded
|
||||
// unconditionally.
|
||||
func TestGatewayClientDatelineNormalization_Scope(t *testing.T) {
|
||||
repo := &gatewayTTLSettingRepo{data: map[string]string{}}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
svc := &GatewayService{
|
||||
settingService: NewSettingService(repo, &config.Config{}),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Default (missing key): fallback in parseSettings/cache loader is true.
|
||||
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
|
||||
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
|
||||
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}))
|
||||
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
|
||||
|
||||
// Switch off: no account qualifies.
|
||||
repo.data[SettingKeyEnableClientDatelineNormalization] = "false"
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
|
||||
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
|
||||
|
||||
// Switch back on: OAuth qualifies again.
|
||||
repo.data[SettingKeyEnableClientDatelineNormalization] = "true"
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
|
||||
}
|
||||
|
||||
// TestGatewayClientDatelineNormalization_HelperNoRewrite exercises the code
|
||||
// path used by Forward: the helper must return ok=false when the switch is
|
||||
// off, when the account is API-Key, when the account is nil, and when the
|
||||
// body carries no fingerprinted dateline. It must return ok=true and a
|
||||
// rewritten body when both the switch is on and the account is Anthropic
|
||||
// OAuth/SetupToken and a rewrite actually happened.
|
||||
func TestGatewayClientDatelineNormalization_HelperNoRewrite(t *testing.T) {
|
||||
repo := &gatewayTTLSettingRepo{data: map[string]string{
|
||||
SettingKeyEnableClientDatelineNormalization: "true",
|
||||
}}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
svc := &GatewayService{
|
||||
settingService: NewSettingService(repo, &config.Config{}),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
dirty := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
|
||||
clean := []byte(`{"messages":[{"role":"user","content":"just hello"}]}`)
|
||||
|
||||
// API-Key account: never rewrites, even with dirty payload.
|
||||
next, ok := svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, dirty)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, next)
|
||||
|
||||
// Nil account: safe no-op.
|
||||
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, nil, dirty)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, next)
|
||||
|
||||
// OAuth account + clean body: no changes, ok=false.
|
||||
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, clean)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, next)
|
||||
|
||||
// OAuth account + dirty body: rewritten, ok=true.
|
||||
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, dirty)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, next)
|
||||
require.Contains(t, string(next), "Today's date is 2026-07-01.")
|
||||
require.NotContains(t, string(next), "2026/07/01")
|
||||
require.NotContains(t, string(next), "Today’s date is")
|
||||
|
||||
// SetupToken account + dirty body: rewritten, ok=true.
|
||||
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, dirty)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, string(next), "Today's date is 2026-07-01.")
|
||||
|
||||
// Switch off: even OAuth account is not rewritten.
|
||||
repo.data[SettingKeyEnableClientDatelineNormalization] = "false"
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, dirty)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, next)
|
||||
}
|
||||
|
||||
// TestGatewayClientDatelineNormalization_LeavesUserProseUntouched double-checks
|
||||
// that the pure normalizer never touches content outside <system-reminder>
|
||||
// blocks. This is an integration guard between the switch-gated helper and
|
||||
// the pkg/anthropicfp scope contract, tripped by anyone who broadens scope.
|
||||
func TestGatewayClientDatelineNormalization_LeavesUserProseUntouched(t *testing.T) {
|
||||
repo := &gatewayTTLSettingRepo{data: map[string]string{
|
||||
SettingKeyEnableClientDatelineNormalization: "true",
|
||||
}}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
svc := &GatewayService{
|
||||
settingService: NewSettingService(repo, &config.Config{}),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// User prose that happens to include a fingerprint-looking sentence
|
||||
// (outside <system-reminder>) must be preserved byte-for-byte.
|
||||
body := []byte(`{"messages":[{"role":"user","content":"I wrote: Today’s date is 2026/07/01. What do you think?"}]}`)
|
||||
next, ok := svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, body)
|
||||
require.False(t, ok, "must not rewrite user prose outside <system-reminder>")
|
||||
require.Nil(t, next)
|
||||
|
||||
// Direct pure-fn check for redundancy.
|
||||
out, hits, changed := anthropicfp.NormalizeDateline(body)
|
||||
require.False(t, changed)
|
||||
require.Empty(t, hits)
|
||||
require.Equal(t, body, out)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
@@ -4781,6 +4782,32 @@ func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, ac
|
||||
return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx)
|
||||
}
|
||||
|
||||
// shouldNormalizeClientDateline reports whether the request body's client
|
||||
// dateline should be normalized before forwarding to Anthropic. The switch is
|
||||
// scoped to Anthropic OAuth/SetupToken accounts only; API-Key accounts and
|
||||
// non-Anthropic platforms bypass this step entirely.
|
||||
func (s *GatewayService) shouldNormalizeClientDateline(ctx context.Context, account *Account) bool {
|
||||
if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil {
|
||||
return false
|
||||
}
|
||||
return s.settingService.IsClientDatelineNormalizationEnabled(ctx)
|
||||
}
|
||||
|
||||
// normalizeClientDatelineIfEnabled applies dateline normalization to body when
|
||||
// the switch is on and the account qualifies. Returns (nextBody, true) only
|
||||
// when the body actually changed; otherwise returns (nil, false) so callers
|
||||
// can skip the writeback.
|
||||
func (s *GatewayService) normalizeClientDatelineIfEnabled(ctx context.Context, account *Account, body []byte) ([]byte, bool) {
|
||||
if !s.shouldNormalizeClientDateline(ctx, account) {
|
||||
return nil, false
|
||||
}
|
||||
next, _, changed := anthropicfp.NormalizeDateline(body)
|
||||
if !changed {
|
||||
return nil, false
|
||||
}
|
||||
return next, true
|
||||
}
|
||||
|
||||
func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Context) (bool, string, string) {
|
||||
if s == nil || s.settingService == nil {
|
||||
return true, "", ""
|
||||
@@ -4930,6 +4957,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。
|
||||
// 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期
|
||||
// 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外,
|
||||
// 保证真实 Claude Code 客户端注入的指纹同样被清洗。
|
||||
if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok {
|
||||
if err := replaceBody(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 强制执行 cache_control 块数量限制(最多 4 个)
|
||||
if err := replaceBody(enforceCacheControlLimit(body)); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -112,6 +112,7 @@ type cachedGatewayForwardingSettings struct {
|
||||
claudeOAuthSystemPromptBlocks string
|
||||
anthropicCacheTTL1hInjection bool
|
||||
rewriteMessageCacheControl bool
|
||||
clientDatelineNormalization bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
@@ -2207,6 +2208,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks
|
||||
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
|
||||
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
|
||||
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
|
||||
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||||
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||||
// codex_cli_only 加固
|
||||
@@ -2345,6 +2347,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
||||
claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
|
||||
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: settings.RewriteMessageCacheControl,
|
||||
clientDatelineNormalization: settings.EnableClientDatelineNormalization,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
s.antigravityUAVersionSF.Forget("antigravity_user_agent_version")
|
||||
@@ -2548,6 +2551,7 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
||||
|
||||
type gatewayForwardingSettingsResult struct {
|
||||
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
|
||||
clientDatelineNormalization bool
|
||||
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
|
||||
}
|
||||
|
||||
@@ -2563,6 +2567,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: cached.clientDatelineNormalization,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2578,6 +2583,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: cached.clientDatelineNormalization,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -2592,6 +2598,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
SettingKeyClaudeOAuthSystemPromptBlocks,
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection,
|
||||
SettingKeyRewriteMessageCacheControl,
|
||||
SettingKeyEnableClientDatelineNormalization,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||
@@ -2602,9 +2609,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
claudeOAuthSystemPromptInjection: true,
|
||||
anthropicCacheTTL1hInjection: false,
|
||||
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
|
||||
clientDatelineNormalization: true,
|
||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||
})
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
|
||||
}
|
||||
fp := true
|
||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||
@@ -2623,6 +2631,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
|
||||
rewriteMessageCacheControl = v == "true"
|
||||
}
|
||||
clientDatelineNormalization := true
|
||||
if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
|
||||
clientDatelineNormalization = v == "true"
|
||||
}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: fp,
|
||||
metadataPassthrough: mp,
|
||||
@@ -2632,6 +2644,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
|
||||
anthropicCacheTTL1hInjection: cacheTTL1h,
|
||||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: clientDatelineNormalization,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
return gatewayForwardingSettingsResult{
|
||||
@@ -2643,12 +2656,13 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
|
||||
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
|
||||
cacheTTL1h: cacheTTL1h,
|
||||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: clientDatelineNormalization,
|
||||
}, nil
|
||||
})
|
||||
if r, ok := val.(gatewayForwardingSettingsResult); ok {
|
||||
return r
|
||||
}
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true}
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
|
||||
}
|
||||
|
||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||
@@ -2669,6 +2683,12 @@ func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context
|
||||
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
|
||||
}
|
||||
|
||||
// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
|
||||
// 的客户端 dateline 归一化。默认开启。
|
||||
func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
|
||||
}
|
||||
|
||||
// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
|
||||
// system block switch, legacy custom expansion prompt, and configurable blocks JSON.
|
||||
// Empty values mean use the built-in Claude Code default blocks.
|
||||
@@ -3173,6 +3193,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyAllowUngroupedKeyScheduling: "false",
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
|
||||
SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
|
||||
SettingKeyEnableClientDatelineNormalization: "true",
|
||||
SettingKeyAntigravityUserAgentVersion: "",
|
||||
SettingKeyOpenAICodexUserAgent: "",
|
||||
SettingPaymentVisibleMethodAlipaySource: "",
|
||||
@@ -3711,6 +3732,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
} else {
|
||||
result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl()
|
||||
}
|
||||
if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
|
||||
result.EnableClientDatelineNormalization = v == "true"
|
||||
} else {
|
||||
result.EnableClientDatelineNormalization = true
|
||||
}
|
||||
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
|
||||
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
|
||||
// codex_cli_only 加固
|
||||
|
||||
@@ -197,6 +197,7 @@ type SystemSettings struct {
|
||||
ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认
|
||||
ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认
|
||||
EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
|
||||
EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true)
|
||||
RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
|
||||
AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
|
||||
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认
|
||||
|
||||
@@ -561,6 +561,7 @@ export interface SystemSettings {
|
||||
claude_oauth_system_prompt_blocks: string;
|
||||
enable_anthropic_cache_ttl_1h_injection: boolean;
|
||||
rewrite_message_cache_control: boolean;
|
||||
enable_client_dateline_normalization: boolean;
|
||||
antigravity_user_agent_version: string;
|
||||
openai_codex_user_agent: string;
|
||||
// codex_cli_only 加固
|
||||
@@ -811,6 +812,7 @@ export interface UpdateSettingsRequest {
|
||||
claude_oauth_system_prompt_blocks?: string;
|
||||
enable_anthropic_cache_ttl_1h_injection?: boolean;
|
||||
rewrite_message_cache_control?: boolean;
|
||||
enable_client_dateline_normalization?: boolean;
|
||||
antigravity_user_agent_version?: string;
|
||||
openai_codex_user_agent?: string;
|
||||
// codex_cli_only 加固
|
||||
|
||||
@@ -5827,6 +5827,8 @@ export default {
|
||||
anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.',
|
||||
rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints',
|
||||
rewriteMessageCacheControlHint: 'Default off: preserve client cache_control on message content blocks. When enabled, client breakpoints are stripped and proxy breakpoints are injected for clients that do not manage caching themselves.',
|
||||
clientDatelineNormalization: 'Client Dateline Normalization',
|
||||
clientDatelineNormalizationHint: 'Default on. Rewrites the "Today\'s date is …" sentence in Anthropic OAuth/Setup Token requests back to a canonical ASCII apostrophe and hyphen date format, erasing steganographic fingerprint bits some clients inject when they detect a non-official base URL. Applies to system prompts and <system-reminder> blocks only; API-Key accounts are unaffected.',
|
||||
antigravityUserAgentVersion: 'Antigravity UA Version',
|
||||
antigravityUserAgentVersionPlaceholder: '1.23.2',
|
||||
antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.',
|
||||
|
||||
@@ -5981,6 +5981,8 @@ export default {
|
||||
anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。',
|
||||
rewriteMessageCacheControl: '改写消息缓存断点',
|
||||
rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。',
|
||||
clientDatelineNormalization: '客户端 dateline 归一化',
|
||||
clientDatelineNormalizationHint: '默认开启。将 Anthropic OAuth/Setup Token 请求体中 "Today\'s date is …" 语句里的撇号与日期分隔符还原为 ASCII 撇号 + 短横线 (2026-07-01) 的规范形态,抹除某些客户端在检测到非官方 base URL 时注入的隐写指纹位。仅作用于 system prompt 与 <system-reminder> 块内,API Key 账号不受影响。',
|
||||
antigravityUserAgentVersion: 'Antigravity UA 版本',
|
||||
antigravityUserAgentVersionPlaceholder: '1.23.2',
|
||||
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
|
||||
|
||||
@@ -4368,6 +4368,31 @@
|
||||
<Toggle v-model="form.rewrite_message_cache_control" />
|
||||
</div>
|
||||
|
||||
<!-- 客户端 dateline 归一化(仅 Anthropic OAuth/SetupToken) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.clientDatelineNormalization",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.clientDatelineNormalizationHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.enable_client_dateline_normalization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Antigravity UA 版本 -->
|
||||
<div>
|
||||
<label
|
||||
@@ -8078,6 +8103,7 @@ const form = reactive<SettingsForm>({
|
||||
claude_oauth_system_prompt_blocks: defaultClaudeOAuthSystemPromptBlocks,
|
||||
enable_anthropic_cache_ttl_1h_injection: false,
|
||||
rewrite_message_cache_control: false,
|
||||
enable_client_dateline_normalization: true,
|
||||
antigravity_user_agent_version: "",
|
||||
openai_codex_user_agent: "",
|
||||
// codex_cli_only 加固
|
||||
@@ -9282,6 +9308,8 @@ async function saveSettings() {
|
||||
enable_anthropic_cache_ttl_1h_injection:
|
||||
form.enable_anthropic_cache_ttl_1h_injection,
|
||||
rewrite_message_cache_control: form.rewrite_message_cache_control,
|
||||
enable_client_dateline_normalization:
|
||||
form.enable_client_dateline_normalization,
|
||||
antigravity_user_agent_version:
|
||||
form.antigravity_user_agent_version?.trim() || "",
|
||||
openai_codex_user_agent:
|
||||
|
||||
@@ -383,6 +383,7 @@ const baseSettingsResponse = {
|
||||
claude_oauth_system_prompt_blocks: "",
|
||||
enable_anthropic_cache_ttl_1h_injection: false,
|
||||
rewrite_message_cache_control: false,
|
||||
enable_client_dateline_normalization: true,
|
||||
antigravity_user_agent_version: "",
|
||||
openai_codex_user_agent: "",
|
||||
payment_enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user