From 1774b79dcbf9ba25a007772c015d29758b7fdbc2 Mon Sep 17 00:00:00 2001 From: coder-zhw <5379212+coder-zhw@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:24:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(zhipu):=20=E4=BF=AE=E6=AD=A3=20GLM=20Coding?= =?UTF-8?q?=20Plan=205h/weekly=20=E7=94=A8=E9=87=8F=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=A0=87=E5=8F=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseZhipuTokenTiers 只在 limitType == "TOKENS_LIMIT" 分支里按 unit 显式分类, 而智谱当前把 5h / weekly 两条窗口都标成 CREDIT_LIMIT(level=lite)。这些条目被放进 creditFallback,entry 结构体又不带 unit,信息在此永久丢失,降级路径退化为 「按 nextResetTime 升序填空位」。当周窗口先于 5h 重置时(每个周期临尾的几小时), 周窗口会被先填进 5h 槽,两档必然标反 —— 函数文档注释第 1 条已警告过该场景, 只是防线建在了错误的 type 分支上。 因为只在「CREDIT_LIMIT 且周窗口更早重置」这一种组合下出错,症状是间歇性的, 容易被误判为随机抽风。该函数自 v0.2.0 到 v0.2.16 逐字节未变,不属于版本回归。 改动: - entry 增加 unit 字段;classify 改为直接接收 unit,不再依赖 item。 - 仅在降级路径(无任何 TOKENS_LIMIT 条目)上,对 creditFallback 中 unit 可识别的 条目先按 unit 分类;unit 仍不可识别的才落入原有的 reset 时间启发式。 - TOKENS_LIMIT 优先占槽的既有语义保持不变:两种 type 同时返回时 CREDIT_LIMIT 依旧不参与槽位竞争。 - 更新 cnZhipuWindow 与 parseZhipuTokenTiers 的文档注释。 - cn_providers_test.go 补充 3 个回归用例。 验证: - 反向验证:仅回退源文件保留测试,CreditLimitUnitClassification 失败于 14 vs 90(两档被对调);另两个钉住原语义的用例修复前后均通过。 - go build ./... 通过;gofmt -l 无输出;golangci-lint v2.14.0 0 issues。 - go test -tags=unit ./internal/service/ 全量通过。 影响范围:backend/internal/service 两个文件(service + unit test)。 --- .../service/cn_provider_quota_service.go | 25 ++++++--- backend/internal/service/cn_providers_test.go | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index def7586fa4..6bbeddec52 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -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 ( @@ -529,7 +529,8 @@ 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 { @@ -537,6 +538,7 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier { hasReset bool percentage float64 resetISO string + unit int64 } var ( fiveHour entry @@ -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 @@ -592,10 +594,10 @@ 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) } @@ -603,8 +605,17 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier { }) // 无任何 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 升序,依次填入仍空缺的槽位。 diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index b78dfbebeb..61fea87a5a 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -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()