mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
feat: 添加 Anthropic 缓存 TTL 注入开关
This commit is contained in:
@@ -209,6 +209,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
EnableCCHSigning: settings.EnableCCHSigning,
|
||||
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
||||
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
|
||||
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
|
||||
@@ -444,6 +445,7 @@ type UpdateSettingsRequest struct {
|
||||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||||
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||
|
||||
// Payment visible method routing
|
||||
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
|
||||
@@ -1273,6 +1275,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.EnableCCHSigning
|
||||
}(),
|
||||
EnableAnthropicCacheTTL1hInjection: func() bool {
|
||||
if req.EnableAnthropicCacheTTL1hInjection != nil {
|
||||
return *req.EnableAnthropicCacheTTL1hInjection
|
||||
}
|
||||
return previousSettings.EnableAnthropicCacheTTL1hInjection
|
||||
}(),
|
||||
PaymentVisibleMethodAlipaySource: func() string {
|
||||
if req.PaymentVisibleMethodAlipaySource != nil {
|
||||
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
|
||||
@@ -1570,6 +1578,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
||||
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
||||
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
|
||||
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
|
||||
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
||||
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
||||
@@ -1949,6 +1958,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.EnableCCHSigning != after.EnableCCHSigning {
|
||||
changed = append(changed, "enable_cch_signing")
|
||||
}
|
||||
if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection {
|
||||
changed = append(changed, "enable_anthropic_cache_ttl_1h_injection")
|
||||
}
|
||||
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
|
||||
changed = append(changed, "payment_visible_method_alipay_source")
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ type SystemSettings struct {
|
||||
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
||||
EnableCCHSigning bool `json:"enable_cch_signing"`
|
||||
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||
|
||||
// Web Search Emulation
|
||||
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
|
||||
|
||||
@@ -336,6 +336,8 @@ const (
|
||||
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
|
||||
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
|
||||
SettingKeyEnableCCHSigning = "enable_cch_signing"
|
||||
// SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection"
|
||||
|
||||
// Balance Low Notification
|
||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||
|
||||
@@ -1,13 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type gatewayTTLSettingRepo struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) Get(context.Context, string) (*Setting, error) {
|
||||
return nil, ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) GetValue(_ context.Context, key string) (string, error) {
|
||||
if r == nil {
|
||||
return "", ErrSettingNotFound
|
||||
}
|
||||
v, ok := r.data[key]
|
||||
if !ok {
|
||||
return "", ErrSettingNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) Set(_ context.Context, key, value string) error {
|
||||
if r == nil {
|
||||
return errors.New("setting repo is nil")
|
||||
}
|
||||
if r.data == nil {
|
||||
r.data = map[string]string{}
|
||||
}
|
||||
r.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
if r == nil {
|
||||
return result, nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if v, ok := r.data[key]; ok {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
|
||||
if r == nil {
|
||||
return errors.New("setting repo is nil")
|
||||
}
|
||||
if r.data == nil {
|
||||
r.data = map[string]string{}
|
||||
}
|
||||
for key, value := range settings {
|
||||
r.data[key] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) GetAll(context.Context) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
if r == nil {
|
||||
return result, nil
|
||||
}
|
||||
for key, value := range r.data {
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *gatewayTTLSettingRepo) Delete(_ context.Context, key string) error {
|
||||
if r != nil {
|
||||
delete(r.data, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertJSONTokenOrder(t *testing.T, body string, tokens ...string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -71,3 +149,60 @@ func TestEnforceCacheControlLimit_PreservesTopLevelFieldOrder(t *testing.T) {
|
||||
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`)
|
||||
require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`))
|
||||
}
|
||||
|
||||
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}`)
|
||||
|
||||
result := injectAnthropicCacheControlTTL1h(body)
|
||||
resultStr := string(result)
|
||||
|
||||
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"cache_control"`, `"system"`, `"messages"`, `"tools"`, `"omega"`)
|
||||
require.Equal(t, "1h", gjson.GetBytes(result, "cache_control.ttl").String())
|
||||
require.Equal(t, "1h", gjson.GetBytes(result, "system.0.cache_control.ttl").String())
|
||||
require.False(t, gjson.GetBytes(result, "system.1.cache_control").Exists())
|
||||
require.Equal(t, "1h", gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").String())
|
||||
require.Equal(t, "5m", gjson.GetBytes(result, "messages.0.content.1.cache_control.ttl").String())
|
||||
require.Equal(t, "1h", gjson.GetBytes(result, "tools.0.cache_control.ttl").String())
|
||||
}
|
||||
|
||||
func TestGatewayCacheTTLGlobalSetting_TargetResolution(t *testing.T) {
|
||||
repo := &gatewayTTLSettingRepo{data: map[string]string{
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection: "true",
|
||||
}}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
svc := &GatewayService{
|
||||
settingService: NewSettingService(repo, &config.Config{}),
|
||||
}
|
||||
account := &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}
|
||||
|
||||
target, ok := svc.resolveCacheTTLUsageOverrideTarget(context.Background(), account)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, cacheTTLTarget5m, target)
|
||||
|
||||
account.Extra = map[string]any{
|
||||
"cache_ttl_override_enabled": true,
|
||||
"cache_ttl_override_target": "1h",
|
||||
}
|
||||
target, ok = svc.resolveCacheTTLUsageOverrideTarget(context.Background(), account)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, cacheTTLTarget1h, target)
|
||||
}
|
||||
|
||||
func TestGatewayCacheTTLGlobalSetting_RequestInjectionScope(t *testing.T) {
|
||||
repo := &gatewayTTLSettingRepo{data: map[string]string{
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection: "true",
|
||||
}}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
svc := &GatewayService{
|
||||
settingService: NewSettingService(repo, &config.Config{}),
|
||||
}
|
||||
|
||||
require.True(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
|
||||
require.True(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
|
||||
require.False(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}))
|
||||
require.False(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
|
||||
|
||||
repo.data[SettingKeyEnableAnthropicCacheTTL1hInjection] = "false"
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
|
||||
require.False(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ const (
|
||||
claudeMimicDebugInfoKey = "claude_mimic_debug_info"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTLTarget5m = "5m"
|
||||
cacheTTLTarget1h = "1h"
|
||||
)
|
||||
|
||||
// ForceCacheBillingContextKey 强制缓存计费上下文键
|
||||
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
|
||||
type forceCacheBillingKeyType struct{}
|
||||
@@ -4226,6 +4231,87 @@ func enforceCacheControlLimit(body []byte) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
// injectAnthropicCacheControlTTL1h 将已有 ephemeral cache_control 块的 ttl 强制写为 1h。
|
||||
// 仅修改已经存在的 cache_control,不新增缓存断点。
|
||||
func injectAnthropicCacheControlTTL1h(body []byte) []byte {
|
||||
return forceEphemeralCacheControlTTL(body, cacheTTLTarget1h)
|
||||
}
|
||||
|
||||
func forceEphemeralCacheControlTTL(body []byte, ttl string) []byte {
|
||||
if len(body) == 0 || ttl == "" {
|
||||
return body
|
||||
}
|
||||
out := body
|
||||
var paths []string
|
||||
addPath := func(path string, value gjson.Result) {
|
||||
cc := value.Get("cache_control")
|
||||
if !cc.Exists() || cc.Get("type").String() != "ephemeral" {
|
||||
return
|
||||
}
|
||||
if cc.Get("ttl").String() == ttl {
|
||||
return
|
||||
}
|
||||
paths = append(paths, path+".cache_control.ttl")
|
||||
}
|
||||
|
||||
if topCC := gjson.GetBytes(body, "cache_control"); topCC.Exists() && topCC.Get("type").String() == "ephemeral" && topCC.Get("ttl").String() != ttl {
|
||||
paths = append(paths, "cache_control.ttl")
|
||||
}
|
||||
|
||||
system := gjson.GetBytes(body, "system")
|
||||
if system.IsArray() {
|
||||
idx := -1
|
||||
system.ForEach(func(_, block gjson.Result) bool {
|
||||
idx++
|
||||
addPath(fmt.Sprintf("system.%d", idx), block)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if messages.IsArray() {
|
||||
msgIdx := -1
|
||||
messages.ForEach(func(_, msg gjson.Result) bool {
|
||||
msgIdx++
|
||||
content := msg.Get("content")
|
||||
if !content.IsArray() {
|
||||
return true
|
||||
}
|
||||
contentIdx := -1
|
||||
content.ForEach(func(_, block gjson.Result) bool {
|
||||
contentIdx++
|
||||
addPath(fmt.Sprintf("messages.%d.content.%d", msgIdx, contentIdx), block)
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if tools.IsArray() {
|
||||
idx := -1
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
idx++
|
||||
addPath(fmt.Sprintf("tools.%d", idx), tool)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if next, err := sjson.SetBytes(out, path, ttl); err == nil {
|
||||
out = next
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, account *Account) bool {
|
||||
if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil {
|
||||
return false
|
||||
}
|
||||
return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx)
|
||||
}
|
||||
|
||||
// Forward 转发请求到Claude API
|
||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
@@ -4385,6 +4471,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
logger.LegacyPrintf("service.gateway", "Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource)
|
||||
}
|
||||
|
||||
if s.shouldInjectAnthropicCacheTTL1h(ctx, account) {
|
||||
body = injectAnthropicCacheControlTTL1h(body)
|
||||
}
|
||||
|
||||
// 获取凭证
|
||||
token, tokenType, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
@@ -7225,9 +7315,9 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
||||
}
|
||||
}
|
||||
|
||||
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类
|
||||
if account.IsCacheTTLOverrideEnabled() {
|
||||
overrideTarget := account.GetCacheTTLOverrideTarget()
|
||||
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类。
|
||||
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||
if eventType == "message_start" {
|
||||
if msg, ok := event["message"].(map[string]any); ok {
|
||||
if u, ok := msg["usage"].(map[string]any); ok {
|
||||
@@ -7634,6 +7724,19 @@ func rewriteCacheCreationJSON(usageObj map[string]any, target string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *GatewayService) resolveCacheTTLUsageOverrideTarget(ctx context.Context, account *Account) (string, bool) {
|
||||
if account == nil {
|
||||
return "", false
|
||||
}
|
||||
if account.IsCacheTTLOverrideEnabled() {
|
||||
return account.GetCacheTTLOverrideTarget(), true
|
||||
}
|
||||
if account.IsAnthropicOAuthOrSetupToken() && s != nil && s.settingService != nil && s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) {
|
||||
return cacheTTLTarget5m, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*ClaudeUsage, error) {
|
||||
// 更新5h窗口状态
|
||||
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
||||
@@ -7670,9 +7773,9 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
|
||||
}
|
||||
}
|
||||
|
||||
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类
|
||||
if account.IsCacheTTLOverrideEnabled() {
|
||||
overrideTarget := account.GetCacheTTLOverrideTarget()
|
||||
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类。
|
||||
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
|
||||
// 同步更新 body JSON 中的嵌套 cache_creation 对象
|
||||
if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens", response.Usage.CacheCreation5mTokens); err == nil {
|
||||
@@ -8240,10 +8343,11 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
result.Usage.InputTokens = 0
|
||||
}
|
||||
|
||||
// Cache TTL Override: 确保计费时 token 分类与账号设置一致
|
||||
// Cache TTL Override: 确保计费时 token 分类与账号设置一致。
|
||||
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||
cacheTTLOverridden := false
|
||||
if account.IsCacheTTLOverrideEnabled() {
|
||||
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
|
||||
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||
applyCacheTTLOverride(&result.Usage, overrideTarget)
|
||||
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ type cachedGatewayForwardingSettings struct {
|
||||
fingerprintUnification bool
|
||||
metadataPassthrough bool
|
||||
cchSigning bool
|
||||
anthropicCacheTTL1hInjection bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
@@ -1245,6 +1246,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||||
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
|
||||
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
|
||||
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
|
||||
updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled)
|
||||
@@ -1308,6 +1310,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
||||
fingerprintUnification: settings.EnableFingerprintUnification,
|
||||
metadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
cchSigning: settings.EnableCCHSigning,
|
||||
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
||||
@@ -1415,22 +1418,30 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||||
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
||||
type gatewayForwardingSettingsResult struct {
|
||||
fp, mp, cch, cacheTTL1h 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 cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: cached.fingerprintUnification,
|
||||
mp: cached.metadataPassthrough,
|
||||
cch: cached.cchSigning,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
}
|
||||
}
|
||||
type gwfResult struct {
|
||||
fp, mp, cch bool
|
||||
}
|
||||
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning}, nil
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: cached.fingerprintUnification,
|
||||
mp: cached.metadataPassthrough,
|
||||
cch: cached.cchSigning,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||||
@@ -1439,6 +1450,7 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing
|
||||
SettingKeyEnableFingerprintUnification,
|
||||
SettingKeyEnableMetadataPassthrough,
|
||||
SettingKeyEnableCCHSigning,
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||
@@ -1446,9 +1458,10 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing
|
||||
fingerprintUnification: true,
|
||||
metadataPassthrough: false,
|
||||
cchSigning: false,
|
||||
anthropicCacheTTL1hInjection: false,
|
||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||
})
|
||||
return gwfResult{true, false, false}, nil
|
||||
return gatewayForwardingSettingsResult{fp: true}, nil
|
||||
}
|
||||
fp := true
|
||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||
@@ -1456,18 +1469,33 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing
|
||||
}
|
||||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
cch := values[SettingKeyEnableCCHSigning] == "true"
|
||||
cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: fp,
|
||||
metadataPassthrough: mp,
|
||||
cchSigning: cch,
|
||||
anthropicCacheTTL1hInjection: cacheTTL1h,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
return gwfResult{fp, mp, cch}, nil
|
||||
return gatewayForwardingSettingsResult{fp: fp, mp: mp, cch: cch, cacheTTL1h: cacheTTL1h}, nil
|
||||
})
|
||||
if r, ok := val.(gwfResult); ok {
|
||||
return r.fp, r.mp, r.cch
|
||||
if r, ok := val.(gatewayForwardingSettingsResult); ok {
|
||||
return r
|
||||
}
|
||||
return true, false, false // fail-open defaults
|
||||
return gatewayForwardingSettingsResult{fp: true}
|
||||
}
|
||||
|
||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||||
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
||||
result := s.getGatewayForwardingSettingsCached(ctx)
|
||||
return result.fp, result.mp, result.cch
|
||||
}
|
||||
|
||||
// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。
|
||||
func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h
|
||||
}
|
||||
|
||||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||||
@@ -1881,6 +1909,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
|
||||
// 分组隔离(默认不允许未分组 Key 调度)
|
||||
SettingKeyAllowUngroupedKeyScheduling: "false",
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
|
||||
SettingPaymentVisibleMethodAlipaySource: "",
|
||||
SettingPaymentVisibleMethodWxpaySource: "",
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
||||
@@ -2228,6 +2257,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
}
|
||||
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
||||
result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||||
|
||||
// Web search emulation: quick enabled check from the JSON config
|
||||
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
|
||||
|
||||
@@ -152,6 +152,7 @@ type SystemSettings struct {
|
||||
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
||||
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
||||
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
|
||||
EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
|
||||
|
||||
// Web Search Emulation
|
||||
WebSearchEmulationEnabled bool // 是否启用 web search 模拟
|
||||
|
||||
@@ -439,6 +439,7 @@ export interface SystemSettings {
|
||||
enable_fingerprint_unification: boolean;
|
||||
enable_metadata_passthrough: boolean;
|
||||
enable_cch_signing: boolean;
|
||||
enable_anthropic_cache_ttl_1h_injection: boolean;
|
||||
web_search_emulation_enabled?: boolean;
|
||||
|
||||
// Payment configuration
|
||||
@@ -609,6 +610,7 @@ export interface UpdateSettingsRequest {
|
||||
enable_fingerprint_unification?: boolean;
|
||||
enable_metadata_passthrough?: boolean;
|
||||
enable_cch_signing?: boolean;
|
||||
enable_anthropic_cache_ttl_1h_injection?: boolean;
|
||||
// Payment configuration
|
||||
payment_enabled?: boolean;
|
||||
payment_min_amount?: number;
|
||||
|
||||
@@ -5019,6 +5019,8 @@ export default {
|
||||
metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.',
|
||||
cchSigning: 'CCH Signing',
|
||||
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.',
|
||||
},
|
||||
webSearchEmulation: {
|
||||
title: 'Web Search Emulation',
|
||||
|
||||
@@ -5178,6 +5178,8 @@ export default {
|
||||
metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
|
||||
cchSigning: 'CCH 签名',
|
||||
cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。',
|
||||
anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入',
|
||||
anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。',
|
||||
},
|
||||
webSearchEmulation: {
|
||||
title: 'Web Search 模拟',
|
||||
|
||||
@@ -3057,6 +3057,31 @@
|
||||
</div>
|
||||
<Toggle v-model="form.enable_cch_signing" />
|
||||
</div>
|
||||
|
||||
<!-- Anthropic Cache TTL 1h Injection -->
|
||||
<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.anthropicCacheTTL1hInjection",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.anthropicCacheTTL1hInjectionHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.enable_anthropic_cache_ttl_1h_injection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Web Search Emulation -->
|
||||
@@ -5810,6 +5835,7 @@ const form = reactive<SettingsForm>({
|
||||
enable_fingerprint_unification: true,
|
||||
enable_metadata_passthrough: false,
|
||||
enable_cch_signing: false,
|
||||
enable_anthropic_cache_ttl_1h_injection: false,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
@@ -6718,6 +6744,8 @@ async function saveSettings() {
|
||||
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
||||
enable_metadata_passthrough: form.enable_metadata_passthrough,
|
||||
enable_cch_signing: form.enable_cch_signing,
|
||||
enable_anthropic_cache_ttl_1h_injection:
|
||||
form.enable_anthropic_cache_ttl_1h_injection,
|
||||
// Payment configuration
|
||||
payment_enabled: form.payment_enabled,
|
||||
payment_min_amount: Number(form.payment_min_amount) || 0,
|
||||
|
||||
@@ -362,6 +362,7 @@ const baseSettingsResponse = {
|
||||
enable_fingerprint_unification: true,
|
||||
enable_metadata_passthrough: false,
|
||||
enable_cch_signing: false,
|
||||
enable_anthropic_cache_ttl_1h_injection: false,
|
||||
payment_enabled: true,
|
||||
payment_min_amount: 1,
|
||||
payment_max_amount: 10000,
|
||||
@@ -567,6 +568,26 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled");
|
||||
});
|
||||
|
||||
it("submits Anthropic cache TTL injection gateway setting", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
enable_anthropic_cache_ttl_1h_injection: true,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enable_anthropic_cache_ttl_1h_injection: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates provider enablement immediately and reloads providers", async () => {
|
||||
const provider = {
|
||||
id: 7,
|
||||
|
||||
Reference in New Issue
Block a user