From dcda7fa59e8922ea93f6c80ce477551461eae260 Mon Sep 17 00:00:00 2001 From: jamespan Date: Tue, 18 Aug 2026 20:29:17 +0800 Subject: [PATCH 1/4] feat(quota): support GLM coding plan quota on z.ai and bigmodel.cn Probe the /api/monitor/usage/quota/limit endpoint for the GLM Coding Plan on both Z.AI regions (api.z.ai and open.bigmodel.cn), including the OpenAI Responses endpoint (/api/v1) on BigModel. Parses the limits-array payload, falls back to legacy field-name payloads, and dispatches for zai, glm, glm-cn, and zhipu-bigmodel-coding. The pay-as-you-go /api/paas/v4 route never probes. --- src/providers/quota.ts | 120 ++++++++++++++++++++++++++------- tests/provider-quota.test.ts | 126 ++++++++++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 27 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 24b06ef7a9..b1490ba235 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -50,6 +50,7 @@ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; const CLINE_BASE_URL = "https://api.cline.bot"; const ZAI_BASE_URL = "https://api.z.ai"; +const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; @@ -343,7 +344,12 @@ function isCanonicalClineBaseUrl(baseUrl: string): boolean { function isCanonicalZaiBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); - return normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`; + return normalized === ZAI_BASE_URL + || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` + || normalized === ZAI_CN_BASE_URL + || normalized === `${ZAI_CN_BASE_URL}/api/coding/paas/v4` + // BigModel serves the same GLM Coding Plan on the OpenAI Responses wire at /api/v1. + || normalized === `${ZAI_CN_BASE_URL}/api/v1`; } function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { @@ -669,34 +675,66 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro /** * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan - * subscription's 5-hour token cycle, weekly quota, and monthly MCP usage. - * Authenticates with the API key as a Bearer token per Z.AI's API reference. + * limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the + * same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT` + * rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 → + * the rolling five-hour window), unit 6 is weeks (number 1 → the weekly + * window). `TIME_LIMIT` rows are the monthly MCP tool budget (Web Search / Web + * Reader / Zread). Every row's `percentage` is the consumed share (falling + * back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms) + * the window reset. */ -async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; - const apiKey = resolveEnvValue(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${ZAI_BASE_URL}/api/monitor/usage/quota/limit`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; +export function parseZaiQuotaLimits(data: Record | null): ProviderQuota | null { + const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null; + if (!limits) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + for (const raw of limits) { + const row = asRecord(raw); + if (!row) continue; + const resetAt = normalizeResetAt(row.nextResetTime); + let percent = normalizePercent(row.percentage); + if (percent === undefined) { + const used = toFiniteNumber(row.currentValue); + const total = toFiniteNumber(row.usage); + if (used !== undefined && total !== undefined && total > 0) { + percent = normalizePercent((used / total) * 100); + } + } + if (percent === undefined) continue; + if (row.type === "TOKENS_LIMIT" || row.type === "CREDIT_LIMIT") { + const unit = toFiniteNumber(row.unit); + if (unit === 3) { + quota.fiveHourPercent = percent; + if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; + windows += 1; + } else if (unit === 6) { + quota.weeklyPercent = percent; + if (resetAt !== undefined) quota.weeklyResetAt = resetAt; + windows += 1; + } + } else if (row.type === "TIME_LIMIT") { + quota.monthlyPercent = percent; + if (resetAt !== undefined) quota.monthlyResetAt = resetAt; + windows += 1; + } } - const body = asRecord(await readQuotaJson(response)); - if (!body || body.success === false) return null; - const data = asRecord(body.data) ?? body; - // The plugin renders a 5h token window, a weekly window, and a monthly MCP - // window. Look for percent fields with window identifiers. + return windows > 0 ? quota : null; +} + +/** + * Legacy Z.AI payload shape: percent fields with window identifiers directly on + * the data object (optionally nested under `quota`). Kept as a fallback so + * older responses keep rendering when the `limits` array is absent. + */ +function parseZaiQuotaLegacyFields(data: Record | null): ProviderQuota | null { + if (!data) return null; const quota: ProviderQuota = { updatedAt: Date.now() }; let windows = 0; const percentAt = (key: string): number | undefined => { - const value = normalizePercent(data?.[key]); + const value = normalizePercent(data[key]); if (value !== undefined) return value; - const nested = asRecord(data?.quota); + const nested = asRecord(data.quota); return nested ? normalizePercent(nested[key]) : undefined; }; const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed"); @@ -714,7 +752,38 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi quota.monthlyPercent = monthly; windows += 1; } - return windows > 0 ? report(provider, "zai:quota-limit", quota) : null; + return windows > 0 ? quota : null; +} + +/** + * Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider + * points at (api.z.ai or open.bigmodel.cn). Authenticates with the API key as + * a Bearer token per Z.AI's API reference. The `limits` array shape is + * preferred; older field-name payloads fall back to the legacy parser. + */ +async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const normalized = normalizedBaseUrl(config.baseUrl); + const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` + ? ZAI_BASE_URL + : ZAI_CN_BASE_URL; + const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + if (!body || body.success === false) return null; + const data = asRecord(body.data) ?? body; + const quota = parseZaiQuotaLimits(data) ?? parseZaiQuotaLegacyFields(data); + return quota ? report(provider, "zai:quota-limit", quota) : null; } /** @@ -2106,7 +2175,8 @@ async function maybeFetchProviderQuota( if ((provider.authMode ?? "key") === "key" && name === "cline-pass") { return fetchClineQuota(name, provider); } - if ((provider.authMode ?? "key") === "key" && name === "zai") { + if ((provider.authMode ?? "key") === "key" + && (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) { return fetchZaiQuota(name, provider); } if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) { diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 1c4e733683..1486568421 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -883,18 +883,27 @@ describe("fetchProviderQuotaReports", () => { seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); return new Response(JSON.stringify({ success: true, - data: { fiveHourPercent: 40.5, weeklyPercent: 52, monthlyMCPUsage: 12.3 }, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 40.5, currentValue: 405, usage: 1000, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 52, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 12.3, nextResetTime: 1789000000000 }, + ], + }, }), { status: 200 }); }) as typeof fetch; - const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai"), true); expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("zai:quota-limit"); expect(result.reports[0]?.quota).toMatchObject({ fiveHourPercent: 40.5, + fiveHourResetAt: 1789000000000, weeklyPercent: 52, + weeklyResetAt: 1789600000000, monthlyPercent: 12.3, + monthlyResetAt: 1789000000000, }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); @@ -902,6 +911,103 @@ describe("fetchProviderQuotaReports", () => { expect(seen[0]?.redirect).toBe("error"); }); + test("Z.AI quota probes the BigModel region from the provider's own host", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); + // Weekly row omits `percentage`: the fallback derives it from currentValue/usage. + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "CREDIT_LIMIT", unit: 3, number: 5, percentage: 20, currentValue: 200, usage: 1000, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, currentValue: 156, usage: 300, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 7.5, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 20, + weeklyPercent: 52, + monthlyPercent: 7.5, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); + expect(seen[0]?.authorization).toBe("Bearer zhipu-bigmodel-coding-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("Z.AI quota falls back to legacy field-name payloads", async () => { + const seen: Array<{ url: string; authorization?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization }); + return new Response(JSON.stringify({ + success: true, + data: { fiveHourPercent: 40.5, weeklyPercent: 52, monthlyMCPUsage: 12.3 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 40.5, + weeklyPercent: 52, + monthlyPercent: 12.3, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); + }); + + test("Z.AI quota probes the BigModel Responses endpoint at /api/v1", async () => { + const seen: Array<{ url: string; authorization?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization }); + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 30, currentValue: 300, usage: 1000, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 60, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 9.5, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/v1"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 30, + weeklyPercent: 60, + monthlyPercent: 9.5, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); + expect(seen[0]?.authorization).toBe("Bearer zhipu-bigmodel-coding-secret"); + }); + test("Z.AI quota treats an unsuccessful payload as a no-report", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ code: 1001, success: false, msg: "Authentication parameter not received", @@ -928,6 +1034,22 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); + test("Z.AI quota never probes the BigModel pay-as-you-go endpoint", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel", "https://open.bigmodel.cn/api/paas/v4"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { // A valid row (with total) exists; a later valid response omitting the // total is a DELIBERATE contract change — the stale row must be dropped From 10b3dee58590ca76ff89bcb69097fe5b378b646a Mon Sep 17 00:00:00 2001 From: jamespan Date: Tue, 18 Aug 2026 21:22:52 +0800 Subject: [PATCH 2/4] fix(quota): tighten zai window matching and legacy fallback gate Require unit+number to identify the five-hour (3/5) and weekly (6/1) windows so unrelated token rows cannot overwrite them. Fall back to the legacy field-name parser only when the limits array is absent, and point the pay-as-you-go regression at a dispatched provider alias. --- src/providers/quota.ts | 9 ++++++--- tests/provider-quota.test.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index b1490ba235..db0202161d 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -704,11 +704,12 @@ export function parseZaiQuotaLimits(data: Record | null): Provi if (percent === undefined) continue; if (row.type === "TOKENS_LIMIT" || row.type === "CREDIT_LIMIT") { const unit = toFiniteNumber(row.unit); - if (unit === 3) { + const number = toFiniteNumber(row.number); + if (unit === 3 && number === 5) { quota.fiveHourPercent = percent; if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; windows += 1; - } else if (unit === 6) { + } else if (unit === 6 && number === 1) { quota.weeklyPercent = percent; if (resetAt !== undefined) quota.weeklyResetAt = resetAt; windows += 1; @@ -782,7 +783,9 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi const body = asRecord(await readQuotaJson(response)); if (!body || body.success === false) return null; const data = asRecord(body.data) ?? body; - const quota = parseZaiQuotaLimits(data) ?? parseZaiQuotaLegacyFields(data); + const quota = Array.isArray(data?.limits) + ? parseZaiQuotaLimits(data) + : parseZaiQuotaLegacyFields(data); return quota ? report(provider, "zai:quota-limit", quota) : null; } diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 1486568421..cfb6ea3914 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1042,7 +1042,7 @@ describe("fetchProviderQuotaReports", () => { }) as typeof fetch; const result = await fetchProviderQuotaReports( - keyQuotaConfig("zhipu-bigmodel", "https://open.bigmodel.cn/api/paas/v4"), + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/paas/v4"), true, ); @@ -1050,6 +1050,37 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); + test("Z.AI quota ignores token rows whose window length does not match", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 2, percentage: 40, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 2, percentage: 52, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 12.3, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ monthlyPercent: 12.3 }); + expect(result.reports[0]?.quota.fiveHourPercent).toBeUndefined(); + expect(result.reports[0]?.quota.weeklyPercent).toBeUndefined(); + }); + + test("Z.AI quota does not fall back to legacy fields when limits is present but empty", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + data: { limits: [], fiveHourPercent: 40.5, weeklyPercent: 52, monthlyMCPUsage: 12.3 }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toEqual([]); + }); + test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { // A valid row (with total) exists; a later valid response omitting the // total is a DELIBERATE contract change — the stale row must be dropped From d884d2c4ada8218b2bea72e2115dfff3ef3478de Mon Sep 17 00:00:00 2001 From: jamespan Date: Fri, 21 Aug 2026 16:57:42 +0800 Subject: [PATCH 3/4] docs(providers): document the Z.AI GLM Coding Plan quota probe Add the provider/quota documentation note for the new Bearer-authenticated destination and keep the pay-as-you-go tests' fixture key short enough to stay below the privacy scan's bearer-token threshold. --- docs-site/src/content/docs/guides/providers.md | 12 ++++++++++++ tests/provider-quota.test.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d1eec12e1a..0878773ae9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -585,3 +585,15 @@ does not follow redirects. The response's rolling, weekly, and monthly `percent` already-consumed utilization: rolling maps to the 5-hour bar, while weekly and monthly keep their matching bars. OpenCodex does not reconstruct dollar caps from local usage logs, and a provider using a non-canonical `baseUrl` is never sent the key for this probe. + +**Z.AI GLM Coding Plan quota.** The `zai`, `glm`, `glm-cn`, and `zhipu-bigmodel-coding` +presets read `GET /api/monitor/usage/quota/limit` with the configured key as a Bearer token +and do not follow redirects. The probe runs against the region the provider points at: +`api.z.ai` (bare or `/api/coding/paas/v4`) or `open.bigmodel.cn` (bare, +`/api/coding/paas/v4`, or the OpenAI Responses endpoint `/api/v1`). The response's `limits` +rows fill the utilization bars: `TOKENS_LIMIT` / `CREDIT_LIMIT` rows with `unit` 3 / +`number` 5 fill the 5-hour bar and `unit` 6 / `number` 1 the weekly bar, while +`TIME_LIMIT` rows fill the monthly MCP bar. The v2 coding-plan protocol reports the +monthly MCP row; the newer protocol does not, so the monthly bar renders only when that +row is present. A provider using a non-canonical `baseUrl` is never sent the key for this +probe. diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index cfb6ea3914..d55a6ff4f2 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -593,11 +593,11 @@ describe("fetchProviderQuotaReports", () => { expect(rejectedRefresh.reports).toEqual([]); }); - function keyQuotaConfig(name: string, baseUrl: string): OcxConfig { + function keyQuotaConfig(name: string, baseUrl: string, apiKey = `${name}-secret`): OcxConfig { return { defaultProvider: name, providers: { - [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey: `${name}-secret` }, + [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey }, }, } as OcxConfig; } @@ -931,7 +931,7 @@ describe("fetchProviderQuotaReports", () => { }) as typeof fetch; const result = await fetchProviderQuotaReports( - keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4"), + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4", "zai-secret"), true, ); @@ -944,7 +944,7 @@ describe("fetchProviderQuotaReports", () => { }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); - expect(seen[0]?.authorization).toBe("Bearer zhipu-bigmodel-coding-secret"); + expect(seen[0]?.authorization).toBe("Bearer zai-secret"); expect(seen[0]?.redirect).toBe("error"); }); @@ -992,7 +992,7 @@ describe("fetchProviderQuotaReports", () => { }) as typeof fetch; const result = await fetchProviderQuotaReports( - keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/v1"), + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/v1", "zai-secret"), true, ); @@ -1005,7 +1005,7 @@ describe("fetchProviderQuotaReports", () => { }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); - expect(seen[0]?.authorization).toBe("Bearer zhipu-bigmodel-coding-secret"); + expect(seen[0]?.authorization).toBe("Bearer zai-secret"); }); test("Z.AI quota treats an unsuccessful payload as a no-report", async () => { From 4729b37d60f11c087ee32ac63f5f44f821e18d84 Mon Sep 17 00:00:00 2001 From: jamespan Date: Fri, 21 Aug 2026 16:59:21 +0800 Subject: [PATCH 4/4] test(quota): lock real Z.AI v2 and new-protocol responses as fixtures Record sanitized live probe responses: the v2 protocol carries the monthly MCP TIME_LIMIT row (search-prime/web-reader/zread usage details), the newer protocol reports CREDIT_LIMIT token rows only. Proves the TIME_LIMIT row is the 30-day MCP window rather than a fixture we invented. --- tests/provider-quota.test.ts | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index d55a6ff4f2..dffa089c56 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1081,6 +1081,56 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toEqual([]); }); + test("Z.AI quota renders a real v2 coding-plan response (monthly MCP TIME_LIMIT)", async () => { + // Sanitized live response captured from the /api/monitor/usage/quota/limit probe + // (level=max, v2 protocol): the TIME_LIMIT row is the 30-day MCP tool budget + // (search-prime / web-reader / zread), independent of the token windows. + const v2Response = { + limits: [ + { type: "TIME_LIMIT", unit: 5, number: 1, usage: 4000, currentValue: 0, remaining: 4000, percentage: 0, nextResetTime: 1788073095998, + usageDetails: [{ modelCode: "search-prime", usage: 0 }, { modelCode: "web-reader", usage: 0 }, { modelCode: "zread", usage: 0 }] }, + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 100, nextResetTime: 1787056863927 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 20, nextResetTime: 1787641095989 }, + ], + level: "max", + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ success: true, data: v2Response }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 100, + fiveHourResetAt: 1787056863927, + weeklyPercent: 20, + weeklyResetAt: 1787641095989, + monthlyPercent: 0, + monthlyResetAt: 1788073095998, + }); + }); + + test("Z.AI quota renders a real new-protocol response without the monthly MCP row", async () => { + // Sanitized live response (level=pro, newer protocol): CREDIT_LIMIT rows only, + // no TIME_LIMIT row — the monthly MCP bar must not render. + const newProtocolResponse = { + limits: [ + { type: "CREDIT_LIMIT", unit: 3, number: 5, usage: 12000, currentValue: 0, remaining: 12000, percentage: 0 }, + { type: "CREDIT_LIMIT", unit: 6, number: 1, usage: 60000, currentValue: 0, remaining: 60000, percentage: 0, nextResetTime: 1787649214999 }, + ], + level: "pro", + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ success: true, data: newProtocolResponse }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 0, + weeklyPercent: 0, + }); + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); + }); + test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { // A valid row (with total) exists; a later valid response omitting the // total is a DELIBERATE contract change — the stale row must be dropped