From 4a99ab14965b99251e30b41485faa0d7100d2a5f Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 6 Sep 2026 11:55:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(subscription):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A2=AB=E5=8A=A8=E9=A2=9D=E5=BA=A6=E5=A5=91=E7=BA=A6=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E4=B8=8E=E6=9D=A5=E6=BA=90=E8=AF=AF=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=20(#575)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): 接受订阅额度窗口的 source_id 字段 修复 #574 新增 source_id 后前端严格投影拒绝成功响应的问题。补齐可选 DTO 字段、白名单及字符串投影,保持其他未知字段校验,不修改后端额度匹配或限流行为。 新增前后端额度字段契约回归测试,避免后端新增字段却遗漏前端白名单和 DTO。 * chore(ui): 移除额度字段修复中的跨语言契约测试 * fix(subscription): 按 Active-Limit 判定被动额度通用组的归属 上游的 X-Codex-Primary/Secondary-* 报告的是本次请求实际计费到的额度, 而不是固定的普通账号额度。请求 Spark 等专属额度时这一组装的是该额度的 副本,按 codex 收下会用它覆盖同周期的普通窗口,把用尽的 7d 刷成满额。 改为由 X-Codex-Active-Limit 决定该组归属:premium/codex 归普通额度, 指向专属额度时改绑到该来源,上游已单独报告该来源则让位避免歧义。 缺少该头且响应里已有别的来源时按副本丢弃。 * fix(subscription): 按窗口去重被动额度副本而非丢弃整组 Active-Limit 指向专属额度时,通用组只有与独立命名空间来源和周期都相同 的窗口才是重复报告。原先只要该来源存在任意窗口就丢掉整组,会漏掉另一 个周期的刷新。改为解析后按来源加周期逐窗口去重。 * fix(subscription): 限定被动额度去重只比较独立命名空间 通用组内部的 primary 与 secondary 是两份数据而非互为副本,周期相同时 原实现让它们互相判定为重复并双双丢弃。去重范围收窄为通用窗口与独立 命名空间窗口之间。 --- .../passive_quota_source_flush_test.go | 1 + .../subscription/passive_quota_source_test.go | 42 +++++ .../providers/codex/observation.go | 113 ++++++++++++- .../providers/codex/observation_test.go | 3 + .../codex/passive_quota_active_limit_test.go | 156 ++++++++++++++++++ .../providers/codex/quota_source_test.go | 4 +- web/src/api/control/types.ts | 1 + web/src/app/resources/credentials.ts | 2 + 8 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 internal/subscription/providers/codex/passive_quota_active_limit_test.go diff --git a/internal/subscription/passive_quota_source_flush_test.go b/internal/subscription/passive_quota_source_flush_test.go index 6ca7c6b0..055e9929 100644 --- a/internal/subscription/passive_quota_source_flush_test.go +++ b/internal/subscription/passive_quota_source_flush_test.go @@ -24,6 +24,7 @@ func TestFlushPassiveQuotaSourcesPreservesCardWindowsAndHealth(t *testing.T) { } observedAt := time.UnixMilli(2000) patches := codex.NormalizePassiveQuotaWindows(map[string]string{ + "X-Codex-Active-Limit": "premium", "X-Codex-Primary-Used-Percent": "0", "X-Codex-Primary-Window-Minutes": "300", "X-Codex-Secondary-Used-Percent": "100", diff --git a/internal/subscription/passive_quota_source_test.go b/internal/subscription/passive_quota_source_test.go index ffddea97..32e083a6 100644 --- a/internal/subscription/passive_quota_source_test.go +++ b/internal/subscription/passive_quota_source_test.go @@ -23,6 +23,8 @@ func TestPassiveQuotaMatchesSourceAndPeriodAcrossSlots(t *testing.T) { t.Fatal(err) } patches := codex.NormalizePassiveQuotaWindows(map[string]string{ + // 本次请求计费到普通额度,通用组报告账号自身窗口,Spark 另有命名空间。 + "X-Codex-Active-Limit": "premium", "X-Codex-Primary-Used-Percent": "5", "X-Codex-Primary-Window-Minutes": "300", "X-Codex-Secondary-Used-Percent": "100", @@ -115,3 +117,43 @@ func TestPassiveQuotaSourceMatchingRejectsUncertainTargets(t *testing.T) { }) } } + +// 复现维护者现场:普通 7d 用尽后请求 Spark,上游只在通用组里回本次计费到的 +// Spark 周窗口。按普通额度收下会把 7d 刷成满额,必须落到 Spark 自己的窗口上。 +func TestPassiveQuotaMeteredRequestDoesNotRefillAccountWeekly(t *testing.T) { + raw, err := codex.NormalizeQuota([]byte(`{"plan_type":"pro", + "rate_limit":{"primary_window":{"used_percent":100,"limit_window_seconds":604800,"reset_at":1788700000}}, + "additional_rate_limits":[{"metered_feature":"codex_bengalfox","limit_name":"GPT-5.3-Codex-Spark", + "rate_limit":{"primary_window":{"used_percent":0,"limit_window_seconds":18000,"reset_at":1788678033}, + "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1789222487}}}]}`), nil) + if err != nil { + t.Fatal(err) + } + var active providerobservation.Snapshot + if err := json.Unmarshal(raw, &active); err != nil { + t.Fatal(err) + } + if len(active.QuotaWindows) != 3 || active.QuotaWindows[0].State != "exhausted" { + t.Fatalf("unexpected active snapshot: %s", raw) + } + + patches := codex.NormalizePassiveQuotaWindows(map[string]string{ + "X-Codex-Active-Limit": "codex_bengalfox", + "X-Codex-Secondary-Used-Percent": "10", + "X-Codex-Secondary-Window-Minutes": "10080", + "X-Codex-Secondary-Reset-At": "1789222487", + }, time.Unix(1788660000, 0)) + merged, err := mergePassiveQuotaSnapshot(raw, patches) + if err != nil { + t.Fatal(err) + } + if !merged.Matched || !merged.Changed || len(merged.Windows) != 3 { + t.Fatalf("merge = %#v, want only the Spark weekly window refreshed", merged) + } + want := append([]providerobservation.QuotaWindow(nil), active.QuotaWindows...) + used, remaining, utilization := 10.0, 90.0, 0.1 + want[2].Used, want[2].Remaining, want[2].Utilization = &used, &remaining, &utilization + if !reflect.DeepEqual(merged.Windows, want) { + t.Fatalf("account weekly window was refilled by the Spark copy: %s", merged.Encoded) + } +} diff --git a/internal/subscription/providers/codex/observation.go b/internal/subscription/providers/codex/observation.go index 28357ab7..315903f2 100644 --- a/internal/subscription/providers/codex/observation.go +++ b/internal/subscription/providers/codex/observation.go @@ -108,8 +108,16 @@ func NormalizeQuota(primary, details []byte) ([]byte, error) { const passiveQuotaMaxResetAtSeconds = math.MaxInt64 / 1000 // passiveQuotaNamespaceFields are the header suffixes that belong to a limit -// namespace as a whole rather than to one of its windows. -var passiveQuotaNamespaceFields = []string{"limit-reached", "limit-name", "allowed"} +// namespace as a whole rather than to one of its windows. active-limit only +// ever appears on the response as a whole, which parses as the empty namespace. +var passiveQuotaNamespaceFields = []string{"limit-reached", "limit-name", "allowed", "active-limit"} + +// codexAccountQuotaSourceID 是普通账号额度的来源标识,与主动查询里没有 +// metered_feature 的 rate_limit 对应。 +const codexAccountQuotaSourceID = "codex" + +// codexAccountActiveLimit 是 X-Codex-Active-Limit 指向普通账号额度时的取值。 +const codexAccountActiveLimit = "premium" // passiveQuotaNamespace holds one Codex limit namespace parsed out of the // response headers. The empty namespace is the credential's own rate limit; @@ -120,25 +128,28 @@ type passiveQuotaNamespace struct { } // NormalizePassiveQuotaWindows 提取响应头中的来源、周期和额度数据。 -// 来源按上游 limit_id 规则与主动查询的 metered_feature 对齐,不依赖 Limit-Name。 +// 来源按上游 limit_id 规则与主动查询的 metered_feature 对齐,不依赖 Limit-Name; +// 没有命名空间的一组由 Active-Limit 决定归属,见 passiveQuotaGenericSourceID。 // 展示元数据仍由主动观测负责;缺少周期的样本由合并层拒绝,不能退回槽位匹配。 func NormalizePassiveQuotaWindows(signals map[string]string, observedAt time.Time) []quotaWindow { if len(signals) == 0 { return nil } result := make([]quotaWindow, 0, 2) + generic := make([]int, 0, 2) namespaces := parsePassiveQuotaNamespaces(signals) for _, key := range sortedNamespaceKeys(namespaces) { namespace := namespaces[key] - prefix, scope, sourceID := "", "account", "codex" + prefix, scope, sourceID := "", "account", passiveQuotaNamespaceSourceID(key) if key != "" { - sourceID += "-" + key limitName := strings.TrimSpace(namespace.rateLevel["limit-name"]) if limitName == "" { limitName = sourceID } scope = limitName prefix = providerobservation.SafeID(limitName) + "-" + } else if sourceID = passiveQuotaGenericSourceID(namespaces); sourceID == "" { + continue } rate := passiveQuotaRate(namespace, observedAt) if rate == nil { @@ -149,15 +160,107 @@ func NormalizePassiveQuotaWindows(signals map[string]string, observedAt time.Tim // rules above; they are cleared, like Label, so the merge updates // nothing but the quota numbers and state. window.Label, window.LabelKey, window.Scope, window.Unit = "", "", "", "" + if key == "" { + generic = append(generic, len(result)) + } result = append(result, window) } } + if len(generic) > 0 { + result = passiveQuotaDropDuplicateCopies(result, generic) + } if len(result) == 0 { return nil } return result } +// passiveQuotaNamespaceSourceID 把响应头命名空间映射成额度来源标识。上游用短名 +// 给专属额度分段(x-codex-bengalfox-*),主动查询用 metered_feature 报告同一个 +// 来源(codex_bengalfox),两者相差一个 codex 前缀。 +func passiveQuotaNamespaceSourceID(key string) string { + if key == "" { + return codexAccountQuotaSourceID + } + return normalizeQuotaSourceID(codexAccountQuotaSourceID + "-" + key) +} + +// passiveQuotaGenericSourceID 判定没有命名空间的 X-Codex-Primary/Secondary-* 组 +// 归属哪个来源。这一组报告的是本次请求实际计费到的额度,而不是固定的普通账号 +// 额度:请求 Spark 等专属额度时,上游把该额度原样放进这一组,按普通额度收下就 +// 会用专属额度覆盖同周期的普通窗口。返回空串表示无法安全归属,整组丢弃。 +func passiveQuotaGenericSourceID(namespaces map[string]*passiveQuotaNamespace) string { + generic, ok := namespaces[""] + if !ok { + return "" + } + switch active := normalizeQuotaSourceID(generic.rateLevel["active-limit"]); active { + case codexAccountQuotaSourceID, codexAccountActiveLimit: + return codexAccountQuotaSourceID + case "": + // 没有 Active-Limit 就无法直接区分普通额度和专属额度的副本。同一响应里 + // 已经独立报告了别的来源时按副本处理,只有单独报告这一组才沿用普通额度。 + if passiveQuotaHasNamespacedWindows(namespaces) { + return "" + } + return codexAccountQuotaSourceID + default: + // 专属额度的副本直接刷新该来源。与独立命名空间真正重复的窗口另行去重, + // 这里不能因为该来源另有一个周期的窗口就丢掉整组。 + return active + } +} + +// passiveQuotaHasNamespacedWindows 报告除通用组外是否还有带窗口数据的来源。 +func passiveQuotaHasNamespacedWindows(namespaces map[string]*passiveQuotaNamespace) bool { + for key, namespace := range namespaces { + if key != "" && len(namespace.windows) > 0 { + return true + } + } + return false +} + +// passiveQuotaDropDuplicateCopies 丢弃与独立命名空间重复的通用窗口。只有来源和 +// 周期都相同才是同一份额度的两次报告:这时两个补丁会指向同一个窗口,合并层判定 +// 歧义后会把两者一起丢弃,因此只保留命名空间单独报告的那份。周期不同的窗口对应 +// 不同额度,必须各自保留。generic 是通用组产出的窗口下标。 +func passiveQuotaDropDuplicateCopies(windows []quotaWindow, generic []int) []quotaWindow { + isGeneric := make(map[int]bool, len(generic)) + for _, index := range generic { + isGeneric[index] = true + } + duplicated := make(map[int]bool, len(generic)) + for _, index := range generic { + for other := range windows { + // 只与独立命名空间比较:通用组内部的两个槽位是两份数据,不是副本。 + if isGeneric[other] || windows[other].SourceID != windows[index].SourceID { + continue + } + if samePassiveQuotaPeriod(windows[other], windows[index]) { + duplicated[index] = true + break + } + } + } + if len(duplicated) == 0 { + return windows + } + kept := windows[:0] + for index, window := range windows { + if duplicated[index] { + continue + } + kept = append(kept, window) + } + return kept +} + +func samePassiveQuotaPeriod(left, right quotaWindow) bool { + return left.WindowSeconds != nil && right.WindowSeconds != nil && + *left.WindowSeconds == *right.WindowSeconds +} + // parsePassiveQuotaNamespaces 从已知字段后缀定位槽位,避免将来源名称中的 // primary/secondary 误认成窗口,或把 over-secondary-limit-percent 当成额度值。 func parsePassiveQuotaNamespaces(signals map[string]string) map[string]*passiveQuotaNamespace { diff --git a/internal/subscription/providers/codex/observation_test.go b/internal/subscription/providers/codex/observation_test.go index 14250b82..1530e3c2 100644 --- a/internal/subscription/providers/codex/observation_test.go +++ b/internal/subscription/providers/codex/observation_test.go @@ -58,7 +58,10 @@ func TestNormalizePassiveQuotaWindowsMapsAdditionalLimitNamespaces(t *testing.T) // limit_name "GPT 5.3 Codex Spark" arrives on the HTTP path under a short // namespace plus an X-Codex--Limit-Name header. windows := NormalizePassiveQuotaWindows(map[string]string{ + // 请求计费到普通额度,通用组报告的就是账号自身的窗口。 + "X-Codex-Active-Limit": "premium", "X-Codex-Primary-Used-Percent": "20", + "X-Codex-Primary-Window-Minutes": "300", "X-Codex-Bengalfox-Limit-Name": "GPT 5.3 Codex Spark", "X-Codex-Bengalfox-Primary-Used-Percent": "33", "X-Codex-Bengalfox-Primary-Window-Minutes": "300", diff --git a/internal/subscription/providers/codex/passive_quota_active_limit_test.go b/internal/subscription/providers/codex/passive_quota_active_limit_test.go new file mode 100644 index 00000000..bee3ed37 --- /dev/null +++ b/internal/subscription/providers/codex/passive_quota_active_limit_test.go @@ -0,0 +1,156 @@ +package codex + +import ( + "encoding/json" + "testing" + "time" +) + +// 上游只在通用 X-Codex-Primary/Secondary-* 组里报告本次请求计费到的那份额度。 +// 请求 Spark 时这一组装的是 Spark 的窗口,按普通账号额度收下会覆盖普通 7d。 +func TestPassiveQuotaGenericWindowsFollowActiveLimit(t *testing.T) { + weekly := map[string]string{ + "X-Codex-Secondary-Used-Percent": "0", + "X-Codex-Secondary-Window-Minutes": "10080", + "X-Codex-Secondary-Reset-At": "1789222487", + } + sparkNamespace := map[string]string{ + "X-Codex-Bengalfox-Primary-Used-Percent": "20", + "X-Codex-Bengalfox-Primary-Window-Minutes": "300", + } + for _, test := range []struct { + name string + activeLimit string + namespaced bool + wantSources []string + wantPeriods []int64 + }{ + {"spark request reports only the metered window", "codex_bengalfox", false, + []string{"codex_bengalfox"}, []int64{604800}}, + {"account request keeps the account source", "premium", false, + []string{"codex"}, []int64{604800}}, + {"account alias keeps the account source", "codex", false, + []string{"codex"}, []int64{604800}}, + // 同来源但不同周期,两个窗口各自对应一份额度,都要刷新。 + {"metered copy covers another period", "codex_bengalfox", true, + []string{"codex_bengalfox", "codex_bengalfox"}, []int64{604800, 18000}}, + {"legacy response without an active limit", "", false, + []string{"codex"}, []int64{604800}}, + {"unidentified copy alongside a metered namespace", "", true, + []string{"codex_bengalfox"}, []int64{18000}}, + } { + t.Run(test.name, func(t *testing.T) { + signals := map[string]string{} + for key, value := range weekly { + signals[key] = value + } + if test.activeLimit != "" { + signals["X-Codex-Active-Limit"] = test.activeLimit + } + if test.namespaced { + for key, value := range sparkNamespace { + signals[key] = value + } + } + + windows := NormalizePassiveQuotaWindows(signals, time.Now()) + if len(windows) != len(test.wantSources) { + t.Fatalf("windows = %#v, want %d from %v", windows, len(test.wantSources), test.wantSources) + } + for index, want := range test.wantSources { + window := windows[index] + if window.SourceID != want { + t.Fatalf("window %d source = %q, want %q (all=%#v)", index, window.SourceID, want, windows) + } + if window.WindowSeconds == nil || *window.WindowSeconds != test.wantPeriods[index] { + t.Fatalf("window %d period = %v, want %d (all=%#v)", + index, window.WindowSeconds, test.wantPeriods[index], windows) + } + } + }) + } +} + +// 同来源同周期才是真正的重复:这时通用副本必须让位,否则两份数据指向同一个 +// 窗口,合并层会判定为歧义而把两者一起丢弃。 +func TestPassiveQuotaMeteredCopyDoesNotCompeteWithItsNamespace(t *testing.T) { + windows := NormalizePassiveQuotaWindows(map[string]string{ + "X-Codex-Active-Limit": "codex_bengalfox", + "X-Codex-Primary-Used-Percent": "20", + "X-Codex-Primary-Window-Minutes": "300", + "X-Codex-Bengalfox-Primary-Used-Percent": "20", + "X-Codex-Bengalfox-Primary-Window-Minutes": "300", + "X-Codex-Bengalfox-Secondary-Used-Percent": "40", + "X-Codex-Bengalfox-Secondary-Window-Minutes": "10080", + }, time.Now()) + if len(windows) != 2 { + t.Fatalf("windows = %#v, want only the namespaced pair", windows) + } + for _, window := range windows { + if window.SourceID != "codex_bengalfox" || window.WindowSeconds == nil { + t.Fatalf("unexpected window: %#v (all=%#v)", window, windows) + } + } + if *windows[0].WindowSeconds != 18000 || *windows[1].WindowSeconds != 604800 { + t.Fatalf("duplicate or missing period: %#v", windows) + } +} + +// Active-Limit 的取值与主动查询的 metered_feature 是同一个来源标识, +// 改绑通用组时不再经过响应头命名空间的前缀推导。 +func TestPassiveQuotaActiveLimitMatchesActiveMeteredFeature(t *testing.T) { + raw, err := NormalizeQuota([]byte(`{ + "rate_limit":{"primary_window":{"used_percent":100,"limit_window_seconds":604800,"reset_at":1788700000}}, + "additional_rate_limits":[{"metered_feature":"codex_bengalfox","limit_name":"GPT-5.3-Codex-Spark", + "rate_limit":{"secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1789222487}}}] + }`), nil) + if err != nil { + t.Fatal(err) + } + var active quotaSnapshot + if err := json.Unmarshal(raw, &active); err != nil { + t.Fatal(err) + } + windows := NormalizePassiveQuotaWindows(map[string]string{ + "X-Codex-Active-Limit": "codex_bengalfox", + "X-Codex-Secondary-Used-Percent": "0", + "X-Codex-Secondary-Window-Minutes": "10080", + "X-Codex-Secondary-Reset-At": "1789222487", + }, time.Now()) + if len(windows) != 1 { + t.Fatalf("windows = %#v, want the rebound Spark window", windows) + } + spark := active.QuotaWindows[1] + if windows[0].SourceID != spark.SourceID { + t.Fatalf("passive source = %q, want the active metered feature %q", windows[0].SourceID, spark.SourceID) + } + if windows[0].WindowSeconds == nil || spark.WindowSeconds == nil || + *windows[0].WindowSeconds != *spark.WindowSeconds { + t.Fatalf("passive period does not align with the active window: %#v", windows[0]) + } +} + +// 去重只针对通用组与独立命名空间之间的重复报告。通用组内部的两个槽位始终是 +// 两份不同的数据,即使周期相同也不能互相判定为副本而双双消失,是否可用交给 +// 合并层判定。 +func TestPassiveQuotaDeduplicationIgnoresSiblingGenericWindows(t *testing.T) { + windows := NormalizePassiveQuotaWindows(map[string]string{ + "X-Codex-Active-Limit": "premium", + "X-Codex-Primary-Used-Percent": "10", + "X-Codex-Primary-Window-Minutes": "300", + "X-Codex-Secondary-Used-Percent": "20", + "X-Codex-Secondary-Window-Minutes": "300", + }, time.Now()) + if len(windows) != 2 { + t.Fatalf("windows = %#v, want both generic slots preserved", windows) + } + for index, window := range windows { + if window.SourceID != "codex" || window.WindowSeconds == nil || *window.WindowSeconds != 18000 { + t.Fatalf("window %d = %#v", index, window) + } + } + if windows[0].Used == nil || *windows[0].Used != 10 || + windows[1].Used == nil || *windows[1].Used != 20 { + t.Fatalf("generic slots lost their own values: %#v", windows) + } +} diff --git a/internal/subscription/providers/codex/quota_source_test.go b/internal/subscription/providers/codex/quota_source_test.go index ee1c747f..42cc1cd5 100644 --- a/internal/subscription/providers/codex/quota_source_test.go +++ b/internal/subscription/providers/codex/quota_source_test.go @@ -25,10 +25,10 @@ func TestQuotaSourcesMatchWithoutDependingOnDisplayName(t *testing.T) { active.QuotaWindows[1].SourceID != "codex_future_tier" { t.Fatalf("active sources = %#v", active.QuotaWindows) } - // 名称变化不影响来源;Active-Limit 不应把普通窗口改绑到当前受限来源。 + // 名称变化不影响来源;本次计费到普通额度,通用组仍归账号自身。 // 周窗口从主动 primary 变为响应头 secondary。 passive := NormalizePassiveQuotaWindows(map[string]string{ - "X-Codex-Active-Limit": "codex_future_tier", + "X-Codex-Active-Limit": "premium", "X-Codex-Future-Tier-Limit-Name": "Renamed display", "X-Codex-Secondary-Used-Percent": "90", "X-Codex-Secondary-Window-Minutes": "10080", diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index 4479f9de..ecef787d 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -174,6 +174,7 @@ export interface CredentialAccountDto { } export interface CredentialQuotaWindowDto { + source_id?: string id: string label: string label_key?: CredentialQuotaLabelKey diff --git a/web/src/app/resources/credentials.ts b/web/src/app/resources/credentials.ts index 0c7ac68e..6dfd1aa6 100644 --- a/web/src/app/resources/credentials.ts +++ b/web/src/app/resources/credentials.ts @@ -212,6 +212,7 @@ const resetCreditConsumeFields = [ 'replayed', ] as const const quotaWindowFields = [ + 'source_id', 'id', 'label', 'label_key', @@ -373,6 +374,7 @@ function projectQuotaWindow(value: unknown): CredentialQuotaWindowDto { const remaining = projectOptionalNumber(record, 'remaining', { minimum: 0 }) const utilization = projectOptionalNumber(record, 'utilization', { minimum: 0, maximum: 1 }) return { + ...(record.source_id === undefined ? {} : { source_id: projectString(record.source_id) }), id, label, ...(record.label_key === undefined