mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
@@ -49,6 +49,13 @@ export interface CodexDualWindowQuota extends QuotaInfo {
|
||||
limitReached: boolean;
|
||||
/** All known Codex quota windows, including Spark when the upstream exposes it. */
|
||||
allWindows?: Record<string, { percentUsed: number; resetAt: string | null }>;
|
||||
/**
|
||||
* Banked reset credits available on the account (display-only, issue #5199).
|
||||
* Eligibility-gated: absent for most accounts. Never throws when missing.
|
||||
*/
|
||||
bankedResetCredits?: number;
|
||||
/** Which window is currently reported as blocking, when the upstream exposes it. */
|
||||
rateLimitReachedType?: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -295,6 +302,26 @@ function parseCodexWindow(
|
||||
return { percentUsed, resetAt: parseWindowReset(window) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex "banked reset credits" — same eligibility-gated field parsed in
|
||||
* codexUsageQuotas.ts (kept in sync manually; the two parsers read the same
|
||||
* /wham/usage payload independently). DISPLAY ONLY, never throws.
|
||||
*/
|
||||
function parseBankedResetCredits(data: Record<string, unknown>): number | undefined {
|
||||
const resetCredits = toRecord(data["rate_limit_reset_credits"] ?? data["rateLimitResetCredits"]);
|
||||
const availableCount = resetCredits["available_count"] ?? resetCredits["availableCount"];
|
||||
const count = toNumber(availableCount, NaN);
|
||||
return Number.isFinite(count) ? count : undefined;
|
||||
}
|
||||
|
||||
function parseRateLimitReachedType(data: Record<string, unknown>): string | undefined {
|
||||
const reachedType = data["rate_limit_reached_type"] ?? data["rateLimitReachedType"];
|
||||
if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim();
|
||||
const reachedTypeObj = toRecord(reachedType);
|
||||
const type = reachedTypeObj["type"];
|
||||
return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined;
|
||||
}
|
||||
|
||||
function findSparkRateLimit(data: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const additional = data["additional_rate_limits"] ?? data["additionalRateLimits"];
|
||||
if (!Array.isArray(additional)) return null;
|
||||
@@ -403,6 +430,9 @@ function parseCodexUsageResponse(
|
||||
secondary: CODEX_WINDOW_WEEKLY,
|
||||
});
|
||||
|
||||
const bankedResetCredits = parseBankedResetCredits(obj);
|
||||
const rateLimitReachedType = parseRateLimitReachedType(obj);
|
||||
|
||||
return {
|
||||
used: Math.round(worstPercentUsed * 100),
|
||||
total: 100,
|
||||
@@ -419,6 +449,9 @@ function parseCodexUsageResponse(
|
||||
window5h,
|
||||
window7d,
|
||||
limitReached,
|
||||
// Banked reset credits (display-only, eligibility-gated — issue #5199).
|
||||
...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}),
|
||||
...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -160,13 +160,43 @@ function findCodexReviewRateLimit(data: JsonRecord): JsonRecord {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex "banked reset credits" — an eligibility-gated field some ChatGPT plans
|
||||
* expose on the /wham/usage payload: a count of extra rate-limit resets the
|
||||
* account has banked (available_count), plus an optional descriptor of which
|
||||
* window is currently blocking (rate_limit_reached_type). DISPLAY ONLY — this
|
||||
* reads the field defensively (many accounts will not have it) and never
|
||||
* throws; redemption is an unofficial mutating endpoint and out of scope
|
||||
* (issue #5199).
|
||||
*/
|
||||
function parseBankedResetCredits(data: JsonRecord): number | undefined {
|
||||
const resetCredits = toRecord(getFieldValue(data, "rate_limit_reset_credits", "rateLimitResetCredits"));
|
||||
const availableCount = getFieldValue(resetCredits, "available_count", "availableCount");
|
||||
const count = toNumber(availableCount, NaN);
|
||||
return Number.isFinite(count) ? count : undefined;
|
||||
}
|
||||
|
||||
function parseRateLimitReachedType(data: JsonRecord): string | undefined {
|
||||
const reachedType = getFieldValue(data, "rate_limit_reached_type", "rateLimitReachedType");
|
||||
if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim();
|
||||
const reachedTypeObj = toRecord(reachedType);
|
||||
const type = getFieldValue(reachedTypeObj, "type");
|
||||
return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined;
|
||||
}
|
||||
|
||||
export function buildCodexUsageQuotas(dataValue: unknown): {
|
||||
rateLimit: JsonRecord;
|
||||
quotas: Record<string, CodexUsageQuota>;
|
||||
/** Banked reset credits available on the account (undefined when absent/not eligible). */
|
||||
bankedResetCredits?: number;
|
||||
/** Which window is currently reported as blocking, when the upstream exposes it. */
|
||||
rateLimitReachedType?: string;
|
||||
} {
|
||||
const data = toRecord(dataValue);
|
||||
const rateLimit = toRecord(getFieldValue(data, "rate_limit", "rateLimit"));
|
||||
const quotas: Record<string, CodexUsageQuota> = {};
|
||||
const bankedResetCredits = parseBankedResetCredits(data);
|
||||
const rateLimitReachedType = parseRateLimitReachedType(data);
|
||||
|
||||
const primaryWindow = toRecord(getFieldValue(rateLimit, "primary_window", "primaryWindow"));
|
||||
if (Object.keys(primaryWindow).length > 0) quotas.session = buildPercentageQuota(primaryWindow);
|
||||
@@ -231,5 +261,10 @@ export function buildCodexUsageQuotas(dataValue: unknown): {
|
||||
);
|
||||
}
|
||||
|
||||
return { rateLimit, quotas };
|
||||
return {
|
||||
rateLimit,
|
||||
quotas,
|
||||
...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}),
|
||||
...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,12 +57,17 @@ export async function getCodexUsage(
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const { rateLimit, quotas } = buildCodexUsageQuotas(data);
|
||||
const { rateLimit, quotas, bankedResetCredits, rateLimitReachedType } =
|
||||
buildCodexUsageQuotas(data);
|
||||
|
||||
return {
|
||||
plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"),
|
||||
limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")),
|
||||
quotas,
|
||||
// Banked reset credits (display-only, eligibility-gated — issue #5199).
|
||||
// Absent for most accounts; never throws when the upstream omits it.
|
||||
...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}),
|
||||
...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Failed to fetch Codex usage: ${(error as Error).message}` };
|
||||
|
||||
@@ -110,13 +110,30 @@ function parseAntigravity(data: any) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex "banked reset credits" — an eligibility-gated field (issue #5199):
|
||||
* a count of extra rate-limit resets the account has banked. DISPLAY ONLY
|
||||
* (redemption is an unofficial mutating endpoint, out of scope). Most
|
||||
* accounts won't have this field; only render it when the count is positive.
|
||||
*/
|
||||
function buildBankedResetCreditsQuota(count: number) {
|
||||
return buildCreditsQuota("banked_reset_credits", count, 100, { currency: "" });
|
||||
}
|
||||
|
||||
function parseCodex(data: any) {
|
||||
return quotaEntries(data).map(([quotaType, quota]) =>
|
||||
const quotas = quotaEntries(data).map(([quotaType, quota]) =>
|
||||
normalizeQuotaEntry(quotaType, quota, {
|
||||
displayName: quota?.displayName,
|
||||
isPercentageOnly: true,
|
||||
})
|
||||
);
|
||||
|
||||
const bankedResetCredits = Number(data?.bankedResetCredits);
|
||||
if (Number.isFinite(bankedResetCredits) && bankedResetCredits > 0) {
|
||||
quotas.push(buildBankedResetCreditsQuota(bankedResetCredits));
|
||||
}
|
||||
|
||||
return quotas;
|
||||
}
|
||||
|
||||
function parseClaude(data: any) {
|
||||
|
||||
@@ -33,6 +33,7 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
|
||||
"Monthly Tools": "Monthly Tools",
|
||||
tokens: "Tokens",
|
||||
time_limit: "Time Limit",
|
||||
banked_reset_credits: "Banked Reset Credits",
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
200
tests/unit/codex-banked-reset-credits-5199.test.ts
Normal file
200
tests/unit/codex-banked-reset-credits-5199.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Regression test for Codex "banked reset credits" (issue #5199).
|
||||
*
|
||||
* DISPLAY ONLY: OmniRoute already calls the ChatGPT backend
|
||||
* `/backend-api/wham/usage` endpoint for quota tracking. Some eligibility-gated
|
||||
* accounts additionally expose `rate_limit_reset_credits.available_count` (a
|
||||
* count of extra rate-limit resets banked on the account) and, optionally,
|
||||
* `rate_limit_reached_type` (which window is currently blocking). This test
|
||||
* verifies:
|
||||
* 1. The field is parsed and surfaced additively when present, across both
|
||||
* independent parsers that read this payload (codexUsageQuotas.ts used by
|
||||
* the dashboard usage fetcher, and codexQuotaFetcher.ts used by the
|
||||
* preflight/monitor fetcher).
|
||||
* 2. Existing quota parsing is completely unaffected when the field is
|
||||
* absent (fail-open — no throw, no regression to session/weekly/etc).
|
||||
*
|
||||
* Redemption of banked reset credits is an unofficial, mutating upstream
|
||||
* endpoint and is explicitly OUT OF SCOPE — this only reads and surfaces data
|
||||
* already present in the existing usage-fetch response.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildCodexUsageQuotas } from "../../open-sse/services/codexUsageQuotas.ts";
|
||||
import { getCodexUsage } from "../../open-sse/services/usage/codex.ts";
|
||||
import {
|
||||
fetchCodexQuota,
|
||||
invalidateCodexQuotaCache,
|
||||
registerCodexConnection,
|
||||
unregisterCodexConnection,
|
||||
} from "../../open-sse/services/codexQuotaFetcher.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ─── codexUsageQuotas.ts (dashboard usage-fetch path) ──────────────────────
|
||||
|
||||
test("buildCodexUsageQuotas surfaces bankedResetCredits when present", () => {
|
||||
const { quotas, bankedResetCredits, rateLimitReachedType } = buildCodexUsageQuotas({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10 },
|
||||
secondary_window: { used_percent: 20 },
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 3 },
|
||||
rate_limit_reached_type: { type: "secondary_window" },
|
||||
});
|
||||
|
||||
assert.equal(bankedResetCredits, 3);
|
||||
assert.equal(rateLimitReachedType, "secondary_window");
|
||||
// Existing quotas remain intact.
|
||||
assert.equal(quotas.session?.used, 10);
|
||||
assert.equal(quotas.weekly?.used, 20);
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas tolerates camelCase field shape", () => {
|
||||
const { bankedResetCredits, rateLimitReachedType } = buildCodexUsageQuotas({
|
||||
rateLimit: { primaryWindow: { usedPercent: 5 } },
|
||||
rateLimitResetCredits: { availableCount: 7 },
|
||||
rateLimitReachedType: "primary_window",
|
||||
});
|
||||
|
||||
assert.equal(bankedResetCredits, 7);
|
||||
assert.equal(rateLimitReachedType, "primary_window");
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas leaves bankedResetCredits undefined when absent (fail-open)", () => {
|
||||
const result = buildCodexUsageQuotas({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10 },
|
||||
secondary_window: { used_percent: 20 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.bankedResetCredits, undefined);
|
||||
assert.equal(result.rateLimitReachedType, undefined);
|
||||
// Existing quota parsing is unaffected — no throw, no missing windows.
|
||||
assert.equal(result.quotas.session?.used, 10);
|
||||
assert.equal(result.quotas.weekly?.used, 20);
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas never throws on a garbage rate_limit_reset_credits shape", () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = buildCodexUsageQuotas({
|
||||
rate_limit: { primary_window: { used_percent: 1 } },
|
||||
rate_limit_reset_credits: "not-an-object",
|
||||
rate_limit_reached_type: 12345,
|
||||
});
|
||||
assert.equal(result.bankedResetCredits, undefined);
|
||||
assert.equal(result.rateLimitReachedType, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── usage/codex.ts (getCodexUsage — full dashboard fetch) ─────────────────
|
||||
|
||||
test("getCodexUsage threads bankedResetCredits through additively", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: "plus",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 15 },
|
||||
secondary_window: { used_percent: 25 },
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 2 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const usage = await getCodexUsage("token", { workspaceId: "ws-1" });
|
||||
|
||||
assert.equal((usage as any).plan, "plus");
|
||||
assert.equal((usage as any).bankedResetCredits, 2);
|
||||
assert.equal((usage as any).quotas.session.used, 15);
|
||||
assert.equal((usage as any).quotas.weekly.used, 25);
|
||||
});
|
||||
|
||||
test("getCodexUsage omits bankedResetCredits and stays intact when absent", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: "plus",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 15 },
|
||||
secondary_window: { used_percent: 25 },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const usage = await getCodexUsage("token", { workspaceId: "ws-1" });
|
||||
|
||||
assert.equal("bankedResetCredits" in (usage as any), false);
|
||||
assert.equal((usage as any).quotas.session.used, 15);
|
||||
assert.equal((usage as any).quotas.weekly.used, 25);
|
||||
});
|
||||
|
||||
// ─── codexQuotaFetcher.ts (preflight/monitor path) ─────────────────────────
|
||||
|
||||
test("fetchCodexQuota surfaces bankedResetCredits from the dual-window parser", async () => {
|
||||
const connectionId = `codex-banked-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 70, reset_after_seconds: 45 },
|
||||
secondary_window: { used_percent: 20, reset_after_seconds: 300 },
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 4 },
|
||||
rate_limit_reached_type: { type: "primary_window" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const quota = await fetchCodexQuota(connectionId, {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { workspaceId: "ws" },
|
||||
});
|
||||
|
||||
assert.ok(quota);
|
||||
assert.equal(quota?.bankedResetCredits, 4);
|
||||
assert.equal(quota?.rateLimitReachedType, "primary_window");
|
||||
// Existing dual-window parsing stays intact.
|
||||
assert.equal(quota?.window5h.percentUsed, 0.7);
|
||||
assert.equal(quota?.window7d.percentUsed, 0.2);
|
||||
|
||||
invalidateCodexQuotaCache(connectionId);
|
||||
unregisterCodexConnection(connectionId);
|
||||
});
|
||||
|
||||
test("fetchCodexQuota omits bankedResetCredits when the payload does not have it (fail-open)", async () => {
|
||||
const connectionId = `codex-nobanked-${Date.now()}`;
|
||||
|
||||
registerCodexConnection(connectionId, { accessToken: "token" });
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 30 },
|
||||
secondary_window: { used_percent: 10 },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const quota = await fetchCodexQuota(connectionId);
|
||||
|
||||
assert.ok(quota);
|
||||
assert.equal(quota?.bankedResetCredits, undefined);
|
||||
assert.equal(quota?.rateLimitReachedType, undefined);
|
||||
assert.equal(quota?.window5h.percentUsed, 0.3);
|
||||
assert.equal(quota?.window7d.percentUsed, 0.1);
|
||||
|
||||
invalidateCodexQuotaCache(connectionId);
|
||||
unregisterCodexConnection(connectionId);
|
||||
});
|
||||
Reference in New Issue
Block a user