mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases (#13689)
* fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases * chore: rename changelog fragment to PR number 13689
This commit is contained in:
1
changelog.d/fixes/13689-openrouter-free-tier-1000-day.md
Normal file
1
changelog.d/fixes/13689-openrouter-free-tier-1000-day.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(openrouter):** sync the `:free` 1000/day tier from `/credits` lifetime purchases instead of staying stuck at 50/day for $10+ accounts
|
||||
@@ -119,6 +119,14 @@ function pruneRpmWindow(state: AccountWindowState, now: number): void {
|
||||
state.requestTimestamps = state.requestTimestamps.filter((ts) => ts > cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifetime-purchase threshold (USD) unlocking OpenRouter's 1000/day
|
||||
* `:free`-model tier instead of the 50/day base tier.
|
||||
* See https://openrouter.ai/docs/limits (`GET /api/v1/key` -> `is_free_tier`,
|
||||
* `GET /api/v1/credits` -> `total_credits`/`total_usage`).
|
||||
*/
|
||||
export const OPENROUTER_PURCHASED_TIER_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Operator override: declare whether $10+ has been purchased all-time on this
|
||||
* account, unlocking the 1000/day tier instead of the 50/day default.
|
||||
@@ -128,6 +136,40 @@ export function setPurchasedTier(accountKey: string, purchasedAtLeast10: boolean
|
||||
state.purchasedAtLeast10 = purchasedAtLeast10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the 50-vs-1000/day tier from OpenRouter quota signals.
|
||||
*
|
||||
* Primary signal is `total_credits` (`GET /api/v1/credits` ->
|
||||
* `data.total_credits`, lifetime USD purchased): a finite value sets the tier
|
||||
* to `totalCredits >= OPENROUTER_PURCHASED_TIER_THRESHOLD`.
|
||||
*
|
||||
* `is_free_tier` (`GET /api/v1/key`) alone is deliberately NOT enough to
|
||||
* unlock the tier — it only means "has paid something before", not
|
||||
* ">= $10 lifetime", so an account that paid e.g. $1 would be wrongly
|
||||
* promoted. When `total_credits` is absent the state is left untouched
|
||||
* (fail open) so a partial quota response can never downgrade an already
|
||||
* unlocked bucket.
|
||||
*
|
||||
* @returns true when the tier was set from `total_credits`, false otherwise.
|
||||
*/
|
||||
export function syncPurchasedTierFromQuota(
|
||||
accountKey: string,
|
||||
signals:
|
||||
| {
|
||||
totalCredits?: number | null;
|
||||
isFreeTier?: boolean | null;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): boolean {
|
||||
const totalCredits = signals?.totalCredits;
|
||||
if (typeof totalCredits === "number" && Number.isFinite(totalCredits)) {
|
||||
setPurchasedTier(accountKey, totalCredits >= OPENROUTER_PURCHASED_TIER_THRESHOLD);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `:free`-variant request attempt against the account bucket.
|
||||
* Failed attempts count toward the daily cap too (per OpenRouter's own
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getFreeWindowStatus,
|
||||
isFreeVariantModel,
|
||||
resolveAccountKey,
|
||||
syncPurchasedTierFromQuota,
|
||||
type FreeWindowStatus,
|
||||
} from "./openrouterFreeWindow.ts";
|
||||
|
||||
@@ -313,6 +314,30 @@ function rememberQuota(connectionId: string, quota: OpenrouterQuota): Openrouter
|
||||
return quota;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed the quota's purchase-history signals into the `:free`-window tier
|
||||
* state so the local 50-vs-1000/day limit tracks OpenRouter's documented
|
||||
* $10 lifetime-purchase tier (https://openrouter.ai/docs/limits) instead of
|
||||
* staying stuck at the 50/day default. Never throws — tier sync must never
|
||||
* break quota fetching.
|
||||
*/
|
||||
function syncTierFromQuota(
|
||||
connectionId: string,
|
||||
connection: Record<string, unknown> | undefined,
|
||||
quota: Pick<OpenrouterQuota, "totalCredits" | "isFreeTier"> | null | undefined
|
||||
): void {
|
||||
if (!quota) return;
|
||||
try {
|
||||
const accountKey = resolveAccountKey(connectionId, connection);
|
||||
syncPurchasedTierFromQuota(accountKey, {
|
||||
totalCredits: quota.totalCredits,
|
||||
isFreeTier: quota.isFreeTier,
|
||||
});
|
||||
} catch {
|
||||
// Fail open: a tier-sync problem must not fail the quota fetch.
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOpenrouterResults(
|
||||
keyResult: EndpointResult,
|
||||
creditsResult: EndpointResult
|
||||
@@ -346,6 +371,7 @@ export async function fetchOpenrouterQuota(
|
||||
): Promise<QuotaInfo | null> {
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
syncTierFromQuota(connectionId, connection, cached.quota);
|
||||
return cached.quota;
|
||||
}
|
||||
|
||||
@@ -375,6 +401,7 @@ export async function fetchOpenrouterQuota(
|
||||
}
|
||||
|
||||
const quota = mergeOpenrouterResults(keyResult, creditsResult);
|
||||
if (quota) syncTierFromQuota(connectionId, connection, quota);
|
||||
return quota ? rememberQuota(connectionId, quota) : null;
|
||||
} catch {
|
||||
// Network error, timeout, etc. — fail open (graceful "unknown").
|
||||
@@ -399,12 +426,33 @@ export async function fetchOpenrouterQuotaWithFreeWindowPreflight(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
): Promise<QuotaInfo | null> {
|
||||
const freeWindowExhausted = checkFreeWindowExhausted(
|
||||
const initiallyExhausted = checkFreeWindowExhausted(
|
||||
connectionId,
|
||||
connection,
|
||||
connection?.requestedModel
|
||||
);
|
||||
return freeWindowExhausted ?? fetchOpenrouterQuota(connectionId, connection);
|
||||
if (!initiallyExhausted) {
|
||||
return fetchOpenrouterQuota(connectionId, connection);
|
||||
}
|
||||
// The local daily counter says exhausted, but it may be enforcing a stale
|
||||
// 50/day tier: fetchOpenrouterQuota() syncs the 1000/day tier from
|
||||
// /credits as a side effect (cached when fresh, fetched otherwise), so
|
||||
// re-evaluate after the refresh instead of returning the stale verdict.
|
||||
// Without this, an exhausted 50-counter short-circuits before the fetch
|
||||
// that would prove the $10+ tier — stuck until UTC midnight.
|
||||
try {
|
||||
const quota = await fetchOpenrouterQuota(connectionId, connection);
|
||||
const accountKey = resolveAccountKey(connectionId, connection);
|
||||
const status = getFreeWindowStatus(accountKey);
|
||||
if (status.dailyRemaining > 0) {
|
||||
// Tier refresh unlocked headroom (e.g. 50 -> 1000/day): allow the
|
||||
// request by returning the fresh quota (null = unknown = fail open).
|
||||
return quota;
|
||||
}
|
||||
return buildFreeWindowExhaustedQuota(status);
|
||||
} catch {
|
||||
return initiallyExhausted;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
*/
|
||||
|
||||
import { fetchOpenrouterQuota, type OpenrouterQuota } from "../openrouterQuotaFetcher.ts";
|
||||
import { getFreeWindowStatus, resolveAccountKey } from "../openrouterFreeWindow.ts";
|
||||
import {
|
||||
getFreeWindowStatus,
|
||||
resolveAccountKey,
|
||||
syncPurchasedTierFromQuota,
|
||||
} from "../openrouterFreeWindow.ts";
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
|
||||
function buildCreditsQuota(quota: OpenrouterQuota): UsageQuota | null {
|
||||
@@ -63,6 +67,20 @@ export async function getOpenrouterUsage(
|
||||
const connection = { apiKey, providerSpecificData: providerSpecificData ?? {} };
|
||||
const quota = (await fetchOpenrouterQuota(connectionId, connection)) as OpenrouterQuota | null;
|
||||
|
||||
// Belt-and-suspenders: fetchOpenrouterQuota() already syncs the tier as a
|
||||
// side effect, but re-apply here so the dashboard payload and the
|
||||
// quota-preflight enforcement always agree even on cache-hit paths.
|
||||
if (quota) {
|
||||
try {
|
||||
syncPurchasedTierFromQuota(resolveAccountKey(connectionId, connection), {
|
||||
totalCredits: quota.totalCredits,
|
||||
isFreeTier: quota.isFreeTier,
|
||||
});
|
||||
} catch {
|
||||
// Fail open: tier sync must never break the usage payload.
|
||||
}
|
||||
}
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
const { dailyQuota, rpmQuota } = buildFreeWindowQuota(connectionId, connection);
|
||||
quotas.free_daily = dailyQuota;
|
||||
|
||||
164
tests/unit/openrouter-free-tier-1000-day.test.ts
Normal file
164
tests/unit/openrouter-free-tier-1000-day.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* OpenRouter `:free` daily tier — $10+ lifetime purchases unlock 1000/day.
|
||||
*
|
||||
* Regression guard for the stuck-at-50 bug: `setPurchasedTier()` had zero
|
||||
* production callers, so any account with >= $10 lifetime purchases was still
|
||||
* displayed and enforced as 50/day until a 429 happened to correct it.
|
||||
* Quota fetching (`GET /api/v1/key` + `GET /api/v1/credits`, see
|
||||
* https://openrouter.ai/docs/limits) must feed `total_credits` into the
|
||||
* free-window tier, and the preflight short-circuit must not hide the signal
|
||||
* that would un-exhaust it.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
clearFreeWindowState,
|
||||
getFreeWindowStatus,
|
||||
recordFreeWindowAttempt,
|
||||
resolveAccountKey,
|
||||
setPurchasedTier,
|
||||
syncPurchasedTierFromQuota,
|
||||
} from "../../open-sse/services/openrouterFreeWindow.ts";
|
||||
import {
|
||||
fetchOpenrouterQuota,
|
||||
fetchOpenrouterQuotaWithFreeWindowPreflight,
|
||||
invalidateOpenrouterQuotaCache,
|
||||
} from "../../open-sse/services/openrouterQuotaFetcher.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearFreeWindowState();
|
||||
});
|
||||
|
||||
function mockQuotaFetch(totalCredits: number | null, isFreeTier = false): void {
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith("/key")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
limit: null,
|
||||
limit_remaining: null,
|
||||
limit_reset: null,
|
||||
is_free_tier: isFreeTier,
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ data: { total_credits: totalCredits, total_usage: 0 } }), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ─── syncPurchasedTierFromQuota unit behavior ─────────────────────────────
|
||||
|
||||
test("syncPurchasedTierFromQuota: total_credits >= 10 unlocks the 1000/day tier", () => {
|
||||
const accountKey = `acct-sync-1000-${Date.now()}`;
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 50);
|
||||
assert.equal(syncPurchasedTierFromQuota(accountKey, { totalCredits: 10 }), true);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 1000);
|
||||
});
|
||||
|
||||
test("syncPurchasedTierFromQuota: total_credits < 10 keeps the 50/day tier", () => {
|
||||
const accountKey = `acct-sync-50-${Date.now()}`;
|
||||
setPurchasedTier(accountKey, true); // start unlocked to prove a downgrade applies
|
||||
assert.equal(syncPurchasedTierFromQuota(accountKey, { totalCredits: 9.99 }), true);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 50);
|
||||
});
|
||||
|
||||
test("syncPurchasedTierFromQuota: missing signals leave state untouched (fail open)", () => {
|
||||
const accountKey = `acct-sync-missing-${Date.now()}`;
|
||||
assert.equal(syncPurchasedTierFromQuota(accountKey, { totalCredits: null }), false);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 50);
|
||||
|
||||
setPurchasedTier(accountKey, true);
|
||||
// is_free_tier=false alone only means "has paid something", not ">= $10" —
|
||||
// must not touch the tier either way.
|
||||
assert.equal(
|
||||
syncPurchasedTierFromQuota(accountKey, { totalCredits: null, isFreeTier: false }),
|
||||
false
|
||||
);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 1000);
|
||||
|
||||
assert.equal(syncPurchasedTierFromQuota(accountKey, null), false);
|
||||
assert.equal(syncPurchasedTierFromQuota(accountKey, undefined), false);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 1000);
|
||||
});
|
||||
|
||||
// ─── fetchOpenrouterQuota feeds the tier ──────────────────────────────────
|
||||
|
||||
test("fetchOpenrouterQuota with total_credits >= 10 unlocks the 1000/day window", async () => {
|
||||
const connectionId = `openrouter-tier-1000-${Date.now()}`;
|
||||
mockQuotaFetch(25);
|
||||
const quota = await fetchOpenrouterQuota(connectionId, { apiKey: "test-key" });
|
||||
assert.ok(quota);
|
||||
const accountKey = resolveAccountKey(connectionId, { apiKey: "test-key" });
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 1000);
|
||||
invalidateOpenrouterQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchOpenrouterQuota with total_credits < 10 keeps the 50/day window", async () => {
|
||||
const connectionId = `openrouter-tier-50-${Date.now()}`;
|
||||
mockQuotaFetch(2);
|
||||
const quota = await fetchOpenrouterQuota(connectionId, { apiKey: "test-key" });
|
||||
assert.ok(quota);
|
||||
const accountKey = resolveAccountKey(connectionId, { apiKey: "test-key" });
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 50);
|
||||
invalidateOpenrouterQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
// ─── Preflight trap: exhausted-at-50 refreshes to 1000 and allows ─────────
|
||||
|
||||
test("preflight exhausted at 50/50 refreshes to the 1000 tier and allows the request", async () => {
|
||||
const connectionId = `openrouter-preflight-unstick-${Date.now()}`;
|
||||
const connection = { apiKey: "or-test-key", requestedModel: "x-ai/grok-4-fast:free" };
|
||||
const accountKey = resolveAccountKey(connectionId, connection);
|
||||
|
||||
// Exhaust the base-tier window (50/50) — the stale state that used to stick
|
||||
// until UTC midnight because the short-circuit skipped the quota fetch.
|
||||
for (let i = 0; i < 50; i++) recordFreeWindowAttempt(accountKey);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyRemaining, 0);
|
||||
|
||||
// Upstream proves >= $10 lifetime purchases.
|
||||
mockQuotaFetch(25);
|
||||
|
||||
const quota = await fetchOpenrouterQuotaWithFreeWindowPreflight(connectionId, connection);
|
||||
|
||||
assert.equal(
|
||||
getFreeWindowStatus(accountKey).dailyLimit,
|
||||
1000,
|
||||
"tier refresh must promote the bucket before the exhausted verdict"
|
||||
);
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyRemaining, 950);
|
||||
assert.ok(quota, "request must be allowed, not short-circuited as exhausted");
|
||||
assert.equal(quota!.limitReached, false);
|
||||
invalidateOpenrouterQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("preflight still short-circuits without network when quota confirms the base tier", async () => {
|
||||
const connectionId = `openrouter-preflight-still-50-${Date.now()}`;
|
||||
const connection = { apiKey: "or-test-key", requestedModel: "x-ai/grok-4-fast:free" };
|
||||
const accountKey = resolveAccountKey(connectionId, connection);
|
||||
|
||||
for (let i = 0; i < 50; i++) recordFreeWindowAttempt(accountKey);
|
||||
mockQuotaFetch(2); // < $10: genuinely still 50/day
|
||||
|
||||
let fetchCalls = 0;
|
||||
const innerFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (...args) => {
|
||||
fetchCalls += 1;
|
||||
return (innerFetch as typeof fetch)(...args);
|
||||
};
|
||||
|
||||
const quota = await fetchOpenrouterQuotaWithFreeWindowPreflight(connectionId, connection);
|
||||
|
||||
assert.ok(fetchCalls > 0, "tier refresh needs the quota fetch to confirm the base tier");
|
||||
assert.ok(quota);
|
||||
assert.equal(quota!.limitReached, true, "genuinely exhausted base tier must still block");
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyLimit, 50);
|
||||
invalidateOpenrouterQuotaCache(connectionId);
|
||||
});
|
||||
@@ -127,21 +127,24 @@ test("DefaultExecutor(openrouter) self-corrects the free window from X-RateLimit
|
||||
});
|
||||
|
||||
const status = getFreeWindowStatus(accountKey);
|
||||
assert.equal(status.dailyRemaining, 0, "server-reported remaining should override the local count");
|
||||
assert.equal(
|
||||
status.dailyRemaining,
|
||||
0,
|
||||
"server-reported remaining should override the local count"
|
||||
);
|
||||
assert.equal(status.dailyLimit, 50, "server-reported limit should be adopted");
|
||||
});
|
||||
|
||||
// ─── 2. ENFORCE: exhausted free window short-circuits the quota preflight ─
|
||||
|
||||
test("fetchOpenrouterQuotaWithFreeWindowPreflight returns limitReached for an exhausted :free model WITHOUT calling fetch (RED without wiring)", async () => {
|
||||
test("fetchOpenrouterQuotaWithFreeWindowPreflight returns limitReached for an exhausted :free model after a tier-refresh fetch (RED without wiring)", async () => {
|
||||
const connectionId = `openrouter-enforce-${Date.now()}`;
|
||||
const accountKey = resolveAccountKey(connectionId, {});
|
||||
|
||||
// Exhaust the daily window (default cap: 50/day at $0 purchased-tier).
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { recordFreeWindowAttempt } = await import(
|
||||
"../../open-sse/services/openrouterFreeWindow.ts"
|
||||
);
|
||||
const { recordFreeWindowAttempt } =
|
||||
await import("../../open-sse/services/openrouterFreeWindow.ts");
|
||||
recordFreeWindowAttempt(accountKey);
|
||||
}
|
||||
assert.equal(getFreeWindowStatus(accountKey).dailyRemaining, 0, "precondition: window exhausted");
|
||||
@@ -157,9 +160,21 @@ test("fetchOpenrouterQuotaWithFreeWindowPreflight returns limitReached for an ex
|
||||
requestedModel: "x-ai/grok-4-fast:free",
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls, 0, "an exhausted free window must short-circuit BEFORE any /key or /credits call");
|
||||
// Tier-refresh behavior: an exhausted window refreshes the $10+ purchase
|
||||
// tier from quota (cached when fresh, fetched otherwise) before verdict —
|
||||
// otherwise a stale 50-counter hides the signal that would un-exhaust it
|
||||
// (stuck until UTC midnight). This mock returns no purchase signal, so the
|
||||
// verdict stays exhausted after the refresh round-trip.
|
||||
assert.ok(
|
||||
fetchCalls > 0,
|
||||
"an exhausted free window must refresh the purchase tier from quota before verdict"
|
||||
);
|
||||
assert.ok(quota, "quota should be a limitReached result, not null");
|
||||
assert.equal(quota!.limitReached, true, "combo preflight must see limitReached to skip this target");
|
||||
assert.equal(
|
||||
quota!.limitReached,
|
||||
true,
|
||||
"combo preflight must see limitReached to skip this target"
|
||||
);
|
||||
invalidateOpenrouterQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
@@ -193,9 +208,8 @@ test("fetchOpenrouterQuotaWithFreeWindowPreflight proceeds to the normal /key+/c
|
||||
test("fetchOpenrouterQuotaWithFreeWindowPreflight ignores free-window state for non-:free requestedModel", async () => {
|
||||
const connectionId = `openrouter-enforce-nonfree-${Date.now()}`;
|
||||
const accountKey = resolveAccountKey(connectionId, {});
|
||||
const { recordFreeWindowAttempt } = await import(
|
||||
"../../open-sse/services/openrouterFreeWindow.ts"
|
||||
);
|
||||
const { recordFreeWindowAttempt } =
|
||||
await import("../../open-sse/services/openrouterFreeWindow.ts");
|
||||
for (let i = 0; i < 50; i++) recordFreeWindowAttempt(accountKey);
|
||||
|
||||
let fetchCalls = 0;
|
||||
@@ -217,7 +231,10 @@ test("fetchOpenrouterQuotaWithFreeWindowPreflight ignores free-window state for
|
||||
requestedModel: "x-ai/grok-4-fast", // paid variant — exhausted free window is irrelevant
|
||||
});
|
||||
|
||||
assert.ok(fetchCalls > 0, "a paid model must never be short-circuited by the free-window counter");
|
||||
assert.ok(
|
||||
fetchCalls > 0,
|
||||
"a paid model must never be short-circuited by the free-window counter"
|
||||
);
|
||||
assert.ok(quota);
|
||||
invalidateOpenrouterQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user