diff --git a/src/resources/extensions/sf/provider-quota-cache.js b/src/resources/extensions/sf/provider-quota-cache.js index 008f9ac56..39a0d6af9 100644 --- a/src/resources/extensions/sf/provider-quota-cache.js +++ b/src/resources/extensions/sf/provider-quota-cache.js @@ -233,59 +233,62 @@ async function fetchMinimaxQuota(apiKey) { } /** - * Z.AI — GET https://api.z.ai/api/monitor/usage/quota/limit with Bearer auth. - * Real responses use a `code` / `msg` / `success` / `data` envelope. When - * `success: false` (e.g. user has no active coding plan), we surface the - * vendor's message as the error rather than silently emitting no windows. + * Z.AI — GET https://api.z.ai/api/monitor/usage/quota/limit. + * + * Two non-obvious things about this endpoint (cross-referenced with + * vbgate/opencode-mystatus reference impl): + * + * 1. Auth header is the RAW api key with NO `Bearer ` prefix. Using + * `Authorization: Bearer ` causes the server to treat the call + * as unauthenticated and respond with success:false / "user does not + * have a coding plan" even when the user is actively on the plan. + * + * 2. Real response shape: + * { code: 200, msg, success: true, + * data: { limits: [ + * { type: "TOKENS_LIMIT" | "TIME_LIMIT", + * usage: , currentValue: , + * percentage: <0-100>, nextResetTime?: + * }, ... + * ] } } */ async function fetchZaiQuota(apiKey) { const res = await fetch("https://api.z.ai/api/monitor/usage/quota/limit", { method: "GET", - headers: { Authorization: `Bearer ${apiKey}` }, + headers: { + Authorization: apiKey, + "Content-Type": "application/json", + "User-Agent": "SingularityForge-Quota/1.0", + }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const payload = await res.json(); - // Treat envelope-level failures as fetch errors so they surface properly. - if (payload?.success === false) { + if (payload?.success === false || payload?.code !== 200) { throw new Error( `z.ai: ${payload?.msg ?? "unknown error"} (code ${payload?.code ?? "?"})`, ); } const windows = []; - const buckets = Array.isArray(payload?.data) - ? payload.data - : Array.isArray(payload?.limits) - ? payload.limits - : Array.isArray(payload) - ? payload - : []; + const buckets = Array.isArray(payload?.data?.limits) ? payload.data.limits : []; for (const bucket of buckets) { if (!bucket || typeof bucket !== "object") continue; - const limit = - typeof bucket.limit === "number" - ? bucket.limit - : typeof bucket.total === "number" - ? bucket.total - : undefined; - let used = - typeof bucket.used === "number" - ? bucket.used - : typeof bucket.consumed === "number" - ? bucket.consumed - : undefined; - if (used === undefined && typeof bucket.remaining === "number" && typeof limit === "number") { - used = limit - bucket.remaining; - } + const limit = toNum(bucket.usage); + const used = toNum(bucket.currentValue); if (typeof used !== "number" || typeof limit !== "number") continue; + const label = + bucket.type === "TOKENS_LIMIT" + ? "5h tokens" + : bucket.type === "TIME_LIMIT" + ? "MCP monthly" + : String(bucket.type ?? "quota"); windows.push({ - label: String(bucket.name ?? bucket.type ?? "quota"), + label, used, limit, usedFraction: clampFraction(used, limit), - resetHint: bucket.reset_time - ? String(bucket.reset_time) - : bucket.resetAt - ? String(bucket.resetAt) + resetHint: + typeof bucket.nextResetTime === "number" + ? new Date(bucket.nextResetTime).toISOString() : undefined, }); } diff --git a/src/resources/extensions/sf/tests/provider-quota-cache.test.mjs b/src/resources/extensions/sf/tests/provider-quota-cache.test.mjs index 337b86854..090fe4967 100644 --- a/src/resources/extensions/sf/tests/provider-quota-cache.test.mjs +++ b/src/resources/extensions/sf/tests/provider-quota-cache.test.mjs @@ -224,19 +224,43 @@ describe("runProviderQuotaRefreshIfStale — minimax", () => { // ─── zai ───────────────────────────────────────────────────────────────────── describe("runProviderQuotaRefreshIfStale — zai", () => { - test("hits /api/monitor/usage/quota/limit and parses bucket array", async () => { + test("uses raw key auth (no Bearer prefix) and parses {type, usage, currentValue}", async () => { const home = tempSfHome(); - stubFetch({ + const calls = stubFetch({ "https://api.z.ai/api/monitor/usage/quota/limit": { - data: [ - { name: "5h tokens", limit: 5000, used: 1500 }, - { name: "MCP monthly", limit: 100, used: 70 }, - ], + code: 200, + msg: "ok", + success: true, + data: { + limits: [ + { + type: "TOKENS_LIMIT", + usage: 5000, + currentValue: 1500, + percentage: 30, + nextResetTime: 1779004800000, + }, + { + type: "TIME_LIMIT", + usage: 100, + currentValue: 70, + percentage: 70, + }, + ], + }, }, }); await runProviderQuotaRefreshIfStale(home, makeAuth({ zai: "test-zai" })); + const zaiCall = calls.find((c) => c.url.includes("z.ai")); + assert.ok(zaiCall); + assert.equal( + zaiCall.headers.Authorization, + "test-zai", + "zai auth header must be the raw key, NO Bearer prefix", + ); + const entry = getProviderQuotaState("zai"); assert.equal(entry.ok, true); assert.equal(entry.windows.length, 2);