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/src/providers/quota.ts b/src/providers/quota.ts index 24b06ef7a9..db0202161d 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,67 @@ 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); + 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; + 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 +753,40 @@ 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 = Array.isArray(data?.limits) + ? parseZaiQuotaLimits(data) + : parseZaiQuotaLegacyFields(data); + return quota ? report(provider, "zai:quota-limit", quota) : null; } /** @@ -2106,7 +2178,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..dffa089c56 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; } @@ -881,6 +881,79 @@ describe("fetchProviderQuotaReports", () => { const url = String(input); const headers = init?.headers as Record | undefined; seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); + return new Response(JSON.stringify({ + success: true, + 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"), 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"); + expect(seen[0]?.authorization).toBe("Bearer zai-secret"); + 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", "zai-secret"), + 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 zai-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 }, @@ -898,8 +971,41 @@ describe("fetchProviderQuotaReports", () => { }); 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", "zai-secret"), + 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 zai-secret"); - expect(seen[0]?.redirect).toBe("error"); }); test("Z.AI quota treats an unsuccessful payload as a no-report", async () => { @@ -928,6 +1034,103 @@ 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-coding", "https://open.bigmodel.cn/api/paas/v4"), + true, + ); + + expect(result.reports).toEqual([]); + 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("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