diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f8d13cbe87..3a3276c602 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -655,13 +655,22 @@ their matching bars. OpenCodex does not reconstruct dollar caps from local usage 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: +presets read `GET /api/monitor/usage/quota/limit` 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. +`/api/coding/paas/v4`, or the OpenAI Responses endpoint `/api/v1`). + +Authentication differs by region: `api.z.ai` takes the key as a Bearer token, while +`open.bigmodel.cn` expects the key directly in `Authorization` with no scheme prefix and +rejects a Bearer header. 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. + +`TIME_LIMIT` rows are **not** model quota and are ignored. They are the shared monthly +MCP call allowance for Web Search, Web Reader, and Zread, so treating them as a model +window would let a spent web-search budget read as exhausted model capacity in +quota-aware account ranking. A plan that reports only `TIME_LIMIT` rows therefore shows +no quota bars rather than a fabricated one, and windows the plan does not report stay +absent instead of rendering as 0%. + +A provider using a non-canonical `baseUrl` is never sent the key for this probe. diff --git a/src/providers/quota.ts b/src/providers/quota.ts index e27bb29a31..3335679e63 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -104,7 +104,23 @@ export function setProviderQuotaBeforePublishForTests( providerQuotaBeforePublishForTests = hook; } const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure"); -type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE; +/** + * The probe succeeded and the upstream authoritatively reported NO model-quota windows. + * + * Distinct from `null`, which means "this probe told us nothing" and deliberately preserves + * the last-good row for up to 30 minutes. Collapsing the two would let a stale report outlive + * the authoritative answer that replaced it: a GLM plan whose payload carries only MCP + * `TIME_LIMIT` rows has no model windows, and the dashboard and quota-aware routing must stop + * showing the previous token windows rather than keep them for another half hour. + * + * Suppression is shared with `TERMINAL_QUOTA_FAILURE`; only the reason differs. + */ +const AUTHORITATIVE_EMPTY_QUOTA = Symbol("authoritative-empty-quota"); +type ProviderQuotaProbeResult = + | ProviderQuotaReport + | null + | typeof TERMINAL_QUOTA_FAILURE + | typeof AUTHORITATIVE_EMPTY_QUOTA; export interface ProviderQuotaReport { provider: string; @@ -636,10 +652,19 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro * 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 + * window). Every row's `percentage` is the consumed share (falling * back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms) * the window reset. + * + * `TIME_LIMIT` rows are deliberately ignored (issue #1168). They are the shared + * monthly MCP *call* allowance for Web Search / Web Reader / Zread — not a + * model-token budget — and `ProviderQuota.monthlyPercent` is consumed as a + * model-capacity signal: `headroomOf()` in `src/oauth/account-quota-rank.ts` + * takes the MAX across every window, so a user who spent their MCP search + * allowance would be ranked as having no model capacity left, and the dashboard + * would draw a full monthly bar for a plan whose model tokens are untouched. + * A payload carrying only `TIME_LIMIT` rows therefore reports no quota at all, + * which is the honest answer rather than a fabricated one. */ export function parseZaiQuotaLimits(data: Record | null): ProviderQuota | null { const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null; @@ -649,6 +674,9 @@ export function parseZaiQuotaLimits(data: Record | null): Provi for (const raw of limits) { const row = asRecord(raw); if (!row) continue; + // Gate on row type before deriving a percentage: an MCP row must not even + // contribute a parsed value to a model-quota report. + if (row.type !== "TOKENS_LIMIT" && row.type !== "CREDIT_LIMIT") continue; const resetAt = normalizeResetAt(row.nextResetTime); let percent = normalizePercent(row.percentage); if (percent === undefined) { @@ -659,21 +687,15 @@ 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); - 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 && number === 1) { - 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; + const unit = toFiniteNumber(row.unit); + 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 && number === 1) { + quota.weeklyPercent = percent; + if (resetAt !== undefined) quota.weeklyResetAt = resetAt; windows += 1; } } @@ -715,9 +737,16 @@ function parseZaiQuotaLegacyFields(data: Record | null): Provid /** * 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 + * points at (api.z.ai or open.bigmodel.cn). The `limits` array shape is * preferred; older field-name payloads fall back to the legacy parser. + * + * Authentication differs by host (issue #1168). `api.z.ai` takes the API key as + * a Bearer token per Z.AI's API reference; `open.bigmodel.cn` expects the key + * directly in `Authorization` with no scheme prefix and answers a Bearer header + * with an auth error, which is why BigModel Coding Plan quota never rendered. + * The host is already canonicalized by `isCanonicalZaiBaseUrl` above and + * `redirect: "error"` stays set, so the bare key cannot travel to a lookalike + * host or follow a redirect off-origin. */ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; @@ -727,8 +756,9 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` ? ZAI_BASE_URL : ZAI_CN_BASE_URL; + const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`; const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + headers: { Accept: "application/json", Authorization: authorization }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); @@ -740,10 +770,16 @@ 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 = Array.isArray(data?.limits) - ? parseZaiQuotaLimits(data) - : parseZaiQuotaLegacyFields(data); - return quota ? report(provider, "zai:quota-limit", quota) : null; + if (Array.isArray(data?.limits)) { + const quota = parseZaiQuotaLimits(data); + // A well-formed `limits[]` we fully understood is authoritative even when it yields no + // model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row. + // Returning `null` here would preserve the previous token windows for up to 30 minutes + // and keep quota-aware routing acting on a report the provider has already superseded. + return quota ? report(provider, "zai:quota-limit", quota) : AUTHORITATIVE_EMPTY_QUOTA; + } + const legacy = parseZaiQuotaLegacyFields(data); + return legacy ? report(provider, "zai:quota-limit", legacy) : null; } /** @@ -2308,9 +2344,18 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot) )), ); - const fresh = probeResults.filter((item): item is ProviderQuotaReport => item !== null && item !== TERMINAL_QUOTA_FAILURE); + const fresh = probeResults.filter((item): item is ProviderQuotaReport => ( + item !== null && item !== TERMINAL_QUOTA_FAILURE && item !== AUTHORITATIVE_EMPTY_QUOTA + )); + // Both sentinels suppress the previous row. A terminal failure means the response was + // invalid; an authoritative empty means the response was valid and said there are no + // model windows. Either way the old row is no longer true, which is what separates them + // from `null` (told us nothing — keep the last-good row). const terminalFailures = new Set( - Object.keys(config.providers).filter((_, index) => probeResults[index] === TERMINAL_QUOTA_FAILURE), + Object.keys(config.providers).filter((_, index) => ( + probeResults[index] === TERMINAL_QUOTA_FAILURE + || probeResults[index] === AUTHORITATIVE_EMPTY_QUOTA + )), ); await providerQuotaBeforePublishForTests?.(); let commitKey: string | null = null; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 5eef964306..ad3a6edbdd 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -979,9 +979,11 @@ describe("fetchProviderQuotaReports", () => { fiveHourResetAt: 1789000000000, weeklyPercent: 52, weeklyResetAt: 1789600000000, - monthlyPercent: 12.3, - monthlyResetAt: 1789000000000, }); + // The TIME_LIMIT row is the MCP call allowance, not a model-token window, so it + // must not surface as monthly model quota (issue #1168). + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); + expect(result.reports[0]?.quota.monthlyResetAt).toBeUndefined(); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); expect(seen[0]?.authorization).toBe("Bearer zai-secret"); @@ -1017,11 +1019,12 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports[0]?.quota).toMatchObject({ fiveHourPercent: 20, weeklyPercent: 52, - monthlyPercent: 7.5, }); + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); - expect(seen[0]?.authorization).toBe("Bearer zai-secret"); + // BigModel takes the raw key; a Bearer prefix is rejected upstream (issue #1168). + expect(seen[0]?.authorization).toBe("zai-secret"); expect(seen[0]?.redirect).toBe("error"); }); @@ -1078,11 +1081,11 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports[0]?.quota).toMatchObject({ fiveHourPercent: 30, weeklyPercent: 60, - monthlyPercent: 9.5, }); + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); - expect(seen[0]?.authorization).toBe("Bearer zai-secret"); + expect(seen[0]?.authorization).toBe("zai-secret"); }); test("Z.AI quota treats an unsuccessful payload as a no-report", async () => { @@ -1127,7 +1130,7 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("Z.AI quota ignores token rows whose window length does not match", async () => { + test("Z.AI quota reports nothing when only unmatched token rows and an MCP row remain", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ success: true, data: { @@ -1141,10 +1144,10 @@ describe("fetchProviderQuotaReports", () => { 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(); + // Neither token row matches a known window length and the TIME_LIMIT row is the MCP + // allowance, so there is no model-quota evidence at all. Reporting no quota is the + // honest outcome; previously the MCP row alone produced a monthly model bar. + expect(result.reports).toEqual([]); }); test("Z.AI quota does not fall back to legacy fields when limits is present but empty", async () => { @@ -1158,7 +1161,7 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toEqual([]); }); - test("Z.AI quota renders a real v2 coding-plan response (monthly MCP TIME_LIMIT)", async () => { + test("Z.AI quota ignores the monthly MCP TIME_LIMIT row in a real v2 response", 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. @@ -1181,9 +1184,99 @@ describe("fetchProviderQuotaReports", () => { fiveHourResetAt: 1787056863927, weeklyPercent: 20, weeklyResetAt: 1787641095989, - monthlyPercent: 0, - monthlyResetAt: 1788073095998, }); + // The MCP allowance is untouched here (percentage 0) while the five-hour model + // window is fully consumed. Reporting the MCP row as monthly model quota is what + // issue #1168 removes: headroomOf() takes the MAX across windows, so an exhausted + // MCP budget would otherwise be read as exhausted model capacity. + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); + expect(result.reports[0]?.quota.monthlyResetAt).toBeUndefined(); + }); + + test("a later MCP-only refresh clears the cached Z.AI model windows", async () => { + // Sequential forced refreshes. The first returns real token windows, so a last-good row + // exists; the second is a SUCCESSFUL response that authoritatively reports no model + // windows. Treating that as a transient failure would preserve the stale token windows + // for up to 30 minutes, so the dashboard and quota-aware routing would keep acting on a + // report the provider has already superseded. + let call = 0; + globalThis.fetch = (async () => { + call += 1; + const limits = call === 1 + ? [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 40, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 52, nextResetTime: 1789600000000 }, + ] + : [ + { type: "TIME_LIMIT", unit: 5, number: 1, usage: 100, currentValue: 20, remaining: 80, percentage: 20, nextResetTime: 1788921262994 }, + ]; + return new Response(JSON.stringify({ success: true, data: { limits, level: "lite" } }), { status: 200 }); + }) as typeof fetch; + + const cfg = keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4", "zai-secret"); + + const first = await fetchProviderQuotaReports(cfg, true); + expect(first.reports).toHaveLength(1); + expect(first.reports[0]?.quota).toMatchObject({ fiveHourPercent: 40, weeklyPercent: 52 }); + + const second = await fetchProviderQuotaReports(cfg, true); + expect(second.reports).toEqual([]); + }); + + test("Z.AI quota reports no model window when the payload carries only an MCP TIME_LIMIT row", async () => { + // A BigModel V1 Lite plan whose MCP allowance is fully spent but whose model tokens + // are untouched. Before issue #1168 this produced monthlyPercent: 100, and because + // headroomOf() in src/oauth/account-quota-rank.ts takes the MAX across every window, + // the account ranked as having ZERO model headroom — a healthy account demoted, or + // skipped, over a spent web-search budget. + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TIME_LIMIT", unit: 5, number: 1, usage: 100, currentValue: 100, remaining: 0, percentage: 100, nextResetTime: 1788921262994, + usageDetails: [{ modelCode: "search-prime", usage: 60 }, { modelCode: "web-reader", usage: 40 }, { modelCode: "zread", usage: 0 }] }, + ], + level: "lite", + }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4", "zai-secret"), + true, + ); + + expect(result.reports).toEqual([]); + }); + + test("Z.AI quota omits absent windows rather than reporting them as zero", async () => { + // Real V1 Lite shape from issue #1168: one five-hour token window plus the MCP row. + // Weekly and monthly must be ABSENT, not 0 — a synthesized 0 would draw a + // full-capacity weekly bar for a plan that never reported one. + globalThis.fetch = (async () => new Response(JSON.stringify({ + code: 200, + msg: "操作成功", + success: true, + data: { + limits: [ + { type: "TIME_LIMIT", unit: 5, number: 1, usage: 100, currentValue: 0, remaining: 100, percentage: 0, nextResetTime: 1788921262994, + usageDetails: [{ modelCode: "search-prime", usage: 0 }, { modelCode: "web-reader", usage: 0 }, { modelCode: "zread", usage: 0 }] }, + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 1, nextResetTime: 1786626122911 }, + ], + level: "lite", + }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4", "zai-secret"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ fiveHourPercent: 1, fiveHourResetAt: 1786626122911 }); + expect(result.reports[0]?.quota.weeklyPercent).toBeUndefined(); + expect(result.reports[0]?.quota.weeklyResetAt).toBeUndefined(); + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); + expect(result.reports[0]?.quota.monthlyResetAt).toBeUndefined(); }); test("Z.AI quota renders a real new-protocol response without the monthly MCP row", async () => {