diff --git a/changelog.d/fixes/13689-openrouter-free-tier-1000-day.md b/changelog.d/fixes/13689-openrouter-free-tier-1000-day.md new file mode 100644 index 0000000000..5401e3b4eb --- /dev/null +++ b/changelog.d/fixes/13689-openrouter-free-tier-1000-day.md @@ -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 diff --git a/open-sse/services/openrouterFreeWindow.ts b/open-sse/services/openrouterFreeWindow.ts index 4f05061a18..e14e62911b 100644 --- a/open-sse/services/openrouterFreeWindow.ts +++ b/open-sse/services/openrouterFreeWindow.ts @@ -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 diff --git a/open-sse/services/openrouterQuotaFetcher.ts b/open-sse/services/openrouterQuotaFetcher.ts index 54d2c80231..2fc7a94422 100644 --- a/open-sse/services/openrouterQuotaFetcher.ts +++ b/open-sse/services/openrouterQuotaFetcher.ts @@ -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 | undefined, + quota: Pick | 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 { 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 ): Promise { - 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 ───────────────────────────────────────────────────────────── diff --git a/open-sse/services/usage/openrouter.ts b/open-sse/services/usage/openrouter.ts index 2dcb05f42a..4fce98ec18 100644 --- a/open-sse/services/usage/openrouter.ts +++ b/open-sse/services/usage/openrouter.ts @@ -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 = {}; const { dailyQuota, rpmQuota } = buildFreeWindowQuota(connectionId, connection); quotas.free_daily = dailyQuota; diff --git a/tests/unit/openrouter-free-tier-1000-day.test.ts b/tests/unit/openrouter-free-tier-1000-day.test.ts new file mode 100644 index 0000000000..c4ed8a2668 --- /dev/null +++ b/tests/unit/openrouter-free-tier-1000-day.test.ts @@ -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); +}); diff --git a/tests/unit/openrouter-free-window-wiring-6842.test.ts b/tests/unit/openrouter-free-window-wiring-6842.test.ts index ec357da6a2..234ba9e431 100644 --- a/tests/unit/openrouter-free-window-wiring-6842.test.ts +++ b/tests/unit/openrouter-free-window-wiring-6842.test.ts @@ -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); });