From 59e9356c5181af48c310f66d1b84f7797086a1d8 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 1 Jul 2026 10:54:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=B9=E9=99=A4=20Anthropic=20OAuth?= =?UTF-8?q?=20=E8=AF=B7=E6=B1=82=E4=B8=AD=E5=AE=A2=E6=88=B7=E7=AB=AF=20dat?= =?UTF-8?q?eline=20=E9=9A=90=E5=86=99=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 /v1/messages 转发到 Anthropic OAuth/setup-token 账号的请求做 dateline 归一化,将 system prompt 与 块中 "Today's date is …" 语句里的 4 种撇号变体与 "/" 日期分隔符还原为 ASCII 撇号 + "-",抹除某些 客户端在检测到非官方 base URL 时注入的 3 bit 隐写指纹。API Key 账号不受 影响。新增系统设置开关 enable_client_dateline_normalization,默认开启。 --- .../internal/handler/admin/setting_handler.go | 12 + backend/internal/handler/dto/settings.go | 1 + backend/internal/pkg/anthropicfp/dateline.go | 288 ++++++++++++++++++ .../internal/pkg/anthropicfp/dateline_test.go | 248 +++++++++++++++ backend/internal/server/api_contract_test.go | 2 + backend/internal/service/domain_constants.go | 7 + .../gateway_dateline_normalization_test.go | 123 ++++++++ backend/internal/service/gateway_service.go | 37 +++ backend/internal/service/setting_service.go | 30 +- backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/settings.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/views/admin/SettingsView.vue | 28 ++ .../admin/__tests__/SettingsView.spec.ts | 1 + 15 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 backend/internal/pkg/anthropicfp/dateline.go create mode 100644 backend/internal/pkg/anthropicfp/dateline_test.go create mode 100644 backend/internal/service/gateway_dateline_normalization_test.go diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 4f4807130e..13d4c3fdbc 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index e55e06b59d..e117d19ba3 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/pkg/anthropicfp/dateline.go b/backend/internal/pkg/anthropicfp/dateline.go new file mode 100644 index 0000000000..6428433506 --- /dev/null +++ b/backend/internal/pkg/anthropicfp/dateline.go @@ -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 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).*?`) + +// 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 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, "") { + 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 `...` 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 +} diff --git a/backend/internal/pkg/anthropicfp/dateline_test.go b/backend/internal/pkg/anthropicfp/dateline_test.go new file mode 100644 index 0000000000..0c1abcd5c4 --- /dev/null +++ b/backend/internal/pkg/anthropicfp/dateline_test.go @@ -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":"\n# currentDate\nToday’s date is 2026/07/01.\n\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":"\nToday’s date is 2026/07/01.\n"},{"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 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":"\nToday’s date is 2026/07/01.\n"}]}`) + 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":"\nToday’s date is 2026/07/01.\n\nsome prose\n\nAlso Todayʼs date is 2026/07/01.\n"}]}`) + 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)) + } +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 41bbc524e4..4b4e5e0b04 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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": "", diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index c83b683670..15e9ec73bf 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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 / 块中 "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 版本号(空值使用环境变量/默认值) diff --git a/backend/internal/service/gateway_dateline_normalization_test.go b/backend/internal/service/gateway_dateline_normalization_test.go new file mode 100644 index 0000000000..125e03f733 --- /dev/null +++ b/backend/internal/service/gateway_dateline_normalization_test.go @@ -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":"\nToday’s date is 2026/07/01.\n"}]}`) + 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 +// 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 ) 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 ") + 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) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index d9f3ef93b6..5d8d8779c2 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 81415d457f..00e9d9029b 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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 加固 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 17d2d741ca..c31e7976c5 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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;空值使用内置默认 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 9210e8c84f..44fbe29187 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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 加固 diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a210ee4144..33b0d15a41 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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 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.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4b4ae03b5e..d79d08b83d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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 与 块内,API Key 账号不受影响。', antigravityUserAgentVersion: 'Antigravity UA 版本', antigravityUserAgentVersionPlaceholder: '1.23.2', antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 42d9af5fcc..11109f4bf0 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4368,6 +4368,31 @@ + +
+
+ +

+ {{ + t( + "admin.settings.gatewayForwarding.clientDatelineNormalizationHint", + ) + }} +

+
+ +
+