Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions backend/internal/service/cn_provider_quota_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ func minimaxResetTime(v gjson.Result) string {
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}

// cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。
// cnZhipuWindow 标识智谱额度条目(TOKENS_LIMIT / CREDIT_LIMIT)所属窗口。
type cnZhipuWindow int

const (
Expand Down Expand Up @@ -529,14 +529,16 @@ func classifyZhipuWindowUnit(unit int64) cnZhipuWindow {
//
// CREDIT_LIMIT(信用额度)与 TOKENS_LIMIT(token 窗口)度量不同:两者同时返回时
// 只让 TOKENS_LIMIT 参与 5h/weekly 槽位竞争,避免信用额度百分比污染阈值停调
// 快照;仅当无任何 TOKENS_LIMIT 条目时才降级用 CREDIT_LIMIT 展示。
// 快照;仅当无任何 TOKENS_LIMIT 条目时才降级用 CREDIT_LIMIT 展示 —— 此时
// CREDIT_LIMIT 同样按 unit 分类(它也携带 unit),不依赖 reset 时间排序。
// 老套餐只回 1 条 TOKENS_LIMIT,自然降级为仅 5h;新套餐回 2 条。
func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
type entry struct {
resetMs int64
hasReset bool
percentage float64
resetISO string
unit int64
}
var (
fiveHour entry
Expand All @@ -546,8 +548,8 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
unclassified []entry
)

classify := func(item gjson.Result, e entry) {
switch classifyZhipuWindowUnit(item.Get("unit").Int()) {
classify := func(unit int64, e entry) {
switch classifyZhipuWindowUnit(unit) {
case cnZhipuWindow5h:
if !fiveHourSet {
fiveHour, fiveHourSet = e, true
Expand Down Expand Up @@ -592,19 +594,28 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
hasReset = resetISO != ""
}
}
e := entry{resetMs: resetMs, hasReset: hasReset, percentage: percentage, resetISO: resetISO}
e := entry{resetMs: resetMs, hasReset: hasReset, percentage: percentage, resetISO: resetISO, unit: item.Get("unit").Int()}
if limitType == "TOKENS_LIMIT" {
hasTokensLimit = true
classify(item, e)
classify(e.unit, e)
} else {
creditFallback = append(creditFallback, e)
}
return true
})

// 无任何 TOKENS_LIMIT 条目(部分套餐只报信用额度):降级用 CREDIT_LIMIT 展示。
// CREDIT_LIMIT 条目同样带 unit(3=5h / 6=weekly;实测 GLM Coding Plan 两条窗口
// 都是 CREDIT_LIMIT),必须和 TOKENS_LIMIT 一样先按 unit 显式分类 —— 否则会退化
// 成 reset 时间启发式,而周期末尾周窗口比 5h 更早重置,时间排序必然把两档标反。
if !hasTokensLimit {
unclassified = append(unclassified, creditFallback...)
for _, e := range creditFallback {
if classifyZhipuWindowUnit(e.unit) != cnZhipuWindowUnknown {
classify(e.unit, e)
continue
}
unclassified = append(unclassified, e)
}
}

// 无 reset 的条目排前,再按 reset 升序,依次填入仍空缺的槽位。
Expand Down
54 changes: 54 additions & 0 deletions backend/internal/service/cn_providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,60 @@ func TestParseZhipuTokenTiers_UnitClassification(t *testing.T) {
require.InDelta(t, 70.0, tiers[1].UsedPercent, 1e-9)
}

// TestParseZhipuTokenTiers_CreditLimitUnitClassification GLM Coding Plan 当前把 5h / weekly
// 两条窗口都标成 CREDIT_LIMIT(实测 level=lite)。CREDIT_LIMIT 同样携带 unit,
// 必须和 TOKENS_LIMIT 一样按 unit 显式分类,否则会退化成 reset 时间排序 ——
// 此处刻意让 weekly 的 nextResetTime 早于 5h(周期末尾),两档会被标反。
func TestParseZhipuTokenTiers_CreditLimitUnitClassification(t *testing.T) {
t.Parallel()
data := gjson.Parse(`{
"limits": [
{"type":"CREDIT_LIMIT","unit":3,"number":5,"percentage":14,"nextResetTime":1700000099999},
{"type":"CREDIT_LIMIT","unit":6,"number":1,"percentage":90,"nextResetTime":1700000000000}
]
}`)
tiers := parseZhipuTokenTiers(data)
require.Len(t, tiers, 2)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 14.0, tiers[0].UsedPercent, 1e-9)
require.Equal(t, "weekly", tiers[1].Window)
require.InDelta(t, 90.0, tiers[1].UsedPercent, 1e-9)
}

// TestParseZhipuTokenTiers_CreditLimitWithoutUnitFallback CREDIT_LIMIT 缺 unit 时,
// 保留原有 fallback:无 nextResetTime 的优先归 5h,其余按 reset 升序填入剩余槽位。
func TestParseZhipuTokenTiers_CreditLimitWithoutUnitFallback(t *testing.T) {
t.Parallel()
data := gjson.Parse(`{
"limits": [
{"type":"CREDIT_LIMIT","percentage":30,"nextResetTime":1700000000000},
{"type":"CREDIT_LIMIT","percentage":80}
]
}`)
tiers := parseZhipuTokenTiers(data)
require.Len(t, tiers, 2)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 80.0, tiers[0].UsedPercent, 1e-9)
require.Equal(t, "weekly", tiers[1].Window)
require.InDelta(t, 30.0, tiers[1].UsedPercent, 1e-9)
}

// TestParseZhipuTokenTiers_TokensLimitWinsOverCreditLimit 两种 type 同时返回时,
// TOKENS_LIMIT 仍然优先占槽、CREDIT_LIMIT 不参与(原有语义保持不变)。
func TestParseZhipuTokenTiers_TokensLimitWinsOverCreditLimit(t *testing.T) {
t.Parallel()
data := gjson.Parse(`{
"limits": [
{"type":"TOKENS_LIMIT","unit":3,"percentage":20,"nextResetTime":1700000000000},
{"type":"CREDIT_LIMIT","unit":6,"percentage":99,"nextResetTime":1700000099999}
]
}`)
tiers := parseZhipuTokenTiers(data)
require.Len(t, tiers, 1)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 20.0, tiers[0].UsedPercent, 1e-9)
}

// TestParseZhipuTokenTiers_SingleTierOldPlan 老套餐仅回 1 条 → 降级为仅 5h。
func TestParseZhipuTokenTiers_SingleTierOldPlan(t *testing.T) {
t.Parallel()
Expand Down
Loading