From 27d1b5fcbc5c5b409168eb93d31ee210ce35b501 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:42:46 +0900 Subject: [PATCH] fix(providers): bound quota response parsing --- src/providers/quota.ts | 85 ++++++++++++---- tests/provider-quota.test.ts | 181 +++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 20 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 5164991c0..8278df273 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -21,6 +21,7 @@ import { sweepExpiredOnWrite, type GenerationContext, } from "../lib/state-store-sweeper"; +import { readBoundedResponseBody } from "../lib/bounded-body"; import { aggregateCodexPoolCapacity, CODEX_CAPACITY_MAX_QUOTA_AGE_MS, @@ -33,6 +34,8 @@ const ACCOUNT_TOKEN_SKEW_MS = 60_000; const CACHE_TTL_MS = 5 * 60_000; const REQUEST_TIMEOUT_MS = 8_000; +/** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */ +export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024; const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; const A6API_BASE_URL = "https://api.a6api.com"; @@ -260,6 +263,43 @@ function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } +const QUOTA_JSON_READ_FAILURE = Symbol("quota-json-read-failure"); + +async function readQuotaJson( + response: Response, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > QUOTA_RESPONSE_MAX_BYTES) { + try { + void response.body?.cancel( + new DOMException("Provider quota response is too large", "QuotaExceededError"), + ).catch(() => undefined); + } catch { + // Best-effort cancellation only. + } + return QUOTA_JSON_READ_FAILURE; + } + + try { + const bounded = await readBoundedResponseBody(response, { + maxBytes: QUOTA_RESPONSE_MAX_BYTES, + totalTimeoutMs: timeoutMs, + inactivityTimeoutMs: timeoutMs, + }); + if (bounded.oversized || bounded.truncated || !bounded.displaySafe) return QUOTA_JSON_READ_FAILURE; + return JSON.parse(bounded.text) as unknown; + } catch { + return QUOTA_JSON_READ_FAILURE; + } +} + +/** Test-only access to the quota reader's deadline and cancellation contract. */ +export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise { + const result = await readQuotaJson(response, timeoutMs); + return result === QUOTA_JSON_READ_FAILURE ? null : result; +} + function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean { return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider); } @@ -354,8 +394,13 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro ? TERMINAL_QUOTA_FAILURE : null; } - const subscription = a6apiPayload(await subscriptionResponse.json().catch(() => null)); - const token = a6apiPayload(await tokenResponse.json().catch(() => null)); + const [subscriptionBody, tokenBody] = await Promise.all([ + readQuotaJson(subscriptionResponse), + readQuotaJson(tokenResponse), + ]); + if (subscriptionBody === QUOTA_JSON_READ_FAILURE || tokenBody === QUOTA_JSON_READ_FAILURE) return null; + const subscription = a6apiPayload(subscriptionBody); + const token = a6apiPayload(tokenBody); const unlimited = token?.unlimited_quota === true || token?.unlimited_quota === 1 || token?.unlimited_quota === "true"; @@ -433,7 +478,7 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; if (!data) return null; const limit = toFiniteNumber(data.limit); @@ -480,7 +525,7 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); // The payload nests balances under `balance_infos` rows keyed by currency; // prefer a USD row, then CNY, then the first row that parses. const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null; @@ -528,7 +573,7 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; const limits = Array.isArray(data?.limits) ? data.limits : null; if (!limits) return null; @@ -576,7 +621,7 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + 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 @@ -632,7 +677,7 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); if (!body || body.success === false) return null; const data = asRecord(body.data) ?? body; const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); @@ -675,7 +720,7 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; if (!data) return null; const available = toFiniteNumber(data.available_balance); @@ -711,7 +756,7 @@ async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Pr ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; if (!data) return null; const diemBalance = toFiniteNumber(data.balance); @@ -754,7 +799,7 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; const quota: ProviderQuota = { updatedAt: Date.now() }; let windows = 0; @@ -802,7 +847,7 @@ async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; if (!data) return null; const stripeBalance = toFiniteNumber(data.stripe_balance); @@ -844,7 +889,7 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig) ? TERMINAL_QUOTA_FAILURE : null; } - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const data = asRecord(body?.data) ?? body; const quota: ProviderQuota = { updatedAt: Date.now() }; let windows = 0; @@ -1028,7 +1073,7 @@ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promi signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) return null; - const parsed = parseXaiCreditsResponse(await response.json().catch(() => null)); + const parsed = parseXaiCreditsResponse(await readQuotaJson(response)); if (!parsed) return null; return { weeklyPercent: parsed.percent, @@ -1062,7 +1107,7 @@ async function fetchXaiQuota(provider: string): Promise null)); + const body = asRecord(await readQuotaJson(response)); const config = asRecord(body?.config); if (!config) return null; const limitCents = centsValue(config.monthlyLimit); @@ -1108,7 +1153,7 @@ async function fetchAnthropicUsageQuota(accessToken: string): Promise null)); + const body = asRecord(await readQuotaJson(response)); if (!body) return null; const fiveHour = parseClaudeBucket(body.five_hour); const sevenDay = parseClaudeBucket(body.seven_day); @@ -1521,7 +1566,7 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) return null; - const quota = parseKimiQuotaPayload(await response.json().catch(() => null)); + const quota = parseKimiQuotaPayload(await readQuotaJson(response)); return quota ? report(provider, "kimi:usages", quota) : null; } @@ -1554,7 +1599,7 @@ async function fetchCursorQuota(provider: string): Promise null)); + const body = asRecord(await readQuotaJson(periodRes)); const planUsage = asRecord(body?.planUsage); if (planUsage) { const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd); @@ -1616,7 +1661,7 @@ async function fetchCursorQuota(provider: string): Promise null)); + const body = asRecord(await readQuotaJson(summaryRes)); const individual = asRecord(body?.individualUsage); const plan = asRecord(individual?.plan); if (plan) { @@ -1645,7 +1690,7 @@ async function fetchCursorQuota(provider: string): Promise null)); + const body = asRecord(await readQuotaJson(response)); if (!body) return null; // Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit. @@ -1759,7 +1804,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) return null; - const body = asRecord(await response.json().catch(() => null)); + const body = asRecord(await readQuotaJson(response)); const models = asRecord(body?.models); if (!models) return null; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index d4f7d7d15..4fa7fe826 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -13,6 +13,8 @@ import { clearProviderQuotaCache, fetchProviderQuotaReports, parseXaiCreditsResponse, + QUOTA_RESPONSE_MAX_BYTES, + readProviderQuotaJsonForTests, setProviderQuotaBeforePublishForTests, } from "../src/providers/quota"; import type { OcxConfig } from "../src/types"; @@ -97,6 +99,21 @@ afterEach(() => { }); describe("fetchProviderQuotaReports", () => { + test("provider quota probes have no direct Response.json calls", () => { + const source = readFileSync(join(import.meta.dir, "../src/providers/quota.ts"), "utf8"); + expect(source).not.toMatch(/\.\s*json\s*\(/); + }); + + test("quota JSON reading cancels a body that stalls before its first byte", async () => { + let cancelCalls = 0; + const response = new Response(new ReadableStream({ + cancel() { cancelCalls += 1; }, + })); + + expect(await readProviderQuotaJsonForTests(response, 10)).toBeNull(); + expect(cancelCalls).toBe(1); + }); + test("returns active provider quota rows without leaking credentials or raw upstream payloads", async () => { await saveCredential("xai", { access: "xai-access-secret", refresh: "xai-refresh-secret", expires: Date.now() + 3600_000 }); await saveCredential("anthropic", { access: "claude-access-secret", refresh: "claude-refresh-secret", expires: Date.now() + 3600_000 }); @@ -501,6 +518,27 @@ describe("fetchProviderQuotaReports", () => { expect(transientFailure.reports).toEqual(valid.reports); }); + test("A6API quota preserves a last-good row after an oversized successful response", async () => { + let oversized = false; + let cancelCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const subscription = String(input).includes("subscription"); + if (oversized && subscription) { + return declaredOversizeQuotaResponse(() => { cancelCalls += 1; }); + } + return Response.json(subscription + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 10_000_000, total_used: 2_000_000, total_available: 8_000_000 } }); + }) as typeof fetch; + + const valid = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + oversized = true; + const preserved = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + + expect(preserved.reports).toEqual(valid.reports); + expect(cancelCalls).toBe(1); + }); + test("A6API quota treats a throttled 429 refresh as transient and keeps the last-good row", async () => { let throttled = false; globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -1757,6 +1795,149 @@ describe("fetchProviderQuotaReports", () => { } as OcxConfig; } + function declaredOversizeQuotaResponse(onCancel: () => void): Response { + return new Response(new ReadableStream({ + cancel() { onCancel(); }, + }), { + status: 200, + headers: { "content-length": String(QUOTA_RESPONSE_MAX_BYTES + 1) }, + }); + } + + function chunkedOversizeQuotaResponse(onCancel: () => void, json = "{}"): Response { + const encoded = new TextEncoder().encode(json); + if (encoded.byteLength > QUOTA_RESPONSE_MAX_BYTES) throw new Error("test JSON exceeds quota response cap"); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.enqueue(new Uint8Array(QUOTA_RESPONSE_MAX_BYTES - encoded.byteLength).fill(0x20)); + controller.enqueue(new Uint8Array([0x20])); + }, + cancel() { onCancel(); }, + }), { status: 200 }); + } + + test("cursor bounds a declared-oversize period response before falling back to summary", async () => { + await saveCredential("cursor", { access: "cursor-access-secret", refresh: "cursor-refresh-secret", expires: Date.now() + 3600_000 }); + let cancelCalls = 0; + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + if (url.endsWith("GetCurrentPeriodUsage")) { + return declaredOversizeQuotaResponse(() => { cancelCalls += 1; }); + } + if (url.endsWith("/api/usage/summary")) { + return Response.json({ individualUsage: { plan: { totalPercentUsed: 42 } } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(cursorOnlyConfig(), true); + expect(result.reports[0]?.source).toBe("cursor:usage-summary"); + expect(result.reports[0]?.quota.monthlyPercent).toBe(42); + expect(seen.map(url => url.split("/").at(-1))).toEqual([ + "GetCurrentPeriodUsage", + "summary", + ]); + expect(cancelCalls).toBe(1); + }); + + test("cursor bounds a chunked summary response after malformed period JSON and falls back to auth usage", async () => { + await saveCredential("cursor", { access: "cursor-access-secret", refresh: "cursor-refresh-secret", expires: Date.now() + 3600_000 }); + let cancelCalls = 0; + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + if (url.endsWith("GetCurrentPeriodUsage")) return new Response("{", { status: 200 }); + if (url.endsWith("/api/usage/summary")) { + return chunkedOversizeQuotaResponse( + () => { cancelCalls += 1; }, + JSON.stringify({ individualUsage: { plan: { totalPercentUsed: 91 } } }), + ); + } + if (url.endsWith("/auth/usage")) { + return Response.json({ "gpt-4": { numRequests: 1, maxRequestUsage: 4 } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(cursorOnlyConfig(), true); + expect(result.reports[0]?.source).toBe("cursor:auth-usage"); + expect(result.reports[0]?.quota.monthlyPercent).toBe(25); + expect(seen.map(url => url.split("/").at(-1))).toEqual([ + "GetCurrentPeriodUsage", + "summary", + "usage", + ]); + expect(cancelCalls).toBe(1); + }); + + test("cursor treats malformed under-cap period and summary JSON as fallback conditions", async () => { + await saveCredential("cursor", { access: "cursor-access-secret", refresh: "cursor-refresh-secret", expires: Date.now() + 3600_000 }); + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + if (url.endsWith("GetCurrentPeriodUsage") || url.endsWith("/api/usage/summary")) { + return new Response("{", { status: 200 }); + } + if (url.endsWith("/auth/usage")) { + return Response.json({ "gpt-4": { numRequests: 3, maxRequestUsage: 10 } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(cursorOnlyConfig(), true); + expect(result.reports[0]?.source).toBe("cursor:auth-usage"); + expect(result.reports[0]?.quota.monthlyPercent).toBe(30); + expect(seen.map(url => url.split("/").at(-1))).toEqual([ + "GetCurrentPeriodUsage", + "summary", + "usage", + ]); + }); + + test("cursor preserves its last-good row when the final quota response exceeds the JSON budget", async () => { + await saveCredential("cursor", { access: "cursor-access-secret", refresh: "cursor-refresh-secret", expires: Date.now() + 3600_000 }); + let mode: "good" | "oversize" = "good"; + let cancelCalls = 0; + let fetchCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls += 1; + const url = String(input); + if (mode === "good" && url.endsWith("GetCurrentPeriodUsage")) { + return Response.json({ planUsage: { totalPercentUsed: 55 } }); + } + if (url.endsWith("GetCurrentPeriodUsage") || url.endsWith("/api/usage/summary")) { + return Response.json({}); + } + if (url.endsWith("/auth/usage")) { + return chunkedOversizeQuotaResponse( + () => { cancelCalls += 1; }, + JSON.stringify({ "gpt-4": { numRequests: 91, maxRequestUsage: 100 } }), + ); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const good = await fetchProviderQuotaReports(cursorOnlyConfig(), true); + const goodUpdatedAt = good.reports[0]?.updatedAt; + const goodQuotaUpdatedAt = good.reports[0]?.quota.updatedAt; + mode = "oversize"; + const preserved = await fetchProviderQuotaReports(cursorOnlyConfig(), true); + const callsAfterRefresh = fetchCalls; + const cached = await fetchProviderQuotaReports(cursorOnlyConfig(), false); + + expect(preserved.reports[0]?.quota.monthlyPercent).toBe(55); + expect(preserved.reports[0]?.updatedAt).toBe(goodUpdatedAt); + expect(preserved.reports[0]?.quota.updatedAt).toBe(goodQuotaUpdatedAt); + expect(cached.reports[0]?.quota.monthlyPercent).toBe(55); + expect(fetchCalls).toBe(callsAfterRefresh); + expect(cancelCalls).toBe(1); + }); + test("cursor falls back to usage-summary when period-usage fails", async () => { await saveCredential("cursor", { access: "cursor-access-secret", refresh: "cursor-refresh-secret", expires: Date.now() + 3600_000 }); globalThis.fetch = (async (input: RequestInfo | URL) => {