Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
99 changes: 72 additions & 27 deletions src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown> | null): ProviderQuota | null {
const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null;
Expand All @@ -649,6 +674,9 @@ export function parseZaiQuotaLimits(data: Record<string, unknown> | 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;
Comment thread
Ingwannu marked this conversation as resolved.
const resetAt = normalizeResetAt(row.nextResetTime);
let percent = normalizePercent(row.percentage);
if (percent === undefined) {
Expand All @@ -659,21 +687,15 @@ export function parseZaiQuotaLimits(data: Record<string, unknown> | 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;
}
}
Expand Down Expand Up @@ -715,9 +737,16 @@ function parseZaiQuotaLegacyFields(data: Record<string, unknown> | 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<ProviderQuotaProbeResult> {
if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null;
Expand All @@ -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),
});
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down
121 changes: 107 additions & 14 deletions tests/provider-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: {
Expand All @@ -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 () => {
Expand All @@ -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.
Expand All @@ -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 () => {
Expand Down
Loading