diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 7ad51660..66661579 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -225,6 +225,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableMetadataPassthrough: settings.EnableMetadataPassthrough, EnableCCHSigning: settings.EnableCCHSigning, EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + RewriteMessageCacheControl: settings.RewriteMessageCacheControl, WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, @@ -515,6 +516,7 @@ type UpdateSettingsRequest struct { EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` EnableCCHSigning *bool `json:"enable_cch_signing"` EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"` + RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"` // Payment visible method routing PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` @@ -1415,6 +1417,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.EnableAnthropicCacheTTL1hInjection }(), + RewriteMessageCacheControl: func() bool { + if req.RewriteMessageCacheControl != nil { + return *req.RewriteMessageCacheControl + } + return previousSettings.RewriteMessageCacheControl + }(), PaymentVisibleMethodAlipaySource: func() string { if req.PaymentVisibleMethodAlipaySource != nil { return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource) @@ -1747,6 +1755,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, EnableCCHSigning: updatedSettings.EnableCCHSigning, EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, + RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, @@ -2143,6 +2152,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection { changed = append(changed, "enable_anthropic_cache_ttl_1h_injection") } + if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl { + changed = append(changed, "rewrite_message_cache_control") + } if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { changed = append(changed, "payment_visible_method_alipay_source") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 2d4cefa1..1c231597 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -162,6 +162,7 @@ type SystemSettings struct { EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` EnableCCHSigning bool `json:"enable_cch_signing"` EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"` + RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"` // Web Search Emulation WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"` diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 27358865..4cde2869 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -773,6 +773,7 @@ func TestAPIContracts(t *testing.T) { "backend_mode_enabled": false, "enable_cch_signing": false, "enable_anthropic_cache_ttl_1h_injection": false, + "rewrite_message_cache_control": false, "enable_fingerprint_unification": true, "enable_metadata_passthrough": false, "web_search_emulation_enabled": false, @@ -988,6 +989,7 @@ func TestAPIContracts(t *testing.T) { "enable_metadata_passthrough": false, "enable_cch_signing": false, "enable_anthropic_cache_ttl_1h_injection": false, + "rewrite_message_cache_control": false, "web_search_emulation_enabled": false, "payment_visible_method_alipay_source": "", "payment_visible_method_wxpay_source": "", diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 8eb90a6b..481b8015 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -370,6 +370,8 @@ const ( SettingKeyEnableCCHSigning = "enable_cch_signing" // SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection" + // SettingKeyRewriteMessageCacheControl 是否改写 messages[*].content[*].cache_control(默认 false) + SettingKeyRewriteMessageCacheControl = "rewrite_message_cache_control" // Balance Low Notification SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关 diff --git a/backend/internal/service/gateway_body_order_test.go b/backend/internal/service/gateway_body_order_test.go index e0c3cafd..aaf27224 100644 --- a/backend/internal/service/gateway_body_order_test.go +++ b/backend/internal/service/gateway_body_order_test.go @@ -150,6 +150,21 @@ func TestEnforceCacheControlLimit_PreservesTopLevelFieldOrder(t *testing.T) { require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`)) } +func TestEnforceCacheControlLimit_CountsToolsAndPreservesMessageAnchorsFirst(t *testing.T) { + body := []byte(`{"alpha":1,"system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"m1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m2","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m3","cache_control":{"type":"ephemeral"}}]}],"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral"}}],"omega":2}`) + + result := enforceCacheControlLimit(body) + resultStr := string(result) + + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"tools"`, `"omega"`) + require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`)) + require.True(t, gjson.GetBytes(result, "system.0.cache_control").Exists()) + require.True(t, gjson.GetBytes(result, "messages.0.content.0.cache_control").Exists()) + require.True(t, gjson.GetBytes(result, "messages.0.content.1.cache_control").Exists()) + require.True(t, gjson.GetBytes(result, "messages.0.content.2.cache_control").Exists()) + require.False(t, gjson.GetBytes(result, "tools.0.cache_control").Exists()) +} + func TestInjectAnthropicCacheControlTTL1h_OnlyUpdatesExistingEphemeralCacheControl(t *testing.T) { body := []byte(`{"alpha":1,"cache_control":{"type":"ephemeral"},"system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"5m"}},{"type":"text","text":"plain"}],"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}},{"type":"text","text":"non","cache_control":{"type":"persistent","ttl":"5m"}}]}],"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral"}}],"omega":2}`) diff --git a/backend/internal/service/gateway_messages_cache.go b/backend/internal/service/gateway_messages_cache.go index cb5384ba..8f7e6b5b 100644 --- a/backend/internal/service/gateway_messages_cache.go +++ b/backend/internal/service/gateway_messages_cache.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -11,7 +12,7 @@ import ( // stripMessageCacheControl 移除 $.messages[*].content[*].cache_control。 // 与 Parrot _strip_message_cache_control 语义一致。 // -// 为什么必须整体清空:客户端(特别是 Claude Code)经常把 cache_control 打在 +// 旧策略为什么整体清空:客户端(特别是 Claude Code)经常把 cache_control 打在 // "当前最后一条 user message" 上;下一轮对话 messages 追加后,原本的最后一条 // 变成中间某条,cache_control 还挂着就导致"前缀签名变化",破坏缓存命中。 // 统一由代理重新打断点(addMessageCacheBreakpoints)才能在多轮间稳定。 @@ -85,6 +86,25 @@ func addMessageCacheBreakpoints(body []byte) []byte { return body } +// rewriteMessageCacheControlIfEnabled 按系统设置决定是否执行旧版 messages 缓存断点改写。 +func (s *GatewayService) rewriteMessageCacheControlIfEnabled(ctx context.Context, body []byte) []byte { + if s == nil || !s.isRewriteMessageCacheControlEnabled(ctx) { + return body + } + body = stripMessageCacheControl(body) + return addMessageCacheBreakpoints(body) +} + +func (s *GatewayService) isRewriteMessageCacheControlEnabled(ctx context.Context) bool { + if s == nil { + return false + } + if s.settingService != nil { + return s.settingService.IsRewriteMessageCacheControlEnabled(ctx) + } + return false +} + // injectCacheControlOnLastContentBlock 把 cache_control 断点打在 messages[idx] // 的最后一个 content block 上。若 content 是 string,先升级成单块 text 数组 // (对齐 Parrot _inject_cache_on_msg 的行为)。 diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 3a003bd2..6151d78e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1251,13 +1251,11 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( body, _ = normalizeClaudeOAuthRequestBody(body, model, normalizeOpts) // Phase D+E+F: messages cache 策略 + 工具名混淆 + tools[-1] 断点 - // 对齐 Parrot transform_request 里剩余的字段级改写。三步顺序有语义约束: - // 1) strip:先清除客户端的 messages[*].cache_control(多轮稳定性) - // 2) breakpoints:再注入 2 个断点(最后一条 + 倒数第二个 user turn) - // 3) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1] + // 对齐 Parrot transform_request 里剩余的字段级改写。顺序有语义约束: + // 1) messages cache:仅在配置开启时清除客户端断点并注入代理断点 + // 2) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1] // 上打断点;mapping 存入 gin.Context 供响应侧 bytes.Replace 还原。 - body = stripMessageCacheControl(body) - body = addMessageCacheBreakpoints(body) + body = s.rewriteMessageCacheControlIfEnabled(ctx, body) if rw := buildToolNameRewriteFromBody(body); rw != nil { body = applyToolNameRewriteToBody(body, rw) @@ -4108,7 +4106,7 @@ type cacheControlPath struct { log string } -func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, messagePaths []string, systemPaths []string) { +func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, messagePaths []string, toolPaths []string, systemPaths []string) { system := gjson.GetBytes(body, "system") if system.IsArray() { sysIndex := 0 @@ -4157,17 +4155,29 @@ func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, }) } - return invalidThinking, messagePaths, systemPaths + tools := gjson.GetBytes(body, "tools") + if tools.IsArray() { + toolIndex := 0 + tools.ForEach(func(_, tool gjson.Result) bool { + if tool.Get("cache_control").Exists() { + toolPaths = append(toolPaths, fmt.Sprintf("tools.%d.cache_control", toolIndex)) + } + toolIndex++ + return true + }) + } + + return invalidThinking, messagePaths, toolPaths, systemPaths } // enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个) -// 超限时优先从 messages 中移除 cache_control,保护 system 中的缓存控制 +// 超限时优先移除工具断点,再移除 messages 断点,最后才移除 system 断点。 func enforceCacheControlLimit(body []byte) []byte { if len(body) == 0 { return body } - invalidThinking, messagePaths, systemPaths := collectCacheControlPaths(body) + invalidThinking, messagePaths, toolPaths, systemPaths := collectCacheControlPaths(body) out := body modified := false @@ -4185,7 +4195,7 @@ func enforceCacheControlLimit(body []byte) []byte { logger.LegacyPrintf("service.gateway", "%s", item.log) } - count := len(messagePaths) + len(systemPaths) + count := len(messagePaths) + len(toolPaths) + len(systemPaths) if count <= maxCacheControlBlocks { if modified { return out @@ -4193,8 +4203,22 @@ func enforceCacheControlLimit(body []byte) []byte { return body } - // 超限:优先从 messages 中移除,再从 system 中移除 + // 超限:优先从 tools 中移除,再从 messages 中移除,最后才从 system 中移除。 remaining := count - maxCacheControlBlocks + for i := len(toolPaths) - 1; i >= 0 && remaining > 0; i-- { + path := toolPaths[i] + if !gjson.GetBytes(out, path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, path) + if !ok { + continue + } + out = next + modified = true + remaining-- + } + for _, path := range messagePaths { if remaining <= 0 { break @@ -4418,11 +4442,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) - // D/E/F: messages cache 策略 + 工具名混淆 + tools[-1] 断点 + // D/E/F: 可选 messages cache 策略 + 工具名混淆 + tools[-1] 断点 // 与 forward_as_chat_completions / forward_as_responses 路径对齐, - // 保证原生 /v1/messages 路径也经过完整的 Parrot 字段级改写。 - body = stripMessageCacheControl(body) - body = addMessageCacheBreakpoints(body) + // 原生 /v1/messages 路径也走同一套可配置字段级改写。 + body = s.rewriteMessageCacheControlIfEnabled(ctx, body) if rw := buildToolNameRewriteFromBody(body); rw != nil { body = applyToolNameRewriteToBody(body, rw) c.Set(toolNameRewriteKey, rw) @@ -8819,8 +8842,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) - body = stripMessageCacheControl(body) - body = addMessageCacheBreakpoints(body) + body = s.rewriteMessageCacheControlIfEnabled(ctx, body) if rw := buildToolNameRewriteFromBody(body); rw != nil { body = applyToolNameRewriteToBody(body, rw) } else { diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go index 4a72885c..9e6f6806 100644 --- a/backend/internal/service/gateway_tool_rewrite_test.go +++ b/backend/internal/service/gateway_tool_rewrite_test.go @@ -1,9 +1,11 @@ package service import ( + "context" "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -188,6 +190,40 @@ func TestAddMessageCacheBreakpoints_StringContentPromoted(t *testing.T) { require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String()) } +func TestRewriteMessageCacheControlIfEnabled_DefaultKeepsClientAnchors(t *testing.T) { + body := []byte(`{"messages":[ + {"role":"user","content":[{"type":"text","text":"stable","cache_control":{"type":"ephemeral","ttl":"1h"}}]}, + {"role":"assistant","content":[{"type":"text","text":"ok"}]}, + {"role":"user","content":[{"type":"text","text":"latest","cache_control":{"type":"ephemeral","ttl":"5m"}}]} + ]}`) + + out := (&GatewayService{}).rewriteMessageCacheControlIfEnabled(context.Background(), body) + + require.JSONEq(t, string(body), string(out)) + require.Equal(t, "1h", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String()) + require.Equal(t, "5m", gjson.GetBytes(out, "messages.2.content.0.cache_control.ttl").String()) +} + +func TestRewriteMessageCacheControlIfEnabled_OptInPreservesLegacyRewrite(t *testing.T) { + body := []byte(`{"messages":[ + {"role":"user","content":[{"type":"text","text":"stable","cache_control":{"type":"ephemeral","ttl":"1h"}}]}, + {"role":"assistant","content":[{"type":"text","text":"ok"}]}, + {"role":"user","content":[{"type":"text","text":"latest","cache_control":{"type":"ephemeral","ttl":"1h"}}]}, + {"role":"assistant","content":[{"type":"text","text":"done"}]} + ]}`) + repo := &gatewayTTLSettingRepo{data: map[string]string{ + SettingKeyRewriteMessageCacheControl: "true", + }} + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{}) + svc := &GatewayService{settingService: NewSettingService(repo, &config.Config{})} + + out := svc.rewriteMessageCacheControlIfEnabled(context.Background(), body) + + require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String()) + require.False(t, gjson.GetBytes(out, "messages.2.content.0.cache_control").Exists()) + require.Equal(t, "5m", gjson.GetBytes(out, "messages.3.content.0.cache_control.ttl").String()) +} + func TestBuildToolNameRewriteFromBody_ReverseOrderedByLengthDesc(t *testing.T) { // 超过阈值触发动态映射,验证 ReverseOrdered 按假名长度倒序排列 body := []byte(`{"tools":[ diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 283a239b..37c7bb8d 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -87,6 +87,7 @@ type cachedGatewayForwardingSettings struct { metadataPassthrough bool cchSigning bool anthropicCacheTTL1hInjection bool + rewriteMessageCacheControl bool expiresAt int64 // unix nano } @@ -1584,6 +1585,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) + updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) @@ -1652,6 +1654,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { metadataPassthrough: settings.EnableMetadataPassthrough, cchSigning: settings.EnableCCHSigning, anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + rewriteMessageCacheControl: settings.RewriteMessageCacheControl, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) @@ -1664,6 +1667,10 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { } } +func (s *SettingService) defaultRewriteMessageCacheControl() bool { + return false +} + func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error { if len(items) == 0 { return nil @@ -1815,17 +1822,18 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { } type gatewayForwardingSettingsResult struct { - fp, mp, cch, cacheTTL1h bool + fp, mp, cch, cacheTTL1h, rewriteMessageCacheControl bool } func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, } } } @@ -1833,10 +1841,11 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, }, nil } } @@ -1847,6 +1856,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) SettingKeyEnableMetadataPassthrough, SettingKeyEnableCCHSigning, SettingKeyEnableAnthropicCacheTTL1hInjection, + SettingKeyRewriteMessageCacheControl, }) if err != nil { slog.Warn("failed to get gateway forwarding settings", "error", err) @@ -1855,9 +1865,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) metadataPassthrough: false, cchSigning: false, anthropicCacheTTL1hInjection: false, + rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), }) - return gatewayForwardingSettingsResult{fp: true}, nil + return gatewayForwardingSettingsResult{fp: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil } fp := true if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { @@ -1866,14 +1877,25 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) mp := values[SettingKeyEnableMetadataPassthrough] == "true" cch := values[SettingKeyEnableCCHSigning] == "true" cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" + rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl() + if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" { + rewriteMessageCacheControl = v == "true" + } gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: fp, metadataPassthrough: mp, cchSigning: cch, anthropicCacheTTL1hInjection: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) - return gatewayForwardingSettingsResult{fp: fp, mp: mp, cch: cch, cacheTTL1h: cacheTTL1h}, nil + return gatewayForwardingSettingsResult{ + fp: fp, + mp: mp, + cch: cch, + cacheTTL1h: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, + }, nil }) if r, ok := val.(gatewayForwardingSettingsResult); ok { return r @@ -1894,6 +1916,11 @@ func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Conte return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h } +// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。 +func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl +} + // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) @@ -2358,6 +2385,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // 分组隔离(默认不允许未分组 Key 调度) SettingKeyAllowUngroupedKeyScheduling: "false", SettingKeyEnableAnthropicCacheTTL1hInjection: "false", + SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", @@ -2734,6 +2762,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" + if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" { + result.RewriteMessageCacheControl = v == "true" + } else { + result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl() + } // Web search emulation: quick enabled check from the JSON config if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 80b8b32a..ebef0d9d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -172,6 +172,7 @@ type SystemSettings struct { EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) + RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false) // Web Search Emulation WebSearchEmulationEnabled bool // 是否启用 web search 模拟 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 01d6969d..9cccdf3e 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -477,6 +477,7 @@ export interface SystemSettings { enable_metadata_passthrough: boolean; enable_cch_signing: boolean; enable_anthropic_cache_ttl_1h_injection: boolean; + rewrite_message_cache_control: boolean; web_search_emulation_enabled?: boolean; // Payment configuration @@ -673,6 +674,7 @@ export interface UpdateSettingsRequest { enable_metadata_passthrough?: boolean; enable_cch_signing?: boolean; enable_anthropic_cache_ttl_1h_injection?: boolean; + rewrite_message_cache_control?: boolean; // Payment configuration payment_enabled?: boolean; risk_control_enabled?: boolean; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 70a8363d..df65c2cc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5335,6 +5335,8 @@ export default { cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.', anthropicCacheTTL1hInjection: 'Anthropic Cache TTL Injection', 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.', }, webSearchEmulation: { title: 'Web Search Emulation', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b43f768c..5204c37e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5494,6 +5494,8 @@ export default { cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。', anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入', anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。', + rewriteMessageCacheControl: '改写消息缓存断点', + rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。', }, webSearchEmulation: { title: 'Web Search 模拟', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 963bf2e0..23655f8f 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3428,6 +3428,29 @@ v-model="form.enable_anthropic_cache_ttl_1h_injection" /> + + +
+
+ +

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

+
+ +
@@ -6547,6 +6570,7 @@ const form = reactive({ enable_metadata_passthrough: false, enable_cch_signing: false, enable_anthropic_cache_ttl_1h_injection: false, + rewrite_message_cache_control: false, // Balance & quota notification balance_low_notify_enabled: false, balance_low_notify_threshold: 0, @@ -7617,6 +7641,7 @@ async function saveSettings() { enable_cch_signing: form.enable_cch_signing, enable_anthropic_cache_ttl_1h_injection: form.enable_anthropic_cache_ttl_1h_injection, + rewrite_message_cache_control: form.rewrite_message_cache_control, // Payment configuration payment_enabled: form.payment_enabled, risk_control_enabled: form.risk_control_enabled, diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 915d9425..dce93f07 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -369,6 +369,7 @@ const baseSettingsResponse = { enable_metadata_passthrough: false, enable_cch_signing: false, enable_anthropic_cache_ttl_1h_injection: false, + rewrite_message_cache_control: false, payment_enabled: true, payment_min_amount: 1, payment_max_amount: 10000, @@ -601,6 +602,26 @@ describe("admin SettingsView payment visible method controls", () => { ); }); + it("submits message cache_control rewrite gateway setting", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + rewrite_message_cache_control: true, + }); + + const wrapper = mountView(); + + await flushPromises(); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + rewrite_message_cache_control: true, + }), + ); + }); + it("updates provider enablement immediately and reloads providers", async () => { const provider = { id: 7,