fix(subscription): 修复被动额度契约缺失与来源误归属 (#575)

* 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 是两份数据而非互为副本,周期相同时
原实现让它们互相判定为重复并双双丢弃。去重范围收窄为通用窗口与独立
命名空间窗口之间。
This commit is contained in:
tbphp authored and GitHub committed 2026-09-06 11:55:18 +08:00
1 parent 2aa5002a45
commit 4a99ab1496
8 files changed
+315 -7

No files matched your search

@@ -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",
@@ -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)
}
}
@@ -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 {
@@ -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-<ns>-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",
@@ -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)
}
}
@@ -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",
+1
View File
@@ -174,6 +174,7 @@ export interface CredentialAccountDto {
}
export interface CredentialQuotaWindowDto {
source_id?: string
id: string
label: string
label_key?: CredentialQuotaLabelKey
+2
View File
@@ -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