From d71d0f76e555d2c2ff7bf098f2a3b99e91e788ae Mon Sep 17 00:00:00 2001 From: killer30001000 Date: Fri, 18 Sep 2026 17:21:17 +0200 Subject: [PATCH] fix(usage): render OpenRouter PAYG credit pool with real denominator (#12468) * fix(usage) handle OpenRouter PAYG credit percentage OpenRouter PAYG accounts without a per-key limit previously rendered the credits row as 'total: 0, remainingPercentage: 100, unlimited: true', treating /credits balance as unlimited even when a real credit pool was present. Route the credit pool through the credits renderer with the real denominator: total = totalCredits when positive, used = total - creditBalance, remaining = creditBalance, remainingPercentage = round(balance / total * 100), isCredits: true, unlimited: false. Per-key limit still wins. A non-positive pool surfaces the row but never invents a 100% bar. Tests cover: explicit key limit, PAYG account credits without key limit, key limit taking priority over account credits, and a balance without a positive denominator. * fix(usage) render OpenRouter PAYG quota as a metered percentage bar The frontend parser was routing every OpenRouter 'credits' quota through buildCreditsQuota(), which sets isCredits: true. QuotaCardExpanded short-circuits on that flag and shows only the USD balance as a bare number, so a real PAYG payload (used: 7.33, total: 10, remaining: 2.67, remainingPercentage: 27) was rendered as '$2.67' instead of the '27% left / 7.33 / 10' bar the backend already computed. Drop isCredits: true for any payload whose total is a positive finite number - the row then goes through the normal normalizeQuotaEntry() path with currency preserved as an extra. The balance-only fallback (total 0 or non-finite denominator, used by legacy /credits responses) still uses buildCreditsQuota() so the row stays renderable, and never invents a 100% percentage. The frontend test now asserts: - PAYG positive denominator -> total: 10, remainingPercentage: 27, currency: 'USD', isCredits !== true. - Balance-only payload -> isCredits === true, creditCount === 2.67, total: 0, no fabricated 100%. - NaN denominator -> balance-only fallback. - Non-credits keys -> unchanged normalizeQuotaEntry() path. - Mixed payload -> normal quota row + PAYG row, both kept. * docs(changelog): add OpenRouter PAYG fix fragment * docs(changelog): remove self credit --- .../12468-openrouter-payg-credit-pool.md | 1 + open-sse/services/usage/openrouter.ts | 52 ++++++- .../components/ProviderLimits/quotaParsing.ts | 21 ++- .../openrouter-payg-credit-percentage.test.ts | 114 +++++++++++++++ ...erlimits-openrouter-credits-parser.test.ts | 130 ++++++++++++++++++ 5 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/12468-openrouter-payg-credit-pool.md create mode 100644 tests/unit/openrouter-payg-credit-percentage.test.ts create mode 100644 tests/unit/providerlimits-openrouter-credits-parser.test.ts diff --git a/changelog.d/fixes/12468-openrouter-payg-credit-pool.md b/changelog.d/fixes/12468-openrouter-payg-credit-pool.md new file mode 100644 index 0000000000..5cfe4615ff --- /dev/null +++ b/changelog.d/fixes/12468-openrouter-payg-credit-pool.md @@ -0,0 +1 @@ +- **fix(usage):** Render OpenRouter PAYG account credits as a metered quota when no per-key spending limit is set ([#12468](https://github.com/diegosouzapw/OmniRoute/pull/12468)) diff --git a/open-sse/services/usage/openrouter.ts b/open-sse/services/usage/openrouter.ts index 4fce98ec18..d423ba60b9 100644 --- a/open-sse/services/usage/openrouter.ts +++ b/open-sse/services/usage/openrouter.ts @@ -15,18 +15,58 @@ import { import { type UsageQuota } from "./quota.ts"; function buildCreditsQuota(quota: OpenrouterQuota): UsageQuota | null { + // OpenRouter #12256 + #12468: per-key USD cap and account-level credit pool + // both flow through this builder. Per-key limit wins; PAYG accounts without + // a key cap report `limit: null` plus a credit pool derived from + // `totalCredits - totalUsage`. We use `totalCredits` as the bar denominator + // when no key limit exists and the pool is positive; a non-positive pool + // must never fabricate a 100% bar. if (quota.limit === null && quota.creditBalance === null) return null; + const hasKeyLimit = quota.limit !== null; + const hasPositivePool = + quota.creditBalance !== null && + quota.creditBalance !== undefined && + Number.isFinite(quota.creditBalance) && + quota.creditBalance > 0; + const totalCredits = + quota.totalCredits !== null && + quota.totalCredits !== undefined && + Number.isFinite(quota.totalCredits) && + quota.totalCredits > 0 + ? quota.totalCredits + : 0; + const total = hasKeyLimit + ? (quota.limit ?? 0) + : totalCredits > 0 + ? totalCredits + : hasPositivePool + ? quota.creditBalance! + : 0; + const used = hasKeyLimit + ? quota.limit! - (quota.limitRemaining ?? quota.limit!) + : total > 0 && hasPositivePool + ? Math.max(0, total - (quota.creditBalance ?? 0)) + : 0; + const remaining = hasKeyLimit + ? (quota.limitRemaining ?? 0) + : hasPositivePool + ? (quota.creditBalance ?? 0) + : 0; + const remainingPercentage = hasKeyLimit + ? Math.max(0, Math.min(100, Math.round((1 - quota.percentUsed) * 100))) + : total > 0 && totalCredits > 0 && hasPositivePool + ? Math.max(0, Math.min(100, Math.round(((quota.creditBalance ?? 0) / totalCredits) * 100))) + : undefined; return { - used: quota.limit !== null ? quota.limit - (quota.limitRemaining ?? quota.limit) : 0, - total: quota.limit ?? 0, - remaining: quota.creditBalance ?? undefined, - remainingPercentage: quota.limit !== null ? Math.round((1 - quota.percentUsed) * 100) : 100, + used, + total, + remaining, + remainingPercentage, resetAt: quota.resetAt ?? null, - unlimited: quota.limit === null, + unlimited: !hasKeyLimit && !hasPositivePool, currency: "USD", }; } - function buildFreeWindowQuota(connectionId: string, connection?: Record) { const accountKey = resolveAccountKey(connectionId, connection); const status = getFreeWindowStatus(accountKey); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 0808afea17..463c19d0c6 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -331,13 +331,26 @@ function parseAgentrouter(data: any) { // USD. Free-tier request windows keep the generic percentage treatment. function parseOpenrouterQuota(quotaKey: string, quota: any) { if (quotaKey !== "credits") return normalizeQuotaEntry(quotaKey, quota); + // OpenRouter backend (PRs #12256 + #12468) reports a positive-denominator + // PAYG payload (used, total, remaining, remainingPercentage) and a + // balance-only payload under legacy keys. The credits renderer in + // QuotaCardExpanded short-circuits when `isCredits: true` and only shows + // the remaining balance as USD - so a positive-denominator PAYG row + // must NOT take that branch. Positive denominators go through the regular + // normalizeQuotaEntry() path (which keeps currency as an extra); only a + // missing/non-positive denominator falls back to buildCreditsQuota() so + // the balance row stays renderable without inventing a 100% percentage. + const total = Number(quota?.total ?? 0); + if (Number.isFinite(total) && total > 0) { + return normalizeQuotaEntry(quotaKey, quota, { + currency: quota?.currency ?? "USD", + }); + } const remaining = Math.max(0, Number(quota?.remaining ?? 0)); - const currency = quota?.currency || "USD"; - const remainingPercentage = - safePercentage(quota?.remainingPercentage) ?? (remaining > 0 ? 100 : 0); + const currency = quota?.currency ?? "USD"; + const remainingPercentage = safePercentage(quota?.remainingPercentage) ?? 0; return buildCreditsQuota("credits", remaining, remainingPercentage, { currency }); } - function parseOpenrouter(data: any) { return quotaEntries(data).map(([quotaKey, quota]) => parseOpenrouterQuota(quotaKey, quota)); } diff --git a/tests/unit/openrouter-payg-credit-percentage.test.ts b/tests/unit/openrouter-payg-credit-percentage.test.ts new file mode 100644 index 0000000000..664d090729 --- /dev/null +++ b/tests/unit/openrouter-payg-credit-percentage.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { invalidateOpenrouterQuotaCache } from "../../open-sse/services/openrouterQuotaFetcher.ts"; +import { getOpenrouterUsage } from "../../open-sse/services/usage/openrouter.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type CreditsQuota = { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + unlimited: boolean; +}; + +async function creditsQuota( + connectionId: string, + key: { limit: number | null; limitRemaining: number | null }, + credits: { totalCredits: number | null; totalUsage: number | null } +): Promise { + globalThis.fetch = (async (url: unknown) => { + if (String(url).endsWith("/key")) { + return new Response( + JSON.stringify({ + data: { + limit: key.limit, + limit_remaining: key.limitRemaining, + limit_reset: null, + is_free_tier: false, + }, + }), + { status: 200 } + ); + } + return new Response( + JSON.stringify({ + data: { + total_credits: credits.totalCredits, + total_usage: credits.totalUsage, + }, + }), + { status: 200 } + ); + }) as typeof fetch; + + const result = await getOpenrouterUsage(connectionId, "synthetic-openrouter-key"); + invalidateOpenrouterQuotaCache(connectionId); + const quota = result.quotas?.credits as CreditsQuota | undefined; + assert.ok(quota); + return quota; +} + +test("OpenRouter explicit key limit reports remaining percentage", async () => { + const quota = await creditsQuota( + "openrouter-payg-key-limit", + { limit: 10, limitRemaining: 9.3 }, + { totalCredits: 10, totalUsage: 0.7 } + ); + + assert.equal(quota.total, 10); + assert.ok(Math.abs(quota.used - 0.7) < 0.000_001); + assert.equal(quota.remaining, 9.3); + assert.equal(quota.remainingPercentage, 93); + assert.equal(quota.unlimited, false); +}); + +test("OpenRouter PAYG account credits provide denominator without key limit", async () => { + const quota = await creditsQuota( + "openrouter-payg-account", + { limit: null, limitRemaining: null }, + { totalCredits: 10, totalUsage: 0.7 } + ); + + assert.equal(quota.total, 10); + assert.ok(Math.abs(quota.used - 0.7) < 0.000_001); + assert.ok(Math.abs((quota.remaining ?? 0) - 9.3) < 0.000_001); + assert.equal(quota.remainingPercentage, 93); + assert.equal(quota.unlimited, false); +}); + +test("OpenRouter key limit takes priority over account credits", async () => { + const quota = await creditsQuota( + "openrouter-payg-key-priority", + { limit: 5, limitRemaining: 2 }, + { totalCredits: 10, totalUsage: 0.7 } + ); + + assert.equal(quota.total, 5); + assert.equal(quota.used, 3); + assert.equal(quota.remaining, 2); + assert.equal(quota.remainingPercentage, 40); + assert.equal(quota.unlimited, false); +}); + +test("OpenRouter balance without positive denominator does not invent 100 percent", async () => { + const quota = await creditsQuota( + "openrouter-payg-no-denominator", + { limit: null, limitRemaining: null }, + { totalCredits: 0, totalUsage: -5 } + ); + + // No positive pool denominator: balance is still surfaced as a credit + // row but no fabricated 100% percentage is reported. + assert.equal(quota.total, 5); + assert.equal(quota.used, 0); + assert.equal(quota.remaining, 5); + assert.equal(quota.remainingPercentage, undefined); + assert.equal(quota.unlimited, false); +}); diff --git a/tests/unit/providerlimits-openrouter-credits-parser.test.ts b/tests/unit/providerlimits-openrouter-credits-parser.test.ts new file mode 100644 index 0000000000..e84357642e --- /dev/null +++ b/tests/unit/providerlimits-openrouter-credits-parser.test.ts @@ -0,0 +1,130 @@ +/** + * Frontend parser regression #12468 follow-up. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts"; + +const parseOpenrouter = (data: unknown) => parseQuotaData("openrouter", data) as QuotaRow[]; + +type QuotaRow = { + name: string; + used: number; + total: number; + remaining: number; + remainingPercentage?: number; + unlimited?: boolean; + isCredits?: boolean; + creditCount?: number; + currency?: string; +}; + +function findCredits(rows: QuotaRow[]): QuotaRow { + const row = rows.find((r: QuotaRow) => r.name === "credits"); + assert.ok(row, "expected 'credits' row"); + return row as QuotaRow; +} + +test("parseOpenrouter renders PAYG account quota with normal percentage bar (no isCredits short-circuit)", () => { + const rows = parseOpenrouter({ + quotas: { + credits: { + used: 7.332_573_982, + total: 10, + remaining: 2.667_426_018, + remainingPercentage: 27, + unlimited: false, + currency: "USD", + }, + }, + }); + const row = findCredits(rows); + assert.equal(row.total, 10, "total must come from PAYG denominator"); + assert.ok(Math.abs(row.used - 7.332_573_982) < 1e-6); + assert.ok(Math.abs(row.remaining - 2.667_426_018) < 1e-6); + assert.equal(row.remainingPercentage, 27); + assert.equal(row.currency, "USD"); + // The credits renderer in QuotaCardExpanded short-circuits when isCredits + // is true and only shows the USD balance - a positive-denominator PAYG row + // must NOT take that branch. + assert.notEqual(row.isCredits, true, "PAYG row must not flip isCredits"); +}); + +test("parseOpenrouter keeps credit-balance row when no positive denominator", () => { + const rows = parseOpenrouter({ + quotas: { + credits: { + used: 0, + total: 0, + remaining: 2.67, + unlimited: false, + currency: "USD", + }, + }, + }); + const row = findCredits(rows); + assert.equal(row.total, 0); + assert.equal(row.used, 0); + assert.equal(row.remaining, 2.67); + assert.equal(row.isCredits, true, "balance row keeps isCredits renderer"); + assert.equal(row.creditCount, 2.67); + assert.notEqual(row.remainingPercentage, 100, "no fabricated 100% percentage"); +}); + +test("parseOpenrouter falls back to balance row for non-finite denominator", () => { + const rows = parseOpenrouter({ + quotas: { + credits: { used: 0, total: NaN, remaining: 1.5 }, + }, + }); + const row = findCredits(rows); + assert.equal(row.isCredits, true); + assert.equal(row.remaining, 1.5); +}); + +test("parseOpenrouter keeps per-model rows alongside PAYG credit row", () => { + const rows = parseOpenrouter({ + quotas: { + credits: { + used: 7.332_573_982, + total: 10, + remaining: 2.667_426_018, + remainingPercentage: 27, + unlimited: false, + currency: "USD", + }, + "anthropic/claude-3.5-sonnet": { + used: 12, + total: 100, + remaining: 88, + resetAt: null, + }, + }, + }); + assert.equal(rows.length, 2); + const credits = findCredits(rows); + assert.equal(credits.total, 10); + assert.ok(Math.abs(credits.used - 7.332_573_982) < 1e-6); + assert.equal(credits.remainingPercentage, 27); + assert.notEqual(credits.isCredits, true); +}); + +test("parseOpenrouter passes non-credits quota keys through normalizeQuotaEntry", () => { + const rows = parseOpenrouter({ + quotas: { + "anthropic/claude-3.5-sonnet": { + used: 12, + total: 100, + remaining: 88, + resetAt: null, + }, + }, + }); + assert.equal(rows.length, 1); + const row = rows[0]; + assert.equal(row.name, "anthropic/claude-3.5-sonnet"); + assert.equal(row.total, 100); + assert.equal(row.remaining, 88); + assert.notEqual(row.isCredits, true); +});