Skip to content

fix(zhipu): 修正 GLM Coding Plan 5h/weekly 用量窗口标反 / fix swapped GLM coding plan usage windows - #8087

Merged
Wei-Shaw merged 1 commit into
Wei-Shaw:mainfrom
coder-zhw:fix/zhipu-credit-limit-window-classification
Oct 15, 2026
Merged

Wei-Shaw merged 1 commit into
Wei-Shaw:mainfrom
coder-zhw:fix/zhipu-credit-limit-window-classification

Conversation

@coder-zhw

Copy link
Copy Markdown
Contributor

中文

问题与原因

智谱 GLM Coding Plan 账号在管理端「用量窗口」列会把 5h 与 weekly 两档的用量互换:

界面标签 实际显示 正确值
5h 90%、重置倒计时 2h 左右 14%
weekly 14%、重置倒计时 4h 左右 90%

一个 7 天窗口只剩 4 小时就重置,说明两档串了。上游响应本身完全正确。

根因在 backend/internal/service/cn_provider_quota_service.go 的 parseZhipuTokenTiers。

unit 是窗口语义的权威来源(classifyZhipuWindowUnit:3 = 5h,6 = weekly),但 classify 只在 limitType == "TOKENS_LIMIT" 分支里被调用。而 GLM Coding Plan(data.level = "lite")当前把两条窗口都标成 CREDIT_LIMIT,其中 unit 依然有效:

{
  "data": {
    "limits": [
      {"type":"CREDIT_LIMIT","unit":3,"number":5,"usage":2000,"currentValue":299,"remaining":1700,"percentage":14,"nextResetTime":1791987903321},
      {"type":"CREDIT_LIMIT","unit":6,"number":1,"usage":10000,"currentValue":9062,"remaining":937,"percentage":90,"nextResetTime":1791980214984}
    ],
    "level": "lite"
  }
}

(unit=3 → 5h 窗口,unit=6 → weekly;nextResetTime 分别对应 14:25:03Z 与 12:16:54Z。注意 weekly 的重置时刻比 5h 更早,这正是周期末尾的形态。)

于是 CREDIT_LIMIT 条目被放进 creditFallback;entry 结构体不含 unit,信息在这一步永久丢失,降级路径退化为「按 nextResetTime 升序依次填空位」。当周窗口先于 5h 重置时,排序后周窗口首先被填进 5h 槽 —— 两档必然标反。

函数自己的文档注释第 1 条已经警告过这个场景:

1. 显式 unit 字段(3=5h / 6=weekly)——不能用 reset 排序代替,周期末尾周窗口会比 5h 更早重置,时间排序必然标反。

只是防线只建在 TOKENS_LIMIT 分支上,上游一改 type 就失效。

症状是间歇性的:只有「CREDIT_LIMIT 且周窗口先于 5h 重置」这一种组合出错。一周里大部分时间周窗口的重置时刻远在 5h 之后,此时解析正确;只有每个周期临尾的那几个小时会标反。因此很容易被误判为随机抽风或某次升级引入 —— 实际上该函数自 v0.2.0 到 v0.2.16 逐字节未变(六个 tag 的 parseZhipuTokenTiers 函数体 sha256 相同)。

参考对照:cc-switch 的 parse_zhipu_token_tiers(src-tauri/src/services/coding_plan.rs)对两种 type 都调用 classify_zhipu_window,只有 unit 认不出时才走兜底,因此同一份上游数据不会标反。

本次改动

沿用原有语义,只修降级路径:

  • entry 增加 unit 字段;classify 改为直接接收 unit,不再依赖 item。
  • 仅在降级路径(无任何 TOKENS_LIMIT 条目)上,对 creditFallback 中 unit 可识别的条目先按 unit 分类;unit 仍不可识别的条目才落入原有的 reset 时间启发式。
  • TOKENS_LIMIT 优先占槽的既有语义完全不变:两种 type 同时返回时,CREDIT_LIMIT 依旧不参与槽位竞争(hasTokensLimit 为真时 creditFallback 仍被丢弃),原有「避免信用额度污染阈值停调快照」的意图保留。
  • 更新 cnZhipuWindow 与 parseZhipuTokenTiers 的文档注释。
  • cn_providers_test.go 补充 3 个回归用例。

只改了 service 层两个文件,未触及 handler、前端、Extra 快照键与调度逻辑。

备选方案:也可以像 cc-switch 那样让两种 type 一视同仁地按 unit 竞争槽位(改动更小、语义更统一)。但那样会在「两种 type 同时返回」时改变既有的 TOKENS_LIMIT 优先级,故本次选了不改语义的保守版本;如果维护者倾向统一,我可以改。

回归覆盖与验证

新增用例:

  • TestParseZhipuTokenTiers_CreditLimitUnitClassification —— 两条 CREDIT_LIMIT 都带 unit,且刻意让 weekly 的 nextResetTime 早于 5h(周期末尾),断言 5h = 14%、weekly = 90%。
  • TestParseZhipuTokenTiers_CreditLimitWithoutUnitFallback —— CREDIT_LIMIT 缺 unit 时保留原有 fallback(无 nextResetTime 优先归 5h,其余按 reset 升序)。
  • TestParseZhipuTokenTiers_TokensLimitWinsOverCreditLimit —— 两种 type 同时返回时 TOKENS_LIMIT 仍优先占槽,钉住未改动的语义。

验证结果:

  • 反向验证:仅回退 cn_provider_quota_service.go、保留测试,TestParseZhipuTokenTiers_CreditLimitUnitClassification 失败于 Max difference between 14 and 90 allowed is 1e-09, but difference was -76,即两档被对调;另两个「保持原语义」的用例在修复前后都通过。
  • 修复后 TestParseZhipuTokenTiers 全部 7 个用例通过,含 4 个既有用例,无回归。
  • go build ./... 通过;gofmt -l 无输出。
  • go test -tags=unit ./...:56 个包通过。另有一个与本改动无关的用例 TestInflightEstimate_AccountMappingNoDBAndBoundedMemory 在整包并发跑时偶发失败(内存增长断言 8742520 略超 8388608 上界约 350 KB),单独复跑 3 次均通过,属负载/GC 敏感的既有 flake,未修改相关代码。

English

Problem and cause

A Zhipu GLM Coding Plan account shows its 5h and weekly usage windows swapped in the admin "quota windows" column: the 5h row reports 90% with a ~2h reset countdown while the weekly row reports 14% with a ~4h countdown. A seven-day window cannot have four hours left. The upstream response itself is correct.

Root cause is in parseZhipuTokenTiers (backend/internal/service/cn_provider_quota_service.go).

unit is the authoritative source of window semantics (classifyZhipuWindowUnit: 3 = 5h, 6 = weekly), but classify is only invoked on the limitType == "TOKENS_LIMIT" branch. GLM Coding Plan (data.level = "lite") currently returns both windows as CREDIT_LIMIT, with unit still populated.

CREDIT_LIMIT entries therefore go into creditFallback, and the entry struct carries no unit, so the information is lost at that point. The fallback degrades to "sort by nextResetTime ascending and fill the empty slots". Whenever the weekly window resets earlier than the 5h window, the weekly entry is filled into the 5h slot first — the two tiers are always swapped.

The function's own doc comment already warns about this exact scenario; the guard was just placed on the wrong branch, so it stops working as soon as upstream switches type.

The symptom is intermittent. Only the combination "CREDIT_LIMIT and weekly resets before 5h" is affected. For most of a cycle the weekly reset is far later than the 5h reset and parsing is correct; only the last few hours before each weekly reset invert. This makes it look like random flakiness or a regression, but the function is byte-identical from v0.2.0 through v0.2.16.

For reference, cc-switch's parse_zhipu_token_tiers (src-tauri/src/services/coding_plan.rs) calls classify_zhipu_window for both types and only falls back when unit is unrecognized, which is why it renders the same payload correctly.

Changes

Preserves existing semantics; only the fallback path is fixed:

  • entry gains a unit field; classify now takes unit directly instead of item.
  • Only on the fallback path (no TOKENS_LIMIT entry at all), creditFallback entries whose unit is recognizable are classified by unit first; entries with an unrecognized unit still take the original reset-time heuristic.
  • The existing TOKENS_LIMIT-first slot priority is unchanged: when both types are present, CREDIT_LIMIT still does not compete for slots (creditFallback is still dropped when hasTokensLimit is true), preserving the original intent of keeping credit figures out of the scheduling-threshold snapshot.
  • Doc comments on cnZhipuWindow and parseZhipuTokenTiers updated.
  • Three regression cases added to cn_providers_test.go.

Only two files in the service layer change; handlers, frontend, Extra snapshot keys and scheduling logic are untouched.

Alternative: mirror cc-switch by letting both types compete for slots by unit (smaller diff, more uniform semantics). That would change the documented TOKENS_LIMIT priority when both types are returned, so this PR takes the conservative route instead. Happy to switch if you prefer the uniform version.

Regression coverage and validation

  • TestParseZhipuTokenTiers_CreditLimitUnitClassification — both CREDIT_LIMIT entries carry unit, with the weekly nextResetTime deliberately earlier than the 5h one; asserts 5h = 14% and weekly = 90%.
  • TestParseZhipuTokenTiers_CreditLimitWithoutUnitFallback — a CREDIT_LIMIT entry without unit keeps the original fallback (no nextResetTime prefers 5h, the rest fill remaining slots by ascending reset).
  • TestParseZhipuTokenTiers_TokensLimitWinsOverCreditLimit — with both types present, TOKENS_LIMIT still wins the slot, pinning the untouched semantics.

Validation performed:

  • Negative check: reverting only cn_provider_quota_service.go while keeping the tests makes TestParseZhipuTokenTiers_CreditLimitUnitClassification fail with Max difference between 14 and 90 allowed is 1e-09, but difference was -76, i.e. the two tiers swapped. The other two cases (which pin preserved behavior) pass both before and after the fix.
  • After the fix all 7 TestParseZhipuTokenTiers_* cases pass, including the 4 pre-existing ones — no regression.
  • go build ./... passes; gofmt -l is clean.
  • go test -tags=unit ./...: 56 packages pass. One unrelated case, TestInflightEstimate_AccountMappingNoDBAndBoundedMemory, failed intermittently when the whole suite ran concurrently (a memory-growth assertion measured 8742520 against an 8388608 bound, ~350 KB over). It passes 3/3 when run in isolation and no related code is touched, so it is a pre-existing load/GC-sensitive flake.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@coder-zhw

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

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)。
@coder-zhw
coder-zhw force-pushed the fix/zhipu-credit-limit-window-classification branch from 46ea122 to 1774b79 Compare October 15, 2026 06:56
github-actions Bot added a commit that referenced this pull request Oct 15, 2026
@coder-zhw

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@Wei-Shaw
Wei-Shaw merged commit fdc7f1f into Wei-Shaw:main Oct 15, 2026
9 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants