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:
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",
|
||||
|
||||
@@ -174,6 +174,7 @@ export interface CredentialAccountDto {
|
||||
}
|
||||
|
||||
export interface CredentialQuotaWindowDto {
|
||||
source_id?: string
|
||||
id: string
|
||||
label: string
|
||||
label_key?: CredentialQuotaLabelKey
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user