* 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 是两份数据而非互为副本,周期相同时 原实现让它们互相判定为重复并双双丢弃。去重范围收窄为通用窗口与独立 命名空间窗口之间。
160 lines
6.6 KiB
Go
160 lines
6.6 KiB
Go
package subscription
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"gpt-load/internal/subscription/providers/codex"
|
|
providerobservation "gpt-load/internal/subscription/providers/observation"
|
|
)
|
|
|
|
const codexSourceQuotaPayload = `{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":80,"limit_window_seconds":604800,"reset_at":1800000000}},"additional_rate_limits":[{"metered_feature":"codex_bengalfox","limit_name":"GPT-5.3-Codex-Spark","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000,"reset_at":1799900000}}}]}`
|
|
|
|
func TestPassiveQuotaMatchesSourceAndPeriodAcrossSlots(t *testing.T) {
|
|
raw, err := codex.NormalizeQuota([]byte(codexSourceQuotaPayload), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var active providerobservation.Snapshot
|
|
if err := json.Unmarshal(raw, &active); err != nil {
|
|
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",
|
|
"X-Codex-Secondary-Window-Minutes": "10080",
|
|
"X-Codex-Secondary-Reset-At": "1800000600",
|
|
"X-Codex-Bengalfox-Secondary-Used-Percent": "30",
|
|
"X-Codex-Bengalfox-Secondary-Window-Minutes": "300",
|
|
"X-Codex-Bengalfox-Secondary-Reset-At": "1799900600",
|
|
}, time.Unix(1799890000, 0))
|
|
merged, err := mergePassiveQuotaSnapshot(raw, patches)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !merged.Matched || !merged.Changed || len(merged.Windows) != 2 {
|
|
t.Fatalf("matched=%v changed=%v windows=%s, want both existing windows refreshed", merged.Matched, merged.Changed, merged.Encoded)
|
|
}
|
|
for index, expected := range []struct {
|
|
used float64
|
|
reset int64
|
|
state string
|
|
}{{100, 1800000600000, "exhausted"}, {30, 1799900600000, "available"}} {
|
|
got, previous := merged.Windows[index], active.QuotaWindows[index]
|
|
if got.ID != previous.ID || got.SourceID != previous.SourceID ||
|
|
got.Label != previous.Label || got.Scope != previous.Scope ||
|
|
!reflect.DeepEqual(got.WindowSeconds, previous.WindowSeconds) ||
|
|
got.Used == nil || *got.Used != expected.used ||
|
|
got.ResetAtMS == nil || *got.ResetAtMS != expected.reset || got.State != expected.state {
|
|
t.Fatalf("wrong target or metadata overwritten: %s", merged.Encoded)
|
|
}
|
|
}
|
|
var encoded providerobservation.Snapshot
|
|
if err := json.Unmarshal(merged.Encoded, &encoded); err != nil ||
|
|
!reflect.DeepEqual(encoded.QuotaWindows, merged.Windows) {
|
|
t.Fatalf("encoded quota windows differ: %s, %v", merged.Encoded, err)
|
|
}
|
|
}
|
|
|
|
func TestPassiveQuotaSourceMatchingRejectsUncertainTargets(t *testing.T) {
|
|
period, otherPeriod := int64(18000), int64(604800)
|
|
used, utilization := 50.0, 0.5
|
|
patch := providerobservation.QuotaWindow{
|
|
ID: "primary", SourceID: "codex", WindowSeconds: &period,
|
|
Used: &used, Utilization: &utilization, State: "exhausted",
|
|
}
|
|
window := providerobservation.QuotaWindow{
|
|
ID: "primary", SourceID: "codex", WindowSeconds: &period, State: "available",
|
|
}
|
|
for _, name := range []string{
|
|
"different source same ID and period", "unknown source", "missing patch period",
|
|
"missing stored period", "legacy snapshot", "unidentified patch",
|
|
"ambiguous stored windows", "duplicate response windows", "period conflict",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
windows, patches := []providerobservation.QuotaWindow{window}, []providerobservation.QuotaWindow{patch}
|
|
switch name {
|
|
case "different source same ID and period":
|
|
patches[0].SourceID = "codex_bengalfox"
|
|
case "unknown source":
|
|
patches[0].SourceID = "codex_unknown"
|
|
case "missing patch period":
|
|
patches[0].WindowSeconds = nil
|
|
case "missing stored period":
|
|
windows[0].WindowSeconds = nil
|
|
case "legacy snapshot":
|
|
windows[0].SourceID = ""
|
|
case "unidentified patch":
|
|
patches[0].SourceID = ""
|
|
case "ambiguous stored windows":
|
|
second := window
|
|
second.ID = "secondary"
|
|
windows = append(windows, second)
|
|
case "duplicate response windows":
|
|
second := patch
|
|
second.ID = "secondary"
|
|
patches = append(patches, second)
|
|
case "period conflict":
|
|
patches[0].WindowSeconds = &otherPeriod
|
|
}
|
|
raw, err := json.Marshal(providerobservation.Snapshot{QuotaWindows: windows})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
merged, err := mergePassiveQuotaSnapshot(raw, patches)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if merged.Matched || merged.Changed || !bytes.Equal(merged.Encoded, raw) {
|
|
t.Fatalf("uncertain sample changed data or freshness: matched=%v changed=%v snapshot=%s", merged.Matched, merged.Changed, merged.Encoded)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 复现维护者现场:普通 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)
|
|
}
|
|
}
|