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); +});