mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d801595c9 | ||
|
|
9c448f89a8 | ||
|
|
73b872998e | ||
|
|
094e1171ef | ||
|
|
733627cf9d | ||
|
|
f084d30d65 | ||
|
|
8ad099baa6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -122,7 +122,7 @@ scripts
|
|||||||
.code-review-state
|
.code-review-state
|
||||||
#openspec/
|
#openspec/
|
||||||
code-reviews/
|
code-reviews/
|
||||||
#AGENTS.md
|
AGENTS.md
|
||||||
backend/cmd/server/server
|
backend/cmd/server/server
|
||||||
deploy/docker-compose.override.yml
|
deploy/docker-compose.override.yml
|
||||||
.gocache/
|
.gocache/
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.119
|
0.1.120
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||||
EnableCCHSigning: settings.EnableCCHSigning,
|
EnableCCHSigning: settings.EnableCCHSigning,
|
||||||
|
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||||
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
||||||
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
|
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
|
||||||
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
|
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
|
||||||
@@ -441,9 +442,10 @@ type UpdateSettingsRequest struct {
|
|||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
|
|
||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||||
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||||||
|
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||||
|
|
||||||
// Payment visible method routing
|
// Payment visible method routing
|
||||||
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
|
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
|
||||||
@@ -1273,6 +1275,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.EnableCCHSigning
|
return previousSettings.EnableCCHSigning
|
||||||
}(),
|
}(),
|
||||||
|
EnableAnthropicCacheTTL1hInjection: func() bool {
|
||||||
|
if req.EnableAnthropicCacheTTL1hInjection != nil {
|
||||||
|
return *req.EnableAnthropicCacheTTL1hInjection
|
||||||
|
}
|
||||||
|
return previousSettings.EnableAnthropicCacheTTL1hInjection
|
||||||
|
}(),
|
||||||
PaymentVisibleMethodAlipaySource: func() string {
|
PaymentVisibleMethodAlipaySource: func() string {
|
||||||
if req.PaymentVisibleMethodAlipaySource != nil {
|
if req.PaymentVisibleMethodAlipaySource != nil {
|
||||||
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
|
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
|
||||||
@@ -1570,6 +1578,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
||||||
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
||||||
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
||||||
|
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
|
||||||
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
|
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
|
||||||
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
||||||
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
||||||
@@ -1949,6 +1958,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.EnableCCHSigning != after.EnableCCHSigning {
|
if before.EnableCCHSigning != after.EnableCCHSigning {
|
||||||
changed = append(changed, "enable_cch_signing")
|
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 {
|
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
|
||||||
changed = append(changed, "payment_visible_method_alipay_source")
|
changed = append(changed, "payment_visible_method_alipay_source")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,9 +142,10 @@ type SystemSettings struct {
|
|||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
|
|
||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
||||||
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
||||||
EnableCCHSigning bool `json:"enable_cch_signing"`
|
EnableCCHSigning bool `json:"enable_cch_signing"`
|
||||||
|
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||||
|
|
||||||
// Web Search Emulation
|
// Web Search Emulation
|
||||||
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
|
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
|
||||||
|
|||||||
@@ -262,6 +262,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
sessionHash := h.gatewayService.GenerateSessionHash(parsedReq)
|
sessionHash := h.gatewayService.GenerateSessionHash(parsedReq)
|
||||||
|
|
||||||
|
// [DEBUG-STICKY] 打印会话 hash 生成结果
|
||||||
|
reqLog.Info("sticky.session_hash_generated",
|
||||||
|
zap.String("session_hash", sessionHash),
|
||||||
|
zap.String("metadata_user_id_raw", parsedReq.MetadataUserID),
|
||||||
|
)
|
||||||
|
|
||||||
// 获取平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则使用分组平台
|
// 获取平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则使用分组平台
|
||||||
platform := ""
|
platform := ""
|
||||||
if forcePlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
|
if forcePlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
|
||||||
@@ -278,6 +284,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
var sessionBoundAccountID int64
|
var sessionBoundAccountID int64
|
||||||
if sessionKey != "" {
|
if sessionKey != "" {
|
||||||
sessionBoundAccountID, _ = h.gatewayService.GetCachedSessionAccountID(c.Request.Context(), apiKey.GroupID, sessionKey)
|
sessionBoundAccountID, _ = h.gatewayService.GetCachedSessionAccountID(c.Request.Context(), apiKey.GroupID, sessionKey)
|
||||||
|
// [DEBUG-STICKY] 打印粘性会话查询结果
|
||||||
|
reqLog.Info("sticky.cache_lookup",
|
||||||
|
zap.String("session_key", sessionKey),
|
||||||
|
zap.Int64("bound_account_id", sessionBoundAccountID),
|
||||||
|
)
|
||||||
if sessionBoundAccountID > 0 {
|
if sessionBoundAccountID > 0 {
|
||||||
prefetchedGroupID := int64(0)
|
prefetchedGroupID := int64(0)
|
||||||
if apiKey.GroupID != nil {
|
if apiKey.GroupID != nil {
|
||||||
@@ -286,6 +297,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
ctx := service.WithPrefetchedStickySession(c.Request.Context(), sessionBoundAccountID, prefetchedGroupID, h.metadataBridgeEnabled())
|
ctx := service.WithPrefetchedStickySession(c.Request.Context(), sessionBoundAccountID, prefetchedGroupID, h.metadataBridgeEnabled())
|
||||||
c.Request = c.Request.WithContext(ctx)
|
c.Request = c.Request.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
reqLog.Info("sticky.no_session_key", zap.String("session_hash", sessionHash))
|
||||||
}
|
}
|
||||||
// 判断是否真的绑定了粘性会话:有 sessionKey 且已经绑定到某个账号
|
// 判断是否真的绑定了粘性会话:有 sessionKey 且已经绑定到某个账号
|
||||||
hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0
|
hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0
|
||||||
@@ -536,6 +549,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
// 选择支持该模型的账号
|
// 选择支持该模型的账号
|
||||||
|
reqLog.Info("sticky.selecting_account",
|
||||||
|
zap.String("session_key", sessionKey),
|
||||||
|
zap.Int64("sticky_bound_account_id", sessionBoundAccountID),
|
||||||
|
zap.Bool("has_bound_session", hasBoundSession),
|
||||||
|
zap.Int("failed_account_count", len(fs.FailedAccountIDs)),
|
||||||
|
)
|
||||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
|
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(fs.FailedAccountIDs) == 0 {
|
if len(fs.FailedAccountIDs) == 0 {
|
||||||
@@ -569,6 +588,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
account := selection.Account
|
account := selection.Account
|
||||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||||
|
|
||||||
|
// [DEBUG-STICKY] 打印账号选择结果
|
||||||
|
reqLog.Info("sticky.account_selected",
|
||||||
|
zap.Int64("selected_account_id", account.ID),
|
||||||
|
zap.String("account_name", account.Name),
|
||||||
|
zap.Bool("slot_acquired", selection.Acquired),
|
||||||
|
zap.Bool("has_wait_plan", selection.WaitPlan != nil),
|
||||||
|
zap.Int64("sticky_bound_account_id", sessionBoundAccountID),
|
||||||
|
zap.Bool("sticky_honored", sessionBoundAccountID > 0 && sessionBoundAccountID == account.ID),
|
||||||
|
)
|
||||||
|
|
||||||
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||||
if account.IsInterceptWarmupEnabled() {
|
if account.IsInterceptWarmupEnabled() {
|
||||||
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, reqStream, isClaudeCodeClient)
|
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, reqStream, isClaudeCodeClient)
|
||||||
@@ -635,6 +664,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// Slot acquired: no longer waiting in queue.
|
// Slot acquired: no longer waiting in queue.
|
||||||
releaseWait()
|
releaseWait()
|
||||||
|
reqLog.Info("sticky.bind_after_wait",
|
||||||
|
zap.String("session_key", sessionKey),
|
||||||
|
zap.Int64("account_id", account.ID),
|
||||||
|
)
|
||||||
if err := h.gatewayService.BindStickySession(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
|
if err := h.gatewayService.BindStickySession(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
|
||||||
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -829,6 +862,17 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定粘性会话(成功转发后绑定/刷新)
|
||||||
|
// - 无现有绑定(首次请求):创建绑定
|
||||||
|
// - 选中账号与粘性账号一致:刷新 TTL
|
||||||
|
// - 粘性账号因负载/RPM 被跳过、选中了其他账号:不覆盖原绑定,
|
||||||
|
// 下次请求粘性账号恢复后仍可命中
|
||||||
|
if sessionKey != "" && (sessionBoundAccountID == 0 || sessionBoundAccountID == account.ID) {
|
||||||
|
if err := h.gatewayService.BindStickySession(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
|
||||||
|
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||||
userAgent := c.GetHeader("User-Agent")
|
userAgent := c.GetHeader("User-Agent")
|
||||||
clientIP := ip.GetClientIP(c)
|
clientIP := ip.GetClientIP(c)
|
||||||
|
|||||||
@@ -449,11 +449,69 @@ func buildSchedulerMetadataAccount(account service.Account) service.Account {
|
|||||||
SessionWindowStart: account.SessionWindowStart,
|
SessionWindowStart: account.SessionWindowStart,
|
||||||
SessionWindowEnd: account.SessionWindowEnd,
|
SessionWindowEnd: account.SessionWindowEnd,
|
||||||
SessionWindowStatus: account.SessionWindowStatus,
|
SessionWindowStatus: account.SessionWindowStatus,
|
||||||
|
AccountGroups: filterSchedulerAccountGroups(account.AccountGroups),
|
||||||
|
GroupIDs: filterSchedulerGroupIDs(account.GroupIDs, account.AccountGroups),
|
||||||
Credentials: filterSchedulerCredentials(account.Credentials),
|
Credentials: filterSchedulerCredentials(account.Credentials),
|
||||||
Extra: filterSchedulerExtra(account.Extra),
|
Extra: filterSchedulerExtra(account.Extra),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterSchedulerAccountGroups(accountGroups []service.AccountGroup) []service.AccountGroup {
|
||||||
|
if len(accountGroups) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]service.AccountGroup, 0, len(accountGroups))
|
||||||
|
for _, ag := range accountGroups {
|
||||||
|
if ag.GroupID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, service.AccountGroup{
|
||||||
|
AccountID: ag.AccountID,
|
||||||
|
GroupID: ag.GroupID,
|
||||||
|
Priority: ag.Priority,
|
||||||
|
CreatedAt: ag.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterSchedulerGroupIDs(groupIDs []int64, accountGroups []service.AccountGroup) []int64 {
|
||||||
|
if len(groupIDs) == 0 && len(accountGroups) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[int64]struct{}, len(groupIDs)+len(accountGroups))
|
||||||
|
filtered := make([]int64, 0, len(groupIDs)+len(accountGroups))
|
||||||
|
for _, id := range groupIDs {
|
||||||
|
if id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
filtered = append(filtered, id)
|
||||||
|
}
|
||||||
|
for _, ag := range accountGroups {
|
||||||
|
if ag.GroupID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[ag.GroupID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ag.GroupID] = struct{}{}
|
||||||
|
filtered = append(filtered, ag.GroupID)
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
func filterSchedulerCredentials(credentials map[string]any) map[string]any {
|
func filterSchedulerCredentials(credentials map[string]any) map[string]any {
|
||||||
if len(credentials) == 0 {
|
if len(credentials) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T)
|
|||||||
SessionWindowStart: &now,
|
SessionWindowStart: &now,
|
||||||
SessionWindowEnd: &windowEnd,
|
SessionWindowEnd: &windowEnd,
|
||||||
SessionWindowStatus: "active",
|
SessionWindowStatus: "active",
|
||||||
|
GroupIDs: []int64{bucket.GroupID},
|
||||||
|
AccountGroups: []service.AccountGroup{
|
||||||
|
{
|
||||||
|
AccountID: 101,
|
||||||
|
GroupID: bucket.GroupID,
|
||||||
|
Priority: 5,
|
||||||
|
Group: &service.Group{ID: bucket.GroupID, Name: "gemini-group"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, cache.SetSnapshot(ctx, bucket, []service.Account{account}))
|
require.NoError(t, cache.SetSnapshot(ctx, bucket, []service.Account{account}))
|
||||||
@@ -79,10 +88,17 @@ func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T)
|
|||||||
require.Equal(t, 4, got.GetMaxSessions())
|
require.Equal(t, 4, got.GetMaxSessions())
|
||||||
require.Equal(t, 11, got.GetSessionIdleTimeoutMinutes())
|
require.Equal(t, 11, got.GetSessionIdleTimeoutMinutes())
|
||||||
require.Nil(t, got.Extra["unused_large_field"])
|
require.Nil(t, got.Extra["unused_large_field"])
|
||||||
|
require.Equal(t, []int64{bucket.GroupID}, got.GroupIDs)
|
||||||
|
require.Len(t, got.AccountGroups, 1)
|
||||||
|
require.Equal(t, account.ID, got.AccountGroups[0].AccountID)
|
||||||
|
require.Equal(t, bucket.GroupID, got.AccountGroups[0].GroupID)
|
||||||
|
require.Nil(t, got.AccountGroups[0].Group)
|
||||||
|
|
||||||
full, err := cache.GetAccount(ctx, account.ID)
|
full, err := cache.GetAccount(ctx, account.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, full)
|
require.NotNil(t, full)
|
||||||
require.Equal(t, "secret-access-token", full.GetCredential("access_token"))
|
require.Equal(t, "secret-access-token", full.GetCredential("access_token"))
|
||||||
require.Equal(t, strings.Repeat("x", 4096), full.GetCredential("huge_blob"))
|
require.Equal(t, strings.Repeat("x", 4096), full.GetCredential("huge_blob"))
|
||||||
|
require.Len(t, full.AccountGroups, 1)
|
||||||
|
require.NotNil(t, full.AccountGroups[0].Group)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,3 +31,43 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) {
|
|||||||
require.Equal(t, true, got.Extra["mixed_scheduling"])
|
require.Equal(t, true, got.Extra["mixed_scheduling"])
|
||||||
require.Nil(t, got.Extra["unused_large_field"])
|
require.Nil(t, got.Extra["unused_large_field"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildSchedulerMetadataAccount_KeepsSlimGroupMembership(t *testing.T) {
|
||||||
|
account := service.Account{
|
||||||
|
ID: 42,
|
||||||
|
Platform: service.PlatformAnthropic,
|
||||||
|
GroupIDs: []int64{7, 9, 7, 0},
|
||||||
|
AccountGroups: []service.AccountGroup{
|
||||||
|
{
|
||||||
|
AccountID: 42,
|
||||||
|
GroupID: 7,
|
||||||
|
Priority: 2,
|
||||||
|
Account: &service.Account{ID: 42, Name: "drop-from-metadata"},
|
||||||
|
Group: &service.Group{ID: 7, Name: "drop-from-metadata"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AccountID: 42,
|
||||||
|
GroupID: 11,
|
||||||
|
Priority: 3,
|
||||||
|
Group: &service.Group{ID: 11, Name: "drop-from-metadata"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AccountID: 42,
|
||||||
|
GroupID: 0,
|
||||||
|
Priority: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buildSchedulerMetadataAccount(account)
|
||||||
|
|
||||||
|
require.Equal(t, []int64{7, 9, 11}, got.GroupIDs)
|
||||||
|
require.Len(t, got.AccountGroups, 2)
|
||||||
|
require.Equal(t, int64(42), got.AccountGroups[0].AccountID)
|
||||||
|
require.Equal(t, int64(7), got.AccountGroups[0].GroupID)
|
||||||
|
require.Equal(t, 2, got.AccountGroups[0].Priority)
|
||||||
|
require.Nil(t, got.AccountGroups[0].Account)
|
||||||
|
require.Nil(t, got.AccountGroups[0].Group)
|
||||||
|
require.Equal(t, int64(11), got.AccountGroups[1].GroupID)
|
||||||
|
require.Nil(t, got.Groups)
|
||||||
|
}
|
||||||
|
|||||||
@@ -740,6 +740,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"allow_ungrouped_key_scheduling": false,
|
"allow_ungrouped_key_scheduling": false,
|
||||||
"backend_mode_enabled": false,
|
"backend_mode_enabled": false,
|
||||||
"enable_cch_signing": false,
|
"enable_cch_signing": false,
|
||||||
|
"enable_anthropic_cache_ttl_1h_injection": false,
|
||||||
"enable_fingerprint_unification": true,
|
"enable_fingerprint_unification": true,
|
||||||
"enable_metadata_passthrough": false,
|
"enable_metadata_passthrough": false,
|
||||||
"web_search_emulation_enabled": false,
|
"web_search_emulation_enabled": false,
|
||||||
@@ -934,6 +935,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"enable_fingerprint_unification": true,
|
"enable_fingerprint_unification": true,
|
||||||
"enable_metadata_passthrough": false,
|
"enable_metadata_passthrough": false,
|
||||||
"enable_cch_signing": false,
|
"enable_cch_signing": false,
|
||||||
|
"enable_anthropic_cache_ttl_1h_injection": false,
|
||||||
"web_search_emulation_enabled": false,
|
"web_search_emulation_enabled": false,
|
||||||
"payment_visible_method_alipay_source": "",
|
"payment_visible_method_alipay_source": "",
|
||||||
"payment_visible_method_wxpay_source": "",
|
"payment_visible_method_wxpay_source": "",
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ const (
|
|||||||
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
|
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
|
||||||
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
|
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
|
||||||
SettingKeyEnableCCHSigning = "enable_cch_signing"
|
SettingKeyEnableCCHSigning = "enable_cch_signing"
|
||||||
|
// SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
|
||||||
|
SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection"
|
||||||
|
|
||||||
// Balance Low Notification
|
// Balance Low Notification
|
||||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||||
|
|||||||
@@ -1,13 +1,91 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func assertJSONTokenOrder(t *testing.T, body string, tokens ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -71,3 +149,60 @@ func TestEnforceCacheControlLimit_PreservesTopLevelFieldOrder(t *testing.T) {
|
|||||||
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`)
|
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`)
|
||||||
require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`))
|
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"
|
claudeMimicDebugInfoKey = "claude_mimic_debug_info"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheTTLTarget5m = "5m"
|
||||||
|
cacheTTLTarget1h = "1h"
|
||||||
|
)
|
||||||
|
|
||||||
// ForceCacheBillingContextKey 强制缓存计费上下文键
|
// ForceCacheBillingContextKey 强制缓存计费上下文键
|
||||||
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
|
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
|
||||||
type forceCacheBillingKeyType struct{}
|
type forceCacheBillingKeyType struct{}
|
||||||
@@ -654,15 +659,31 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
|
|||||||
|
|
||||||
// 1. 最高优先级:从 metadata.user_id 提取 session_xxx
|
// 1. 最高优先级:从 metadata.user_id 提取 session_xxx
|
||||||
if parsed.MetadataUserID != "" {
|
if parsed.MetadataUserID != "" {
|
||||||
if uid := ParseMetadataUserID(parsed.MetadataUserID); uid != nil && uid.SessionID != "" {
|
uid := ParseMetadataUserID(parsed.MetadataUserID)
|
||||||
|
if uid != nil && uid.SessionID != "" {
|
||||||
|
slog.Info("sticky.hash_source",
|
||||||
|
"source", "metadata_user_id",
|
||||||
|
"session_id", uid.SessionID,
|
||||||
|
"device_id", uid.DeviceID,
|
||||||
|
"is_new_format", uid.IsNewFormat,
|
||||||
|
)
|
||||||
return uid.SessionID
|
return uid.SessionID
|
||||||
}
|
}
|
||||||
|
slog.Info("sticky.hash_metadata_parse_failed",
|
||||||
|
"metadata_user_id", parsed.MetadataUserID,
|
||||||
|
"parsed_nil", uid == nil,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 提取带 cache_control: {type: "ephemeral"} 的内容
|
// 2. 提取带 cache_control: {type: "ephemeral"} 的内容
|
||||||
cacheableContent := s.extractCacheableContent(parsed)
|
cacheableContent := s.extractCacheableContent(parsed)
|
||||||
if cacheableContent != "" {
|
if cacheableContent != "" {
|
||||||
return s.hashContent(cacheableContent)
|
hash := s.hashContent(cacheableContent)
|
||||||
|
slog.Info("sticky.hash_source",
|
||||||
|
"source", "cacheable_content",
|
||||||
|
"hash", hash,
|
||||||
|
)
|
||||||
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 最后 fallback: 使用 session上下文 + system + 所有消息的完整摘要串
|
// 3. 最后 fallback: 使用 session上下文 + system + 所有消息的完整摘要串
|
||||||
@@ -702,7 +723,13 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if combined.Len() > 0 {
|
if combined.Len() > 0 {
|
||||||
return s.hashContent(combined.String())
|
hash := s.hashContent(combined.String())
|
||||||
|
slog.Info("sticky.hash_source",
|
||||||
|
"source", "message_content_fallback",
|
||||||
|
"hash", hash,
|
||||||
|
"content_len", combined.Len(),
|
||||||
|
)
|
||||||
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
@@ -1406,14 +1433,29 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
}
|
}
|
||||||
|
|
||||||
var stickyAccountID int64
|
var stickyAccountID int64
|
||||||
|
var stickySource string
|
||||||
if prefetch := prefetchedStickyAccountIDFromContext(ctx, groupID); prefetch > 0 {
|
if prefetch := prefetchedStickyAccountIDFromContext(ctx, groupID); prefetch > 0 {
|
||||||
stickyAccountID = prefetch
|
stickyAccountID = prefetch
|
||||||
|
stickySource = "prefetch"
|
||||||
} else if sessionHash != "" && s.cache != nil {
|
} else if sessionHash != "" && s.cache != nil {
|
||||||
if accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash); err == nil {
|
if accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash); err == nil {
|
||||||
stickyAccountID = accountID
|
stickyAccountID = accountID
|
||||||
|
stickySource = "cache"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [DEBUG-STICKY] 调度器入口日志
|
||||||
|
slog.Info("sticky.scheduler_entry",
|
||||||
|
"group_id", derefGroupID(groupID),
|
||||||
|
"session_hash", shortSessionHash(sessionHash),
|
||||||
|
"sticky_account_id", stickyAccountID,
|
||||||
|
"sticky_source", stickySource,
|
||||||
|
"model", requestedModel,
|
||||||
|
"load_batch", cfg.LoadBatchEnabled,
|
||||||
|
"has_concurrency_svc", s.concurrencyService != nil,
|
||||||
|
"excluded_count", len(excludedIDs),
|
||||||
|
)
|
||||||
|
|
||||||
if s.debugModelRoutingEnabled() && requestedModel != "" {
|
if s.debugModelRoutingEnabled() && requestedModel != "" {
|
||||||
groupPlatform := ""
|
groupPlatform := ""
|
||||||
if group != nil {
|
if group != nil {
|
||||||
@@ -1589,6 +1631,13 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
if len(routingCandidates) > 0 {
|
if len(routingCandidates) > 0 {
|
||||||
// 1.5. 在路由账号范围内检查粘性会话
|
// 1.5. 在路由账号范围内检查粘性会话
|
||||||
if sessionHash != "" && stickyAccountID > 0 {
|
if sessionHash != "" && stickyAccountID > 0 {
|
||||||
|
slog.Debug("sticky.layer1_5_checking",
|
||||||
|
"sticky_account_id", stickyAccountID,
|
||||||
|
"in_routing_list", containsInt64(routingAccountIDs, stickyAccountID),
|
||||||
|
"is_excluded", isExcluded(stickyAccountID),
|
||||||
|
"in_account_map", func() bool { _, ok := accountByID[stickyAccountID]; return ok }(),
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
)
|
||||||
if containsInt64(routingAccountIDs, stickyAccountID) && !isExcluded(stickyAccountID) {
|
if containsInt64(routingAccountIDs, stickyAccountID) && !isExcluded(stickyAccountID) {
|
||||||
// 粘性账号在路由列表中,优先使用
|
// 粘性账号在路由列表中,优先使用
|
||||||
if stickyAccount, ok := accountByID[stickyAccountID]; ok {
|
if stickyAccount, ok := accountByID[stickyAccountID]; ok {
|
||||||
@@ -1612,6 +1661,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
stickyCacheMissReason = "session_limit"
|
stickyCacheMissReason = "session_limit"
|
||||||
// 继续到负载感知选择
|
// 继续到负载感知选择
|
||||||
} else {
|
} else {
|
||||||
|
slog.Debug("sticky.layer1_5_hit",
|
||||||
|
"account_id", stickyAccountID,
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
"result", "slot_acquired",
|
||||||
|
)
|
||||||
if s.debugModelRoutingEnabled() {
|
if s.debugModelRoutingEnabled() {
|
||||||
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
|
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
|
||||||
}
|
}
|
||||||
@@ -1762,27 +1816,65 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
// 检查账户是否需要清理粘性会话绑定
|
// 检查账户是否需要清理粘性会话绑定
|
||||||
clearSticky := shouldClearStickySession(account, requestedModel)
|
clearSticky := shouldClearStickySession(account, requestedModel)
|
||||||
if clearSticky {
|
if clearSticky {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_clear",
|
||||||
|
"account_id", accountID,
|
||||||
|
"reason", "should_clear_sticky_session",
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
)
|
||||||
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
||||||
}
|
}
|
||||||
if !clearSticky && s.isAccountInGroup(account, groupID) &&
|
|
||||||
s.isAccountAllowedForPlatform(account, platform, useMixed) &&
|
|
||||||
(requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) &&
|
|
||||||
s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) &&
|
|
||||||
s.isAccountSchedulableForQuota(account) &&
|
|
||||||
s.isAccountSchedulableForWindowCost(ctx, account, true) &&
|
|
||||||
|
|
||||||
s.isAccountSchedulableForRPM(ctx, account, true) { // 粘性会话窗口费用+RPM 检查
|
// 注意:不再检查 isAccountInGroup,因为 accountByID 已经从按分组过滤的
|
||||||
|
// accounts 列表构建,账号一定在分组内。而 scheduler snapshot 缓存
|
||||||
|
// 反序列化后 AccountGroups 字段为空,导致 isAccountInGroup 永远返回 false。
|
||||||
|
platformOK := s.isAccountAllowedForPlatform(account, platform, useMixed)
|
||||||
|
modelSupported := requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)
|
||||||
|
modelSchedulable := s.isAccountSchedulableForModelSelection(ctx, account, requestedModel)
|
||||||
|
quotaOK := s.isAccountSchedulableForQuota(account)
|
||||||
|
windowCostOK := s.isAccountSchedulableForWindowCost(ctx, account, true)
|
||||||
|
rpmOK := s.isAccountSchedulableForRPM(ctx, account, true)
|
||||||
|
schedulable := s.isAccountSchedulableForSelection(account)
|
||||||
|
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_checks",
|
||||||
|
"account_id", accountID,
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
"clear_sticky", clearSticky,
|
||||||
|
"schedulable", schedulable,
|
||||||
|
"platform_ok", platformOK,
|
||||||
|
"model_supported", modelSupported,
|
||||||
|
"model_schedulable", modelSchedulable,
|
||||||
|
"quota_ok", quotaOK,
|
||||||
|
"window_cost_ok", windowCostOK,
|
||||||
|
"rpm_ok", rpmOK,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !clearSticky && platformOK && modelSupported && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable {
|
||||||
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 会话数量限制检查
|
// 会话数量限制检查
|
||||||
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
|
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_miss",
|
||||||
|
"account_id", accountID,
|
||||||
|
"reason", "session_limit",
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_hit",
|
||||||
|
"account_id", accountID,
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
"result", "slot_acquired",
|
||||||
|
)
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
||||||
}
|
}
|
||||||
return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil)
|
return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_slot_busy",
|
||||||
|
"account_id", accountID,
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
|
||||||
@@ -1791,6 +1883,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
// 会话限制已满,继续到 Layer 2
|
// 会话限制已满,继续到 Layer 2
|
||||||
} else {
|
} else {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_hit",
|
||||||
|
"account_id", accountID,
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
"result", "wait_plan",
|
||||||
|
)
|
||||||
return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{
|
return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
MaxConcurrency: account.Concurrency,
|
MaxConcurrency: account.Concurrency,
|
||||||
@@ -1799,12 +1896,42 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if !clearSticky {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_miss",
|
||||||
|
"account_id", accountID,
|
||||||
|
"reason", "gate_check_failed",
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_miss",
|
||||||
|
"account_id", accountID,
|
||||||
|
"reason", "account_not_in_map",
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if len(routingAccountIDs) == 0 && sessionHash != "" {
|
||||||
|
slog.Debug("sticky.layer1_5_no_routing_skip",
|
||||||
|
"sticky_account_id", stickyAccountID,
|
||||||
|
"is_excluded", func() bool { return stickyAccountID > 0 && isExcluded(stickyAccountID) }(),
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
"reason", func() string {
|
||||||
|
if stickyAccountID == 0 {
|
||||||
|
return "no_sticky_binding"
|
||||||
|
}
|
||||||
|
return "sticky_account_excluded"
|
||||||
|
}(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Layer 2: 负载感知选择 ============
|
// ============ Layer 2: 负载感知选择 ============
|
||||||
|
slog.Debug("sticky.layer2_fallback",
|
||||||
|
"session", shortSessionHash(sessionHash),
|
||||||
|
"sticky_account_id", stickyAccountID,
|
||||||
|
"reason", "sticky_not_used_falling_back_to_load_balance",
|
||||||
|
"total_accounts", len(accounts),
|
||||||
|
)
|
||||||
candidates := make([]*Account, 0, len(accounts))
|
candidates := make([]*Account, 0, len(accounts))
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
acc := &accounts[i]
|
acc := &accounts[i]
|
||||||
@@ -4104,6 +4231,87 @@ func enforceCacheControlLimit(body []byte) []byte {
|
|||||||
return body
|
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
|
// Forward 转发请求到Claude API
|
||||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -4263,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)
|
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)
|
token, tokenType, err := s.GetAccessToken(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -7103,9 +7315,9 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类
|
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类。
|
||||||
if account.IsCacheTTLOverrideEnabled() {
|
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||||
overrideTarget := account.GetCacheTTLOverrideTarget()
|
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||||
if eventType == "message_start" {
|
if eventType == "message_start" {
|
||||||
if msg, ok := event["message"].(map[string]any); ok {
|
if msg, ok := event["message"].(map[string]any); ok {
|
||||||
if u, ok := msg["usage"].(map[string]any); ok {
|
if u, ok := msg["usage"].(map[string]any); ok {
|
||||||
@@ -7512,6 +7724,19 @@ func rewriteCacheCreationJSON(usageObj map[string]any, target string) bool {
|
|||||||
return true
|
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) {
|
func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*ClaudeUsage, error) {
|
||||||
// 更新5h窗口状态
|
// 更新5h窗口状态
|
||||||
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
||||||
@@ -7548,9 +7773,9 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类
|
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类。
|
||||||
if account.IsCacheTTLOverrideEnabled() {
|
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||||
overrideTarget := account.GetCacheTTLOverrideTarget()
|
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||||
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
|
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
|
||||||
// 同步更新 body JSON 中的嵌套 cache_creation 对象
|
// 同步更新 body JSON 中的嵌套 cache_creation 对象
|
||||||
if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens", response.Usage.CacheCreation5mTokens); err == nil {
|
if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens", response.Usage.CacheCreation5mTokens); err == nil {
|
||||||
@@ -8118,10 +8343,11 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
|||||||
result.Usage.InputTokens = 0
|
result.Usage.InputTokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache TTL Override: 确保计费时 token 分类与账号设置一致
|
// Cache TTL Override: 确保计费时 token 分类与账号设置一致。
|
||||||
|
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||||
cacheTTLOverridden := false
|
cacheTTLOverridden := false
|
||||||
if account.IsCacheTTLOverrideEnabled() {
|
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||||
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
|
applyCacheTTLOverride(&result.Usage, overrideTarget)
|
||||||
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
|
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1379,10 +1379,12 @@ func shouldInferIngressFunctionCallOutputPreviousResponseID(
|
|||||||
if signals.HasFunctionCallOutputMissingCallID {
|
if signals.HasFunctionCallOutputMissingCallID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// If the client already sent tool-call context or item_reference anchors,
|
// If the client already sent the actual tool-call context, treat this as
|
||||||
// treat this as a full replay / self-contained continuation payload rather
|
// a full replay / self-contained continuation payload rather than
|
||||||
// than downgrading it into an inferred delta continuation.
|
// downgrading it into an inferred delta continuation. item_reference alone
|
||||||
if signals.HasToolCallContext || signals.HasItemReferenceForAllCallIDs {
|
// is not enough on the store=false WS path: it still needs a valid prior
|
||||||
|
// response anchor so upstream can resolve the referenced function_call.
|
||||||
|
if signals.HasToolCallContext {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(expectedPreviousResponseID) != ""
|
return strings.TrimSpace(expectedPreviousResponseID) != ""
|
||||||
|
|||||||
@@ -1488,7 +1488,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFun
|
|||||||
require.False(t, gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").Exists(), "请求已包含 function_call 上下文时不应自动补齐 previous_response_id")
|
require.False(t, gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").Exists(), "请求已包含 function_call 上下文时不应自动补齐 previous_response_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFunctionCallOutputSkipsAutoAttachWhenItemReferencesPresent(t *testing.T) {
|
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFunctionCallOutputAutoAttachWhenOnlyItemReferencesPresent(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
@@ -1619,7 +1619,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFun
|
|||||||
|
|
||||||
require.Equal(t, 1, captureDialer.DialCount())
|
require.Equal(t, 1, captureDialer.DialCount())
|
||||||
require.Len(t, captureConn.writes, 2)
|
require.Len(t, captureConn.writes, 2)
|
||||||
require.False(t, gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").Exists(), "请求已包含 item_reference 锚点时不应自动补齐 previous_response_id")
|
require.Equal(t, "resp_auto_prev_ref_1", gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").String(), "仅有 item_reference 不足以自包含 function_call_output,应回填上一轮响应 ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreflightPingFailReconnectsBeforeTurn(t *testing.T) {
|
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreflightPingFailReconnectsBeforeTurn(t *testing.T) {
|
||||||
|
|||||||
@@ -303,12 +303,12 @@ func TestShouldInferIngressFunctionCallOutputPreviousResponseID(t *testing.T) {
|
|||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "skip_when_item_reference_already_covers_all_call_ids",
|
name: "infer_when_only_item_reference_covers_call_ids",
|
||||||
storeDisabled: true,
|
storeDisabled: true,
|
||||||
turn: 2,
|
turn: 2,
|
||||||
signals: ToolContinuationSignals{HasFunctionCallOutput: true, HasItemReferenceForAllCallIDs: true},
|
signals: ToolContinuationSignals{HasFunctionCallOutput: true, HasItemReferenceForAllCallIDs: true},
|
||||||
expectedPrevious: "resp_2",
|
expectedPrevious: "resp_2",
|
||||||
want: false,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "skip_when_function_call_output_missing_call_id",
|
name: "skip_when_function_call_output_missing_call_id",
|
||||||
|
|||||||
@@ -82,10 +82,11 @@ const backendModeDBTimeout = 5 * time.Second
|
|||||||
|
|
||||||
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
||||||
type cachedGatewayForwardingSettings struct {
|
type cachedGatewayForwardingSettings struct {
|
||||||
fingerprintUnification bool
|
fingerprintUnification bool
|
||||||
metadataPassthrough bool
|
metadataPassthrough bool
|
||||||
cchSigning bool
|
cchSigning bool
|
||||||
expiresAt int64 // unix nano
|
anthropicCacheTTL1hInjection bool
|
||||||
|
expiresAt int64 // unix nano
|
||||||
}
|
}
|
||||||
|
|
||||||
var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
|
var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
|
||||||
@@ -1245,6 +1246,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||||
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||||||
|
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
|
||||||
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
|
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
|
||||||
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
|
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
|
||||||
updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled)
|
updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled)
|
||||||
@@ -1305,10 +1307,11 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
|||||||
})
|
})
|
||||||
gatewayForwardingSF.Forget("gateway_forwarding")
|
gatewayForwardingSF.Forget("gateway_forwarding")
|
||||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||||
fingerprintUnification: settings.EnableFingerprintUnification,
|
fingerprintUnification: settings.EnableFingerprintUnification,
|
||||||
metadataPassthrough: settings.EnableMetadataPassthrough,
|
metadataPassthrough: settings.EnableMetadataPassthrough,
|
||||||
cchSigning: settings.EnableCCHSigning,
|
cchSigning: settings.EnableCCHSigning,
|
||||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||||
|
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||||
})
|
})
|
||||||
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
||||||
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
||||||
@@ -1415,22 +1418,30 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
type gatewayForwardingSettingsResult struct {
|
||||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
fp, mp, cch, cacheTTL1h bool
|
||||||
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
}
|
||||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
|
||||||
|
func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult {
|
||||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||||
if time.Now().UnixNano() < cached.expiresAt {
|
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) {
|
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||||
if time.Now().UnixNano() < cached.expiresAt {
|
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)
|
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||||||
@@ -1439,16 +1450,18 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing
|
|||||||
SettingKeyEnableFingerprintUnification,
|
SettingKeyEnableFingerprintUnification,
|
||||||
SettingKeyEnableMetadataPassthrough,
|
SettingKeyEnableMetadataPassthrough,
|
||||||
SettingKeyEnableCCHSigning,
|
SettingKeyEnableCCHSigning,
|
||||||
|
SettingKeyEnableAnthropicCacheTTL1hInjection,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||||
fingerprintUnification: true,
|
fingerprintUnification: true,
|
||||||
metadataPassthrough: false,
|
metadataPassthrough: false,
|
||||||
cchSigning: false,
|
cchSigning: false,
|
||||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
anthropicCacheTTL1hInjection: false,
|
||||||
|
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||||
})
|
})
|
||||||
return gwfResult{true, false, false}, nil
|
return gatewayForwardingSettingsResult{fp: true}, nil
|
||||||
}
|
}
|
||||||
fp := true
|
fp := true
|
||||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||||
@@ -1456,18 +1469,33 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing
|
|||||||
}
|
}
|
||||||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||||||
cch := values[SettingKeyEnableCCHSigning] == "true"
|
cch := values[SettingKeyEnableCCHSigning] == "true"
|
||||||
|
cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||||
fingerprintUnification: fp,
|
fingerprintUnification: fp,
|
||||||
metadataPassthrough: mp,
|
metadataPassthrough: mp,
|
||||||
cchSigning: cch,
|
cchSigning: cch,
|
||||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
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 {
|
if r, ok := val.(gatewayForwardingSettingsResult); ok {
|
||||||
return r.fp, r.mp, r.cch
|
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 检查是否开启邮件验证
|
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||||||
@@ -1880,12 +1908,13 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeyMaxClaudeCodeVersion: "",
|
SettingKeyMaxClaudeCodeVersion: "",
|
||||||
|
|
||||||
// 分组隔离(默认不允许未分组 Key 调度)
|
// 分组隔离(默认不允许未分组 Key 调度)
|
||||||
SettingKeyAllowUngroupedKeyScheduling: "false",
|
SettingKeyAllowUngroupedKeyScheduling: "false",
|
||||||
SettingPaymentVisibleMethodAlipaySource: "",
|
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
|
||||||
SettingPaymentVisibleMethodWxpaySource: "",
|
SettingPaymentVisibleMethodAlipaySource: "",
|
||||||
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
SettingPaymentVisibleMethodWxpaySource: "",
|
||||||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
||||||
openAIAdvancedSchedulerSettingKey: "false",
|
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||||
|
openAIAdvancedSchedulerSettingKey: "false",
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.settingRepo.SetMultiple(ctx, defaults)
|
return s.settingRepo.SetMultiple(ctx, defaults)
|
||||||
@@ -2228,6 +2257,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
}
|
}
|
||||||
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
||||||
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
||||||
|
result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||||||
|
|
||||||
// Web search emulation: quick enabled check from the JSON config
|
// Web search emulation: quick enabled check from the JSON config
|
||||||
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
|
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
|
||||||
|
|||||||
@@ -149,9 +149,10 @@ type SystemSettings struct {
|
|||||||
BackendModeEnabled bool
|
BackendModeEnabled bool
|
||||||
|
|
||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
||||||
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
||||||
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
|
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
|
||||||
|
EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
|
||||||
|
|
||||||
// Web Search Emulation
|
// Web Search Emulation
|
||||||
WebSearchEmulationEnabled bool // 是否启用 web search 模拟
|
WebSearchEmulationEnabled bool // 是否启用 web search 模拟
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ export interface SystemSettings {
|
|||||||
enable_fingerprint_unification: boolean;
|
enable_fingerprint_unification: boolean;
|
||||||
enable_metadata_passthrough: boolean;
|
enable_metadata_passthrough: boolean;
|
||||||
enable_cch_signing: boolean;
|
enable_cch_signing: boolean;
|
||||||
|
enable_anthropic_cache_ttl_1h_injection: boolean;
|
||||||
web_search_emulation_enabled?: boolean;
|
web_search_emulation_enabled?: boolean;
|
||||||
|
|
||||||
// Payment configuration
|
// Payment configuration
|
||||||
@@ -609,6 +610,7 @@ export interface UpdateSettingsRequest {
|
|||||||
enable_fingerprint_unification?: boolean;
|
enable_fingerprint_unification?: boolean;
|
||||||
enable_metadata_passthrough?: boolean;
|
enable_metadata_passthrough?: boolean;
|
||||||
enable_cch_signing?: boolean;
|
enable_cch_signing?: boolean;
|
||||||
|
enable_anthropic_cache_ttl_1h_injection?: boolean;
|
||||||
// Payment configuration
|
// Payment configuration
|
||||||
payment_enabled?: boolean;
|
payment_enabled?: boolean;
|
||||||
payment_min_amount?: number;
|
payment_min_amount?: number;
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import Select from './Select.vue'
|
import Select from './Select.vue'
|
||||||
import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
|
import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||||
|
import { setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -224,6 +225,7 @@ const goToPage = (newPage: number) => {
|
|||||||
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||||
if (value === null || typeof value === 'boolean') return
|
if (value === null || typeof value === 'boolean') return
|
||||||
const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
|
const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
|
||||||
|
setPersistedPageSize(newPageSize)
|
||||||
emit('update:pageSize', newPageSize)
|
emit('update:pageSize', newPageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
|
import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||||
|
|
||||||
/**
|
const STORAGE_KEY = 'table-page-size'
|
||||||
* 读取当前系统配置的表格默认每页条数。
|
|
||||||
* 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
|
|
||||||
*/
|
|
||||||
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
|
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored !== null) {
|
||||||
|
const parsed = Number(stored)
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return normalizeTablePageSize(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read persisted page size:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
|
return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setPersistedPageSize(size: number): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, String(size))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to persist page size:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref, reactive, onUnmounted, toRaw } from 'vue'
|
import { ref, reactive, onUnmounted, toRaw } from 'vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import type { BasePaginationResponse, FetchOptions } from '@/types'
|
import type { BasePaginationResponse, FetchOptions } from '@/types'
|
||||||
import { getPersistedPageSize } from './usePersistedPageSize'
|
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize'
|
||||||
|
|
||||||
interface PaginationState {
|
interface PaginationState {
|
||||||
page: number
|
page: number
|
||||||
@@ -88,6 +88,7 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
|
|||||||
const handlePageSizeChange = (size: number) => {
|
const handlePageSizeChange = (size: number) => {
|
||||||
pagination.page_size = size
|
pagination.page_size = size
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
|
setPersistedPageSize(size)
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5019,6 +5019,8 @@ export default {
|
|||||||
metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.',
|
metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.',
|
||||||
cchSigning: 'CCH Signing',
|
cchSigning: 'CCH Signing',
|
||||||
cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.',
|
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: {
|
webSearchEmulation: {
|
||||||
title: 'Web Search Emulation',
|
title: 'Web Search Emulation',
|
||||||
|
|||||||
@@ -5178,6 +5178,8 @@ export default {
|
|||||||
metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
|
metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
|
||||||
cchSigning: 'CCH 签名',
|
cchSigning: 'CCH 签名',
|
||||||
cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。',
|
cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。',
|
||||||
|
anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入',
|
||||||
|
anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。',
|
||||||
},
|
},
|
||||||
webSearchEmulation: {
|
webSearchEmulation: {
|
||||||
title: 'Web Search 模拟',
|
title: 'Web Search 模拟',
|
||||||
|
|||||||
@@ -3057,6 +3057,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<Toggle v-model="form.enable_cch_signing" />
|
<Toggle v-model="form.enable_cch_signing" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Web Search Emulation -->
|
<!-- Web Search Emulation -->
|
||||||
@@ -5810,6 +5835,7 @@ const form = reactive<SettingsForm>({
|
|||||||
enable_fingerprint_unification: true,
|
enable_fingerprint_unification: true,
|
||||||
enable_metadata_passthrough: false,
|
enable_metadata_passthrough: false,
|
||||||
enable_cch_signing: false,
|
enable_cch_signing: false,
|
||||||
|
enable_anthropic_cache_ttl_1h_injection: false,
|
||||||
// Balance & quota notification
|
// Balance & quota notification
|
||||||
balance_low_notify_enabled: false,
|
balance_low_notify_enabled: false,
|
||||||
balance_low_notify_threshold: 0,
|
balance_low_notify_threshold: 0,
|
||||||
@@ -6718,6 +6744,8 @@ async function saveSettings() {
|
|||||||
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
||||||
enable_metadata_passthrough: form.enable_metadata_passthrough,
|
enable_metadata_passthrough: form.enable_metadata_passthrough,
|
||||||
enable_cch_signing: form.enable_cch_signing,
|
enable_cch_signing: form.enable_cch_signing,
|
||||||
|
enable_anthropic_cache_ttl_1h_injection:
|
||||||
|
form.enable_anthropic_cache_ttl_1h_injection,
|
||||||
// Payment configuration
|
// Payment configuration
|
||||||
payment_enabled: form.payment_enabled,
|
payment_enabled: form.payment_enabled,
|
||||||
payment_min_amount: Number(form.payment_min_amount) || 0,
|
payment_min_amount: Number(form.payment_min_amount) || 0,
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ const baseSettingsResponse = {
|
|||||||
enable_fingerprint_unification: true,
|
enable_fingerprint_unification: true,
|
||||||
enable_metadata_passthrough: false,
|
enable_metadata_passthrough: false,
|
||||||
enable_cch_signing: false,
|
enable_cch_signing: false,
|
||||||
|
enable_anthropic_cache_ttl_1h_injection: false,
|
||||||
payment_enabled: true,
|
payment_enabled: true,
|
||||||
payment_min_amount: 1,
|
payment_min_amount: 1,
|
||||||
payment_max_amount: 10000,
|
payment_max_amount: 10000,
|
||||||
@@ -567,6 +568,26 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled");
|
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 () => {
|
it("updates provider enablement immediately and reloads providers", async () => {
|
||||||
const provider = {
|
const provider = {
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|||||||
Reference in New Issue
Block a user