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:
Notaloop763
2026-09-18 09:32:10 -05:00
committed by GitHub
parent ce56098115
commit 1609767f37
6 changed files with 304 additions and 14 deletions

View 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

View File

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

View File

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

View File

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

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

View File

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