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
This commit is contained in:
killer30001000
2026-09-18 17:21:17 +02:00
committed by GitHub
parent ad633c8440
commit d71d0f76e5
5 changed files with 308 additions and 10 deletions

View File

@@ -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))

View File

@@ -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<string, unknown>) {
const accountKey = resolveAccountKey(connectionId, connection);
const status = getFreeWindowStatus(accountKey);

View File

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

View File

@@ -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<CreditsQuota> {
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);
});

View File

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