From 20de0d9c792515b7b8904f73c5dbff3a252bedfd Mon Sep 17 00:00:00 2001 From: Mr White <42571711+Neuron-Mr-White@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:13:19 +0800 Subject: [PATCH] fix(usage): parse CREDIT_LIMIT rows from z.ai coding-plan quota API (#11378) Merged via consolidated batch validation. Z.ai's quota API now returns CREDIT_LIMIT rows for GLM Coding Plan subscription keys instead of TOKENS_LIMIT, breaking the dashboard quota card. Own test passes. --- changelog.d/fixes/glm-credit-limit-quota.md | 1 + open-sse/services/usage/glm.ts | 23 ++++- tests/unit/glm-team-quota.test.ts | 93 +++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/glm-credit-limit-quota.md diff --git a/changelog.d/fixes/glm-credit-limit-quota.md b/changelog.d/fixes/glm-credit-limit-quota.md new file mode 100644 index 0000000000..e46eb96ee9 --- /dev/null +++ b/changelog.d/fixes/glm-credit-limit-quota.md @@ -0,0 +1 @@ +- **fix(usage):** z.ai/GLM coding-plan subscription keys now render their quota cards again, with absolute credits. Z.ai's `/api/monitor/usage/quota/limit` switched these keys from `TOKENS_LIMIT` to `CREDIT_LIMIT` rows (same `unit`/`number` semantics: unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly), and the parser only matched `TOKENS_LIMIT`/`TIME_LIMIT`, so both rows were dropped and the subscription card rendered empty. `CREDIT_LIMIT` is now accepted alongside `TOKENS_LIMIT`, and when the row carries absolute credit fields (`usage`/`currentValue`/`remaining`) they are preferred over the percent-only scale, so the card shows `3341 / 28000` like z.ai's own dashboard instead of `11 / 100` diff --git a/open-sse/services/usage/glm.ts b/open-sse/services/usage/glm.ts index 0e14a36aba..489e16efde 100644 --- a/open-sse/services/usage/glm.ts +++ b/open-sse/services/usage/glm.ts @@ -155,15 +155,30 @@ export async function getGlmUsage(apiKey: string, providerSpecificData?: Record< const resetMs = toNumber(src.nextResetTime, 0); const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null; - if (type === "TOKENS_LIMIT") { + // Z.ai coding-plan keys (CREDIT-based, e.g. GLM Coding Max/Lite) report + // CREDIT_LIMIT rows with the same unit/number semantics as TOKENS_LIMIT + // (unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly). Without + // this branch every CREDIT_LIMIT row is dropped and the quota card + // renders empty for subscription keys. + if (type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT") { const quotaName = getGlmTokenQuotaName(src, quotas); const usedPercent = toPercentage(src.percentage); const remaining = Math.max(0, 100 - usedPercent); + // CREDIT_LIMIT rows (z.ai coding-plan keys) carry absolute credits on + // top of the percentage: usage = window total, currentValue = consumed, + // remaining = credits left. Prefer them so the quota card renders + // "3341 / 28000" like z.ai's own dashboard instead of a percent-only + // scale. TOKENS_LIMIT rows without absolute fields keep the percent path. + const totalCredits = toNumber(src.usage, 0); + const usedCredits = totalCredits > 0 ? toNumber(src.currentValue, usedPercent) : usedPercent; + const remainingCredits = totalCredits > 0 ? toNumber(src.remaining, remaining) : remaining; + const total = totalCredits > 0 ? totalCredits : 100; + quotas[quotaName] = { - used: usedPercent, - total: 100, - remaining, + used: usedCredits, + total, + remaining: remainingCredits, remainingPercentage: remaining, resetAt, displayName: getGlmQuotaDisplayName(quotaName), diff --git a/tests/unit/glm-team-quota.test.ts b/tests/unit/glm-team-quota.test.ts index cdbede97dd..a7e6cac870 100644 --- a/tests/unit/glm-team-quota.test.ts +++ b/tests/unit/glm-team-quota.test.ts @@ -317,3 +317,96 @@ describe("getGlmUsage team quota parsing", () => { } }); }); + +describe("getGlmUsage CREDIT_LIMIT (coding-plan subscription keys)", () => { + // Real-world response from https://api.z.ai/api/monitor/usage/quota/limit + // for a GLM Coding Max subscription key (2026-08): limits use CREDIT_LIMIT + // instead of TOKENS_LIMIT, with identical unit/number semantics plus + // absolute credit fields (usage/currentValue/remaining). + const CREDIT_LIMIT_RESPONSE = { + code: 200, + msg: "Operation successful", + data: { + limits: [ + { + type: "CREDIT_LIMIT", + unit: 3, + number: 5, + usage: 28000, + currentValue: 3341, + remaining: 24658, + percentage: 11, + nextResetTime: 1787563232239, + }, + { + type: "CREDIT_LIMIT", + unit: 6, + number: 1, + usage: 140000, + currentValue: 25224, + remaining: 114775, + percentage: 18, + nextResetTime: 1788077327998, + }, + ], + level: "max", + }, + success: true, + }; + + it("maps CREDIT_LIMIT rows to session/weekly quotas with absolute credits", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify(CREDIT_LIMIT_RESPONSE), { status: 200 }); + + try { + const usage = await getGlmUsage("zai-subscription-key"); + + assert.equal(usage.plan, "Max"); + assert.ok(usage.quotas.session, "5-hour window quota should render"); + assert.ok(usage.quotas.weekly, "weekly quota should render"); + // Absolute credits — matches z.ai's own dashboard ("4.1K / 140K" style). + assert.equal(usage.quotas.session.used, 3341); + assert.equal(usage.quotas.session.total, 28000); + assert.equal(usage.quotas.session.remaining, 24658); + assert.equal(usage.quotas.weekly.used, 25224); + assert.equal(usage.quotas.weekly.total, 140000); + assert.equal(usage.quotas.weekly.remaining, 114775); + // Percentages stay derived from the upstream percentage field. + assert.equal(usage.quotas.session.remainingPercentage, 89); + assert.equal(usage.quotas.weekly.remainingPercentage, 82); + assert.equal(usage.quotas.session.displayName, "5 Hours Quota"); + assert.equal(usage.quotas.weekly.displayName, "Weekly Quota"); + assert.equal(usage.quotas.session.resetAt, new Date(1787563232239).toISOString()); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("falls back to the percent scale when a CREDIT_LIMIT row has no absolute fields", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: 200, + success: true, + data: { + limits: [{ type: "CREDIT_LIMIT", unit: 3, number: 5, percentage: 40 }], + level: "lite", + }, + }), + { status: 200 } + ); + + try { + const usage = await getGlmUsage("zai-key"); + + assert.equal(usage.quotas.session.used, 40); + assert.equal(usage.quotas.session.total, 100); + assert.equal(usage.quotas.session.remaining, 60); + assert.equal(usage.quotas.session.remainingPercentage, 60); + } finally { + globalThis.fetch = originalFetch; + } + }); +});