mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
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:
1
changelog.d/fixes/12468-openrouter-payg-credit-pool.md
Normal file
1
changelog.d/fixes/12468-openrouter-payg-credit-pool.md
Normal 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))
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
114
tests/unit/openrouter-payg-credit-percentage.test.ts
Normal file
114
tests/unit/openrouter-payg-credit-percentage.test.ts
Normal 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);
|
||||
});
|
||||
130
tests/unit/providerlimits-openrouter-credits-parser.test.ts
Normal file
130
tests/unit/providerlimits-openrouter-credits-parser.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user