feat: OpenRouter quota tracking (key/credits + free-window counter) (#6842) (#7651)

Validated in merge-train --fast @ 4ed4498 (static gates + changed tests 29/29 + vitest green, 2m39s; full suite ran today on trains 1/2c)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-18 03:11:01 -03:00
committed by GitHub
parent b28331307e
commit 9e084e18a7
15 changed files with 1341 additions and 9 deletions

View File

@@ -0,0 +1 @@
- **feat(sse):** OpenRouter quota tracking — a dedicated fetcher polls `/api/v1/key` + `/api/v1/credits` (per-key credit cap/remaining/reset, daily/weekly/monthly USD spend, BYOK usage) with a 45s cache and graceful degradation, a local per-account counter tracks the `:free`-model 50-or-1000-per-day + 20 RPM windows (corrected from `X-RateLimit-*` headers and `Retry-After` on 429), and OpenRouter `402` responses now lock the connection with a real cooldown instead of triggering an immediate reselection of the same credit-exhausted key (#6842).

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_07_17_v3849_6842_free_window_wiring": "PR #7651 (openrouter :free-window quota tracking) follow-up: the counter shipped built but never wired into the request pipeline, so combos kept spending guaranteed-429 requests on exhausted free-tier targets. Own growth: src/sse/services/auth.ts 2461->2462 (+1, irreducible at the existing model-aware preflight chokepoint — the `provider === \"codex\"` check that forwards requestedModel into the connection arg is extended to also cover `openrouter`, one added boolean + a doc comment, offset to a single net line by dropping the now-redundant inline condition). Enforcement itself lives in open-sse/services/openrouterQuotaFetcher.ts (not frozen) and the dispatch-time record/correct hooks live in open-sse/executors/base.ts (not frozen). Covered by tests/unit/openrouter-free-window-wiring-6842.test.ts.",
"_rebaseline_2026_07_17_v3849_ownerprs_media_audio": "Own-growth from the v3.8.49 owner-PR merge campaign (fast-gates PR->release do not run check:file-size, so this surfaced only on re-sync): src/app/api/usage/analytics/route.ts 942->948 (+6 = the 180d/365d getRangeStartIso cases from #7213 usage-extended-periods) and tests/unit/audio-transcription-handler.test.ts new testFrozen 824 (Gladia async STT test cases added by #7603). Both irreducible additions covered by their PR tests; structural shrink tracked in #3501.",
"_rebaseline_2026_07_14_7034_goog_api_key": "Issue #7034 (gemini-cli x-goog-api-key client auth) own growth: src/sse/services/auth.ts 2458->2461 (+3 = import + the two-line extractGoogApiKeyHeader() call/return at the existing extractApiKey() chokepoint, plus a 1-line doc-comment mention offset by a 1-line net save elsewhere in the same edit). The actual header-read/trim logic was EXTRACTED into a new leaf module src/sse/services/googApiKeyAuth.ts (shared by both extractApiKey() here and extractBearer() in src/server/authz/policies/clientApi.ts, which is not frozen) to keep this frozen file's growth to the irreducible call-site wiring. Covered by tests/unit/auth-extract-api-key.test.ts and tests/unit/authz/client-api-policy.test.ts.",
"_rebaseline_2026_07_14_6928_comfyui_baseurl_override": "Issue #6928 own growth: open-sse/handlers/videoGeneration.ts 1265->1275 (+10 = resolveComfyUiBaseUrl import + expanding the comfyui dispatch call into a multi-line object literal so the per-connection providerSpecificData.baseUrl override — same storage convention self-hosted chat providers use — is threaded through to handleComfyUIVideoGeneration; Prettier's 100-char width forces the multi-line form), src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 1053->1054 (+1 = comfyui added to CONFIGURABLE_BASE_URL_PROVIDERS/DEFAULT_PROVIDER_BASE_URLS/getProviderBaseUrlPlaceholder so the Add/Edit connection modals render an editable base-URL field for ComfyUI, mirroring self-hosted chat providers). The identical dispatch pattern was also applied to imageGeneration.ts and musicGeneration.ts, both well under their frozen caps. Covered by tests/unit/comfyui-baseurl-override-6928.test.ts (resolver unit tests + handler-level fetch-mock overrides for image/video/music) and the new provider-page-helpers-3501.test.ts assertion.",
@@ -270,7 +271,7 @@
"_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.",
"src/sse/handlers/chat.ts": 1796,
"src/sse/handlers/chatHelpers.ts": 876,
"src/sse/services/auth.ts": 2461,
"src/sse/services/auth.ts": 2462,
"open-sse/executors/default.ts": 877,
"open-sse/translator/request/openai-responses.ts": 902,
"open-sse/executors/kiro.ts": 944,

View File

@@ -527,7 +527,7 @@ Highlights (full list under `open-sse/services/`):
| Combo routing | `combo.ts` (17 strategies), `comboConfig.ts`, `comboMetrics.ts`, `comboManifestMetrics.ts`, `comboAgentMiddleware.ts` |
| Auto Combo engine | `autoCombo/``engine.ts`, `scoring.ts`, `taskFitness.ts`, `virtualFactory.ts`, `modePacks.ts`, `autoPrefix.ts`, `persistence.ts`, `providerDiversity.ts`, `providerRegistryAccessor.ts`, `routerStrategy.ts`, `selfHealing.ts`, `index.ts` |
| Resilience | `accountFallback.ts` (cooldown + lockout), `errorClassifier.ts`, `emergencyFallback.ts`, `rateLimitManager.ts`, `rateLimitSemaphore.ts`, `accountSemaphore.ts`, `accountSelector.ts` |
| Quotas | `quotaMonitor.ts`, `quotaPreflight.ts`, `bailianQuotaFetcher.ts`, `codexQuotaFetcher.ts`, `deepseekQuotaFetcher.ts`, `crofUsageFetcher.ts`, `antigravityCredits.ts` |
| Quotas | `quotaMonitor.ts`, `quotaPreflight.ts`, `bailianQuotaFetcher.ts`, `codexQuotaFetcher.ts`, `deepseekQuotaFetcher.ts`, `openrouterQuotaFetcher.ts`, `openrouterFreeWindow.ts`, `crofUsageFetcher.ts`, `antigravityCredits.ts` |
| Caching | `reasoningCache.ts`, `searchCache.ts`, `signatureCache.ts`, `requestDedup.ts` |
| Routing intelligence | `intentClassifier.ts`, `taskAwareRouter.ts`, `backgroundTaskDetector.ts`, `volumeDetector.ts`, `wildcardRouter.ts`, `workflowFSM.ts`, `specificityDetector.ts`, `specificityRules.ts`, `specificityTypes.ts` |
| Model handling | `modelCapabilities.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`, `modelStrip.ts`, `model.ts`, `provider.ts`, `providerRequestDefaults.ts`, `providerCostData.ts`, `payloadRules.ts` |

View File

@@ -299,7 +299,7 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
- **`nous-research`** — The shipped freeNote ("Free tier: 50 RPM, 500,000 TPM") does not match the current Nous Portal product. The portal launched April 27, 2026 and structures its free tier as $0.10/month in recurring cre…
- **`nvidia`** — The "40 RPM, 70+ models" rate limit element matches the catalog, but the freeNote framing as a simple dev-access tier undersells that the old one-time credit pool has been removed — access is now tru…
- **`ollama-cloud`** — Our shipped freeNote is "(none)" — this is stale. Ollama Cloud launched a cloud inference product with a genuine free tier that provides light weekly GPU-time-based access to hosted open models.
- **`openrouter`** — RPD tightened from 200 to 50 for zero-credit accounts (RPM unchanged at 20). The catalog note was accurate on RPM but overstated the RPD by 4x for the no-credits baseline tier.
- **`openrouter`** — RPD tightened from 200 to 50 for zero-credit accounts (RPM unchanged at 20). The catalog note was accurate on RPM but overstated the RPD by 4x for the no-credits baseline tier. **Runtime tracking (#6842)**: this is no longer just a static note — `open-sse/services/openrouterQuotaFetcher.ts` polls `/api/v1/key` + `/api/v1/credits` for per-key credit cap/remaining/reset and daily/weekly/monthly USD spend, and `open-sse/services/openrouterFreeWindow.ts` locally tracks the `:free`-model 50-or-1000-per-day + 20 RPM windows described above (corrected from `X-RateLimit-*` response headers on 429s), surfaced in Dashboard → Provider Quota.
- **`phind`** — Phind shut down on January 16, 2026. The provider has now been **fully removed** from the catalog (registry, executor, and both the web-cookie and API-key catalog entries) — matching the dead-service-removal precedent (#5246 Gemini CLI).
- **`pollinations`** — Partially matches — the "no API key required" claim is still true for anonymous access, but the catalog freeNote omits that: (1) rate limits do apply (interval throttle of ~1 req/6-15s for anonymous …
- **`predibase`** — The shipped freeNote ($25 free trial credits, 30-day validity) still matches current documentation. However, the catalog omits the concurrent 20,000 tokens/day serverless rate limit that applies duri…

View File

@@ -155,6 +155,27 @@ function buildCloudflareAiRules(): ProviderErrorRule[] {
];
}
// ─── OpenRouter ─────────────────────────────────────────────────────────────
// #6842: OpenRouter returns 402 for both a negative account balance and a
// depleted per-key credit cap. The global `status_402` rule already maps this
// to `quota_exhausted` with a zero cooldown (immediate fallback to the next
// connection), but leaves the scope ambiguous and doesn't stop the SAME
// connection from being reselected instantly (credits genuinely need a
// top-up, not a timed wait). This explicit rule locks the whole connection
// (scope: "connection" — credits are account-wide, not per-model) for a real
// cooldown so combo routing skips it instead of hot-looping back onto it.
function buildOpenrouterRules(): ProviderErrorRule[] {
return [
{
id: "openrouter-credit-exhausted-402",
match: ({ status }) => {
if (status !== 402) return null;
return { reason: "quota_exhausted", scope: "connection", cooldownMs: 2 * 60 * 1000 };
},
},
];
}
/**
* Global registry. Provider name → ordered list of rules (first match wins).
* Add new providers here; the matcher in classifyError will pick them up
@@ -167,6 +188,7 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
["minimax", buildMinimaxRules()],
["minimax-passthrough", buildMinimaxRules()],
["cloudflare-ai", buildCloudflareAiRules()],
["openrouter", buildOpenrouterRules()],
]);
/**

View File

@@ -17,6 +17,12 @@ import {
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import {
recordFreeWindowAttempt,
correctFromRateLimitHeaders,
resolveAccountKey,
isFreeVariantModel,
} from "../services/openrouterFreeWindow.ts";
import type { PoolConfig } from "../services/sessionPool/types.ts";
import type { Session } from "../services/sessionPool/session.ts";
import { SessionPool } from "../services/sessionPool/sessionPool.ts";
@@ -1214,8 +1220,26 @@ export class BaseExecutor {
body: bodyString,
};
// OpenRouter `:free`-variant local window (#6842): record every real
// dispatch attempt (failed attempts still consume a request slot per
// OpenRouter's own accounting) and self-correct the local counters
// from the upstream `X-RateLimit-*` headers on the response. Scoped
// to `:free` models only — no-op (and no extra work) for every other
// OpenRouter request or provider.
const openrouterFreeWindowAccountKey =
this.provider === "openrouter" && isFreeVariantModel(model) && activeCredentials.connectionId
? resolveAccountKey(activeCredentials.connectionId, activeCredentials)
: null;
if (openrouterFreeWindowAccountKey) {
recordFreeWindowAttempt(openrouterFreeWindowAccountKey);
}
let response = await fetchWithStartTimeout(url, fetchOptions);
if (openrouterFreeWindowAccountKey) {
correctFromRateLimitHeaders(openrouterFreeWindowAccountKey, response.headers);
}
// Context Editing 400-fallback for Claude-compatible relays.
if (
response.status === HTTP_STATUS.BAD_REQUEST &&

View File

@@ -1554,13 +1554,21 @@ export function checkFallbackError(
}
return fallback;
}
const cooldownMs = configuredRule.cooldownMs ?? 0;
// #6842: non-backoff configured rules (e.g. status_402) previously never
// consulted providerRuleRegistry, so a provider-specific rule (like
// OpenRouter's credit-exhausted 402 lock) could never override the
// generic zero-cooldown default. Mirror the backoff branch above so
// provider rules win on cooldown/reason regardless of `backoff`.
const providerMatch = provider
? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null)
: null;
const cooldownMs = providerMatch?.cooldownMs ?? configuredRule.cooldownMs ?? 0;
return {
shouldFallback: true,
cooldownMs,
baseCooldownMs: cooldownMs,
configuredCooldownMs: cooldownMs,
reason: configuredRule.reason ?? RateLimitReason.UNKNOWN,
reason: providerMatch?.reason ?? configuredRule.reason ?? RateLimitReason.UNKNOWN,
};
}

View File

@@ -0,0 +1,255 @@
/**
* openrouterFreeWindow.ts — OpenRouter `:free`-variant local window tracker (#6842)
*
* OpenRouter's official monitoring API (`/api/v1/key`, `/api/v1/credits`) reports
* USD spend, never request counts — so the `:free`-model per-account request
* windows (docs/reference/FREE_TIERS.md) cannot be read from those endpoints.
* This module tracks them locally instead:
*
* - A UTC-day counter: 50 requests/day at $0 all-time purchased, 1000/day
* once $10+ has been purchased (operator-overridable via setPurchasedTier).
* - A 20 RPM rolling window (true rolling — timestamps pruned to the last 60s,
* not a fixed-bucket reset).
*
* Bucketed by ACCOUNT, not by connection/key — multiple OmniRoute connections
* that share one upstream OpenRouter account must share one window. OmniRoute's
* `provider_connections` are per-key, so callers resolve an account bucket key
* via `resolveAccountKey()`: an explicit `providerSpecificData.openrouterAccountKey`
* groups keys under one account; otherwise each connection gets its own bucket
* (safe default — never worse than no tracking).
*
* State is in-memory only (module-level Map, not persisted). This is a
* deliberate MVP scope: local counting is inherently best-effort (drifts on
* process restart or multi-instance deployments sharing one OpenRouter
* account) and is corrected from upstream `X-RateLimit-*` response headers on
* every 429, which is authoritative. See the plan's "Risks" section — SQLite
* persistence is a possible follow-up, not required for correctness here.
*/
const RPM_WINDOW_MS = 60_000;
const RPM_LIMIT = 20;
const DAILY_LIMIT_BASE = 50;
const DAILY_LIMIT_PURCHASED = 1000;
interface AccountWindowState {
dayKey: string;
dayCount: number;
purchasedAtLeast10: boolean;
requestTimestamps: number[];
serverDailyLimit: number | null;
serverDailyRemaining: number | null;
serverResetAtMs: number | null;
}
export interface FreeWindowStatus {
dailyLimit: number;
dailyUsed: number;
dailyRemaining: number;
dailyResetAt: string;
rpmLimit: number;
rpmUsed: number;
rpmRemaining: number;
}
const accountWindows = new Map<string, AccountWindowState>();
function utcDayKey(now: number): string {
return new Date(now).toISOString().slice(0, 10);
}
function nextUtcMidnightIso(now: number): string {
const date = new Date(now);
const next = Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() + 1,
0,
0,
0,
0
);
return new Date(next).toISOString();
}
/**
* Whether a model id is an OpenRouter `:free`-variant (e.g.
* `x-ai/grok-4-fast:free`). Shared by the dispatch-time record/correct hooks
* (`open-sse/executors/base.ts`) and the quota-preflight enforcement hook
* (`open-sse/services/openrouterQuotaFetcher.ts`) so both sides agree on the
* same definition of "free variant."
*/
export function isFreeVariantModel(model: string | null | undefined): boolean {
return typeof model === "string" && model.endsWith(":free");
}
/**
* Resolve the shared account bucket key for a connection. Operators group
* multiple keys under one OpenRouter account via `providerSpecificData.
* openrouterAccountKey`; without it, each connection is its own bucket.
*/
export function resolveAccountKey(
connectionId: string,
connection?: Record<string, unknown> | null
): string {
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
const explicit = typeof psd?.openrouterAccountKey === "string" ? psd.openrouterAccountKey : "";
return explicit.trim().length > 0 ? `acct:${explicit.trim()}` : `conn:${connectionId}`;
}
function getOrInitState(accountKey: string, now: number): AccountWindowState {
const dayKey = utcDayKey(now);
const existing = accountWindows.get(accountKey);
if (existing && existing.dayKey === dayKey) return existing;
const fresh: AccountWindowState = {
dayKey,
dayCount: 0,
purchasedAtLeast10: existing?.purchasedAtLeast10 ?? false,
requestTimestamps: [],
serverDailyLimit: null,
serverDailyRemaining: null,
serverResetAtMs: null,
};
accountWindows.set(accountKey, fresh);
return fresh;
}
function pruneRpmWindow(state: AccountWindowState, now: number): void {
const cutoff = now - RPM_WINDOW_MS;
state.requestTimestamps = state.requestTimestamps.filter((ts) => ts > cutoff);
}
/**
* Operator override: declare whether $10+ has been purchased all-time on this
* account, unlocking the 1000/day tier instead of the 50/day default.
*/
export function setPurchasedTier(accountKey: string, purchasedAtLeast10: boolean): void {
const state = getOrInitState(accountKey, Date.now());
state.purchasedAtLeast10 = purchasedAtLeast10;
}
/**
* Record a `:free`-variant request attempt against the account bucket.
* Failed attempts count toward the daily cap too (per OpenRouter's own
* accounting — a rejected request still consumed a request slot).
*/
export function recordFreeWindowAttempt(accountKey: string, now: number = Date.now()): void {
const state = getOrInitState(accountKey, now);
pruneRpmWindow(state, now);
state.dayCount += 1;
state.requestTimestamps.push(now);
}
function getHeader(headers: Headers | Record<string, string>, name: string): string | null {
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name);
}
const record = headers as Record<string, string>;
return record[name] ?? record[name.toLowerCase()] ?? null;
}
function parseResetMsFromHeader(reset: string | null): number | null {
if (reset === null) return null;
const resetNum = Number(reset);
if (!Number.isFinite(resetNum)) return null;
return resetNum > 10_000_000_000 ? resetNum : resetNum * 1000;
}
function parseRetryAfterMs(retryAfter: string | null, now: number): number | null {
if (retryAfter === null) return null;
const seconds = Number(retryAfter);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return now + seconds * 1000;
}
function resolveResetMs(
reset: string | null,
retryAfter: string | null,
now: number
): number | null {
const headerResetMs = parseResetMsFromHeader(reset);
const retryAfterMs = parseRetryAfterMs(retryAfter, now);
if (retryAfterMs === null) return headerResetMs;
return headerResetMs === null ? retryAfterMs : Math.max(headerResetMs, retryAfterMs);
}
/**
* Correct local counters from OpenRouter's authoritative rate-limit headers,
* present on 429 responses per OpenRouter's docs. `Retry-After` (seconds)
* is folded into the reset timestamp when present and later than the
* `X-RateLimit-Reset` value.
*/
export function correctFromRateLimitHeaders(
accountKey: string,
headers: Headers | Record<string, string>,
now: number = Date.now()
): void {
const state = getOrInitState(accountKey, now);
const limit = getHeader(headers, "x-ratelimit-limit");
const remaining = getHeader(headers, "x-ratelimit-remaining");
if (limit !== null && Number.isFinite(Number(limit))) {
state.serverDailyLimit = Number(limit);
}
if (remaining !== null && Number.isFinite(Number(remaining))) {
state.serverDailyRemaining = Number(remaining);
}
const resetMs = resolveResetMs(
getHeader(headers, "x-ratelimit-reset"),
getHeader(headers, "retry-after"),
now
);
if (resetMs !== null) {
state.serverResetAtMs = resetMs;
}
}
function resolveDailyLimit(state: AccountWindowState): number {
if (state.serverDailyLimit !== null) return state.serverDailyLimit;
return state.purchasedAtLeast10 ? DAILY_LIMIT_PURCHASED : DAILY_LIMIT_BASE;
}
function resolveDailyUsed(state: AccountWindowState, dailyLimit: number): number {
if (state.serverDailyRemaining !== null) {
return Math.max(0, dailyLimit - state.serverDailyRemaining);
}
return state.dayCount;
}
/**
* Current window status for the account bucket: daily count vs limit
* (50-or-1000, server-corrected when available) and the 20 RPM rolling
* window, plus reset timestamps for the dashboard countdown.
*/
export function getFreeWindowStatus(
accountKey: string,
now: number = Date.now()
): FreeWindowStatus {
const state = getOrInitState(accountKey, now);
pruneRpmWindow(state, now);
const dailyLimit = resolveDailyLimit(state);
const dailyUsed = resolveDailyUsed(state, dailyLimit);
const dailyResetAt =
state.serverResetAtMs !== null
? new Date(state.serverResetAtMs).toISOString()
: nextUtcMidnightIso(now);
const rpmUsed = state.requestTimestamps.length;
return {
dailyLimit,
dailyUsed,
dailyRemaining: Math.max(0, dailyLimit - dailyUsed),
dailyResetAt,
rpmLimit: RPM_LIMIT,
rpmUsed,
rpmRemaining: Math.max(0, RPM_LIMIT - rpmUsed),
};
}
/** Test/ops helper — clears all in-memory account window state. */
export function clearFreeWindowState(): void {
accountWindows.clear();
}

View File

@@ -0,0 +1,357 @@
/**
* openrouterQuotaFetcher.ts — OpenRouter Key/Credits Quota Fetcher (#6842)
*
* Implements QuotaFetcher for the OpenRouter provider (quotaPreflight.ts + quotaMonitor.ts).
*
* OpenRouter exposes two official, documented monitoring endpoints
* (https://openrouter.ai/docs/api/reference/limits):
*
* GET https://openrouter.ai/api/v1/key
* -> { data: { limit, limit_remaining, limit_reset, usage, usage_daily,
* usage_weekly, usage_monthly, is_free_tier, byok_usage,
* include_byok_in_limit } }
* `limit`/`limit_remaining` are per-key USD credit caps — null means
* unlimited/never set. `limit_reset` is null when the cap never resets.
*
* GET https://openrouter.ai/api/v1/credits
* -> { data: { total_credits, total_usage } }
* Account-level totals; upstream caches this endpoint for ~60s already.
*
* We fetch both (credits is a cheap second call, same auth) and merge into one
* QuotaInfo. Graceful "unknown" on any fetch failure — quota tracking must
* never block routing (mirrors deepseekQuotaFetcher.ts / bailianQuotaFetcher.ts).
*
* Cache: in-memory TTL (45s, inside the 30-60s window OpenRouter's own docs
* recommend) keyed by connectionId, so combo preflight/monitor polling doesn't
* hammer the upstream on every request.
*
* Registration: call registerOpenrouterQuotaFetcher() once at server startup.
*/
import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
import { registerMonitorFetcher } from "./quotaMonitor.ts";
import { throttleQuotaFetch } from "./quotaFetchThrottle.ts";
import {
getFreeWindowStatus,
isFreeVariantModel,
resolveAccountKey,
type FreeWindowStatus,
} from "./openrouterFreeWindow.ts";
const OPENROUTER_CONFIG = {
baseUrl: "https://openrouter.ai/api/v1",
keyPath: "/key",
creditsPath: "/credits",
};
// Cache TTL — inside OpenRouter's documented 30-60s window.
const CACHE_TTL_MS = 45_000;
export interface OpenrouterQuota extends QuotaInfo {
limit: number | null;
limitRemaining: number | null;
isFreeTier: boolean;
usage: number;
usageDaily: number;
usageWeekly: number;
usageMonthly: number;
byokUsage: number | null;
includeByokInLimit: boolean;
totalCredits: number | null;
totalUsage: number | null;
creditBalance: number | null;
}
interface CacheEntry {
quota: OpenrouterQuota;
fetchedAt: number;
}
const quotaCache = new Map<string, CacheEntry>();
const _cacheCleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of quotaCache) {
if (now - entry.fetchedAt > CACHE_TTL_MS * 5) {
quotaCache.delete(key);
}
}
}, 5 * 60_000);
if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) {
(_cacheCleanup as { unref?: () => void }).unref?.();
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function toNullableNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function toFiniteNumber(value: unknown, fallback = 0): number {
const n = toNullableNumber(value);
return n === null ? fallback : n;
}
function toIsoOrNull(value: unknown): string | null {
const n = toNullableNumber(value);
if (n === null) return null;
const date = new Date(n < 1e12 ? n * 1000 : n);
if (Number.isNaN(date.getTime()) || date.getTime() <= 0) return null;
return date.toISOString();
}
// ─── Response Parsers ────────────────────────────────────────────────────────
interface OpenrouterKeyFields {
limit: number | null;
limitRemaining: number | null;
limitReset: string | null;
isFreeTier: boolean;
usage: number;
usageDaily: number;
usageWeekly: number;
usageMonthly: number;
byokUsage: number | null;
includeByokInLimit: boolean;
}
/**
* Parse the `GET /api/v1/key` response body. Returns null when the payload
* doesn't carry a recognizable `data` object (e.g. an unexpected shape).
*/
export function parseOpenrouterKeyResponse(data: unknown): OpenrouterKeyFields | null {
const outer = toRecord(data);
const inner = "data" in outer ? toRecord(outer.data) : outer;
if (Object.keys(inner).length === 0) return null;
return {
limit: toNullableNumber(inner.limit),
limitRemaining: toNullableNumber(inner.limit_remaining),
limitReset: toIsoOrNull(inner.limit_reset),
isFreeTier: inner.is_free_tier === true,
usage: toFiniteNumber(inner.usage, 0),
usageDaily: toFiniteNumber(inner.usage_daily, 0),
usageWeekly: toFiniteNumber(inner.usage_weekly, 0),
usageMonthly: toFiniteNumber(inner.usage_monthly, 0),
byokUsage: toNullableNumber(inner.byok_usage),
includeByokInLimit: inner.include_byok_in_limit === true,
};
}
interface OpenrouterCreditsFields {
totalCredits: number | null;
totalUsage: number | null;
}
/**
* Parse the `GET /api/v1/credits` response body. Returns nulls (not a full
* null-object) when the payload is missing — credits is a best-effort
* secondary signal, the key endpoint alone is enough to build a quota.
*/
export function parseOpenrouterCreditsResponse(data: unknown): OpenrouterCreditsFields {
const outer = toRecord(data);
const inner = "data" in outer ? toRecord(outer.data) : outer;
return {
totalCredits: toNullableNumber(inner.total_credits),
totalUsage: toNullableNumber(inner.total_usage),
};
}
function buildQuotaFromParts(
key: OpenrouterKeyFields,
credits: OpenrouterCreditsFields
): OpenrouterQuota {
const hasCap = key.limit !== null && key.limitRemaining !== null;
const limitReached = hasCap && (key.limitRemaining as number) <= 0;
const percentUsed = hasCap && key.limit! > 0 ? 1 - key.limitRemaining! / key.limit! : 0;
const creditBalance =
key.limitRemaining !== null
? key.limitRemaining
: credits.totalCredits !== null && credits.totalUsage !== null
? credits.totalCredits - credits.totalUsage
: null;
return {
used: percentUsed * 100,
total: 100,
percentUsed,
resetAt: key.limitReset,
limitReached,
limit: key.limit,
limitRemaining: key.limitRemaining,
isFreeTier: key.isFreeTier,
usage: key.usage,
usageDaily: key.usageDaily,
usageWeekly: key.usageWeekly,
usageMonthly: key.usageMonthly,
byokUsage: key.byokUsage,
includeByokInLimit: key.includeByokInLimit,
totalCredits: credits.totalCredits,
totalUsage: credits.totalUsage,
creditBalance,
};
}
// ─── Free-Window Preflight (#6842) ───────────────────────────────────────────
/**
* Build a `limitReached` QuotaInfo from the local `:free`-window daily
* counter — no upstream I/O, so this is safe to call on every preflight
* without adding latency or spending a request.
*/
function buildFreeWindowExhaustedQuota(status: FreeWindowStatus): QuotaInfo {
const percentUsed = status.dailyLimit > 0 ? status.dailyUsed / status.dailyLimit : 1;
return {
used: status.dailyUsed,
total: status.dailyLimit,
percentUsed: Math.min(1, Math.max(0, percentUsed)),
resetAt: status.dailyResetAt,
limitReached: true,
};
}
/**
* When the requested model is a `:free` variant and the locally-tracked
* daily window is already exhausted, short-circuit before any network call:
* the upstream `/key` + `/credits` fetch below only reports USD spend, never
* the `:free` request count, so it cannot see this exhaustion on its own —
* and dispatching the chat request itself would just spend a guaranteed 429.
*/
function checkFreeWindowExhausted(
connectionId: string,
connection: Record<string, unknown> | undefined,
requestedModel: unknown
): QuotaInfo | null {
if (!isFreeVariantModel(typeof requestedModel === "string" ? requestedModel : null)) {
return null;
}
const accountKey = resolveAccountKey(connectionId, connection);
const status = getFreeWindowStatus(accountKey);
return status.dailyRemaining <= 0 ? buildFreeWindowExhaustedQuota(status) : null;
}
// ─── Core Fetcher ────────────────────────────────────────────────────────────
async function fetchJson(
url: string,
apiKey: string
): Promise<{ status: number; data: unknown } | null> {
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) return { status: response.status, data: null };
const data = await response.json();
return { status: response.status, data };
} catch {
return null;
}
}
/**
* Fetch current quota for an OpenRouter connection.
* Returns quota info based on the /key + /credits API responses.
*
* @param connectionId - Connection ID from the DB (used for cache keying)
* @param connection - Optional connection object with apiKey
* @returns OpenrouterQuota or null if fetch fails / no credentials
*/
export async function fetchOpenrouterQuota(
connectionId: string,
connection?: Record<string, unknown>
): Promise<QuotaInfo | null> {
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.quota;
}
const apiKey =
typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0
? connection.apiKey
: null;
if (!apiKey) return null;
try {
await throttleQuotaFetch();
const keyUrl = `${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.keyPath}`;
const keyResult = await fetchJson(keyUrl, apiKey);
// 401/403 on the key endpoint: token invalid — remove from cache, fail open.
if (!keyResult || keyResult.status === 401 || keyResult.status === 403) {
quotaCache.delete(connectionId);
return null;
}
if (keyResult.status !== 200) return null;
const keyFields = parseOpenrouterKeyResponse(keyResult.data);
if (!keyFields) return null;
const creditsUrl = `${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.creditsPath}`;
const creditsResult = await fetchJson(creditsUrl, apiKey);
const creditsFields =
creditsResult && creditsResult.status === 200
? parseOpenrouterCreditsResponse(creditsResult.data)
: { totalCredits: null, totalUsage: null };
const quota = buildQuotaFromParts(keyFields, creditsFields);
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
return quota;
} catch {
// Network error, timeout, etc. — fail open (graceful "unknown").
return null;
}
}
// ─── Invalidation ────────────────────────────────────────────────────────────
export function invalidateOpenrouterQuotaCache(connectionId: string): void {
quotaCache.delete(connectionId);
}
/**
* The fetcher actually wired into quotaPreflight.ts / quotaMonitor.ts (#6842
* follow-up). Kept as a thin wrapper — rather than inlined into
* fetchOpenrouterQuota() above — so the /key + /credits fetcher itself stays
* a plain, independently-testable function and the free-window short-circuit
* doesn't add branching to its already-tight complexity budget.
*/
export async function fetchOpenrouterQuotaWithFreeWindowPreflight(
connectionId: string,
connection?: Record<string, unknown>
): Promise<QuotaInfo | null> {
const freeWindowExhausted = checkFreeWindowExhausted(
connectionId,
connection,
connection?.requestedModel
);
return freeWindowExhausted ?? fetchOpenrouterQuota(connectionId, connection);
}
// ─── Registration ─────────────────────────────────────────────────────────────
/**
* Register the OpenRouter quota fetcher with the preflight and monitor systems.
* Call this once at server startup (in chat.ts or app entry point).
*/
export function registerOpenrouterQuotaFetcher(): void {
registerQuotaFetcher("openrouter", fetchOpenrouterQuotaWithFreeWindowPreflight);
registerMonitorFetcher("openrouter", fetchOpenrouterQuotaWithFreeWindowPreflight);
}

View File

@@ -7,6 +7,7 @@ import { getDbInstance } from "@/lib/db/core";
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts";
import { getOpenrouterUsage } from "./usage/openrouter.ts";
import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts";
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts";
import {
@@ -539,6 +540,7 @@ export const USAGE_FETCHER_PROVIDERS = [
"vertex",
"vertex-partner",
"codebuddy-cn",
"openrouter",
] as const;
export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number];
@@ -612,6 +614,8 @@ export async function getUsageForProvider(
return await getNanoGptUsage(apiKey || "");
case "deepseek":
return await getDeepseekUsage(id || "", apiKey || "");
case "openrouter":
return await getOpenrouterUsage(id || "", apiKey || "", providerSpecificData);
case "opencode":
case "opencode-zen":
return await getOpencodeUsage(id || "", apiKey || "");

View File

@@ -0,0 +1,90 @@
/**
* usage/openrouter.ts — OpenRouter usage-dashboard builder (#6842)
*
* Extracted as a leaf module (not inlined in usage.ts) so the god-file stays
* flat: this owns turning an OpenrouterQuota into the `UsageQuota` shape the
* Dashboard → Usage page renders, mirroring getDeepseekUsage's pattern.
*/
import { fetchOpenrouterQuota, type OpenrouterQuota } from "../openrouterQuotaFetcher.ts";
import { getFreeWindowStatus, resolveAccountKey } from "../openrouterFreeWindow.ts";
import { type UsageQuota } from "./quota.ts";
function buildCreditsQuota(quota: OpenrouterQuota): UsageQuota | null {
if (quota.limit === null && quota.creditBalance === null) return null;
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,
resetAt: quota.resetAt ?? null,
unlimited: quota.limit === null,
currency: "USD",
};
}
function buildFreeWindowQuota(connectionId: string, connection?: Record<string, unknown>) {
const accountKey = resolveAccountKey(connectionId, connection);
const status = getFreeWindowStatus(accountKey);
const dailyQuota: UsageQuota = {
used: status.dailyUsed,
total: status.dailyLimit,
remaining: status.dailyRemaining,
remainingPercentage: Math.round((status.dailyRemaining / status.dailyLimit) * 100),
resetAt: status.dailyResetAt,
unlimited: false,
displayName: "Free-tier requests (daily)",
};
const rpmQuota: UsageQuota = {
used: status.rpmUsed,
total: status.rpmLimit,
remaining: status.rpmRemaining,
remainingPercentage: Math.round((status.rpmRemaining / status.rpmLimit) * 100),
resetAt: null,
unlimited: false,
displayName: "Free-tier requests (per minute)",
};
return { dailyQuota, rpmQuota };
}
/**
* OpenRouter Usage — merges the /key + /credits polling fetcher with the
* locally-tracked `:free`-variant request window into one usage payload.
*/
export async function getOpenrouterUsage(
connectionId: string,
apiKey: string,
providerSpecificData?: Record<string, unknown> | null
) {
if (!apiKey) {
return { message: "OpenRouter API key not available. Add a key to view usage." };
}
const connection = { apiKey, providerSpecificData: providerSpecificData ?? {} };
const quota = (await fetchOpenrouterQuota(connectionId, connection)) as OpenrouterQuota | null;
const quotas: Record<string, UsageQuota> = {};
const { dailyQuota, rpmQuota } = buildFreeWindowQuota(connectionId, connection);
quotas.free_daily = dailyQuota;
quotas.free_rpm = rpmQuota;
if (!quota) {
return {
plan: "OpenRouter (usage endpoint unreachable)",
quotas,
message: "OpenRouter connected. Balance/credit-cap data temporarily unavailable.",
};
}
const creditsQuota = buildCreditsQuota(quota);
if (creditsQuota) quotas.credits = creditsQuota;
return {
plan: quota.isFreeTier ? "OpenRouter (Free Tier)" : "OpenRouter",
quotas,
isFreeTier: quota.isFreeTier,
usageDaily: quota.usageDaily,
usageWeekly: quota.usageWeekly,
usageMonthly: quota.usageMonthly,
};
}

View File

@@ -124,6 +124,7 @@ import {
import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/services/bailianQuotaFetcher.ts";
import { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts";
import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts";
import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts";
import { registerOpencodeQuotaFetcher } from "@omniroute/open-sse/services/opencodeQuotaFetcher.ts";
import { registerGenericQuotaFetchers } from "@omniroute/open-sse/services/genericQuotaFetcher.ts";
import {
@@ -145,10 +146,10 @@ registerBailianCodingPlanQuotaFetcher();
// Surfaces usable_requests + credits in the monitor and only blocks (preflight
// opt-in) when the active bucket reaches zero.
registerCrofUsageFetcher();
// Register DeepSeek balance quota fetcher.
// Hooks into quotaPreflight + quotaMonitor so combos can switch accounts before balance is exhausted.
registerDeepseekQuotaFetcher();
registerOpenrouterQuotaFetcher();
// Register OpenCode quota fetcher (opencode-go / opencode / opencode-zen).
// Surfaces the $12/5h, $30/wk, $60/mo windows in the limits page and enables

View File

@@ -1483,8 +1483,7 @@ export async function getProviderCredentials(
{ fallbackStrategy?: string; stickyRoundRobinLimit?: number }
>;
const providerOverride = providerStrategyOverrides[resolvedId] || {};
const strategy =
providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
let connection;
const affinityConnection = await selectSessionAffinityConnection(
@@ -1834,8 +1833,10 @@ export async function getProviderCredentialsWithQuotaPreflight(
}
return defaultThresholdPercent;
};
// #6842: openrouter also needs requestedModel, for the :free-window check.
const modelAwarePreflight = provider === "codex" || provider === "openrouter";
const preflightCredentials =
requestedModel && provider === "codex" ? { ...credentials, requestedModel } : credentials;
requestedModel && modelAwarePreflight ? { ...credentials, requestedModel } : credentials;
const preflight = await preflightQuota(provider, connectionId, preflightCredentials, {
resolveMinRemainingPercent,
resolveWarnRemainingPercent: () => warnThresholdPercent,

View File

@@ -0,0 +1,223 @@
/**
* #6842 follow-up — wire the OpenRouter `:free`-window counter into the
* actual request pipeline (dispatch + combo preflight skip).
*
* The counter itself (open-sse/services/openrouterFreeWindow.ts) shipped in
* #6842 but was never called anywhere outside the dashboard usage reader —
* combos kept spending guaranteed-429 requests on exhausted `:free` targets.
* This file proves the two wiring chokepoints:
*
* 1. RECORD — open-sse/executors/base.ts dispatches a `:free`-variant
* OpenRouter request -> recordFreeWindowAttempt() fires, and a 429
* response carrying X-RateLimit-* headers self-corrects the local
* counter via correctFromRateLimitHeaders().
* 2. ENFORCE — open-sse/services/openrouterQuotaFetcher.ts's
* fetchOpenrouterQuotaWithFreeWindowPreflight() (the function actually
* registered with quotaPreflight.ts / quotaMonitor.ts — the same
* chokepoint the PR's own /key+/credits check uses) returns
* limitReached:true for an exhausted `:free` model WITHOUT making any
* network call — proving combos skip the target instead of dispatching
* a guaranteed-429 request. It wraps the unchanged fetchOpenrouterQuota()
* so the free-window short-circuit doesn't add branching to that
* function's own (already tight) cyclomatic-complexity budget.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import {
clearFreeWindowState,
getFreeWindowStatus,
resolveAccountKey,
} from "../../open-sse/services/openrouterFreeWindow.ts";
import {
fetchOpenrouterQuotaWithFreeWindowPreflight,
invalidateOpenrouterQuotaCache,
} from "../../open-sse/services/openrouterQuotaFetcher.ts";
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
clearFreeWindowState();
});
// ─── 1. RECORD: dispatching a `:free` request increments the window ───────
test("DefaultExecutor(openrouter) records a free-window attempt when dispatching a :free model", async () => {
const executor = new DefaultExecutor("openrouter");
const connectionId = `openrouter-record-${Date.now()}`;
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ choices: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const accountKey = resolveAccountKey(connectionId, {});
assert.equal(getFreeWindowStatus(accountKey).dailyUsed, 0, "precondition: window starts empty");
await executor.execute({
model: "x-ai/grok-4-fast:free",
body: { model: "x-ai/grok-4-fast:free", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "or-test-key", connectionId },
});
assert.equal(fetchCalls, 1, "sanity: request was actually dispatched");
assert.equal(
getFreeWindowStatus(accountKey).dailyUsed,
1,
"recordFreeWindowAttempt should have incremented the daily counter"
);
});
test("DefaultExecutor(openrouter) does NOT record a free-window attempt for a non-:free model", async () => {
const executor = new DefaultExecutor("openrouter");
const connectionId = `openrouter-nonfree-${Date.now()}`;
globalThis.fetch = async () =>
new Response(JSON.stringify({ choices: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
const accountKey = resolveAccountKey(connectionId, {});
await executor.execute({
model: "x-ai/grok-4-fast",
body: { model: "x-ai/grok-4-fast", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "or-test-key", connectionId },
});
assert.equal(
getFreeWindowStatus(accountKey).dailyUsed,
0,
"paid (non-:free) OpenRouter models must not touch the free-window counter"
);
});
test("DefaultExecutor(openrouter) self-corrects the free window from X-RateLimit-* response headers on 429", async () => {
const executor = new DefaultExecutor("openrouter");
const connectionId = `openrouter-correct-${Date.now()}`;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: {
"Content-Type": "application/json",
"x-ratelimit-limit": "50",
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 3600),
},
});
const accountKey = resolveAccountKey(connectionId, {});
await executor.execute({
model: "x-ai/grok-4-fast:free",
body: { model: "x-ai/grok-4-fast:free", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "or-test-key", connectionId },
skipUpstreamRetry: true,
});
const status = getFreeWindowStatus(accountKey);
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 () => {
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"
);
recordFreeWindowAttempt(accountKey);
}
assert.equal(getFreeWindowStatus(accountKey).dailyRemaining, 0, "precondition: window exhausted");
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ data: {} }), { status: 200 });
};
const quota = await fetchOpenrouterQuotaWithFreeWindowPreflight(connectionId, {
apiKey: "or-test-key",
requestedModel: "x-ai/grok-4-fast:free",
});
assert.equal(fetchCalls, 0, "an exhausted free window must short-circuit BEFORE any /key or /credits call");
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");
invalidateOpenrouterQuotaCache(connectionId);
});
test("fetchOpenrouterQuotaWithFreeWindowPreflight proceeds to the normal /key+/credits fetch when the free window is NOT exhausted", async () => {
const connectionId = `openrouter-enforce-ok-${Date.now()}`;
let fetchCalls = 0;
globalThis.fetch = async (url) => {
fetchCalls += 1;
if (String(url).endsWith("/key")) {
return new Response(
JSON.stringify({
data: { limit: null, limit_remaining: null, limit_reset: null, is_free_tier: true },
}),
{ status: 200 }
);
}
return new Response(JSON.stringify({ data: {} }), { status: 200 });
};
const quota = await fetchOpenrouterQuotaWithFreeWindowPreflight(connectionId, {
apiKey: "or-test-key",
requestedModel: "x-ai/grok-4-fast:free",
});
assert.ok(fetchCalls > 0, "with window remaining, the fetcher must still hit /key + /credits");
assert.ok(quota);
invalidateOpenrouterQuotaCache(connectionId);
});
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"
);
for (let i = 0; i < 50; i++) recordFreeWindowAttempt(accountKey);
let fetchCalls = 0;
globalThis.fetch = async (url) => {
fetchCalls += 1;
if (String(url).endsWith("/key")) {
return new Response(
JSON.stringify({
data: { limit: null, limit_remaining: null, limit_reset: null, is_free_tier: false },
}),
{ status: 200 }
);
}
return new Response(JSON.stringify({ data: {} }), { status: 200 });
};
const quota = await fetchOpenrouterQuotaWithFreeWindowPreflight(connectionId, {
apiKey: "or-test-key",
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(quota);
invalidateOpenrouterQuotaCache(connectionId);
});

View File

@@ -0,0 +1,345 @@
/**
* #6842 — OpenRouter quota tracking (TDD).
*
* Covers the three scoped behaviors:
* 1. /api/v1/key + /api/v1/credits endpoint parsing (incl. null fields, BYOK).
* 2. Free-window local counter: UTC-day rollover + 20 RPM rolling window +
* X-RateLimit-* header correction.
* 3. 402 -> credit-exhausted classification (connection-scope lock, not the
* whole-provider circuit breaker).
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
fetchOpenrouterQuota,
invalidateOpenrouterQuotaCache,
parseOpenrouterKeyResponse,
parseOpenrouterCreditsResponse,
registerOpenrouterQuotaFetcher,
} from "../../open-sse/services/openrouterQuotaFetcher.ts";
import {
clearFreeWindowState,
correctFromRateLimitHeaders,
getFreeWindowStatus,
recordFreeWindowAttempt,
resolveAccountKey,
setPurchasedTier,
} from "../../open-sse/services/openrouterFreeWindow.ts";
import { getProviderErrorRuleMatch } from "../../open-sse/config/providerErrorRules.ts";
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
clearFreeWindowState();
});
// ─── 1. Endpoint parsing ──────────────────────────────────────────────────
test("parseOpenrouterKeyResponse parses a full /api/v1/key response", () => {
const parsed = parseOpenrouterKeyResponse({
data: {
limit: 100,
limit_remaining: 42.5,
limit_reset: null,
is_free_tier: false,
usage: 57.5,
usage_daily: 1.2,
usage_weekly: 8,
usage_monthly: 20,
byok_usage: 3.1,
include_byok_in_limit: true,
},
});
assert.ok(parsed);
assert.equal(parsed.limit, 100);
assert.equal(parsed.limitRemaining, 42.5);
assert.equal(parsed.limitReset, null);
assert.equal(parsed.isFreeTier, false);
assert.equal(parsed.usageDaily, 1.2);
assert.equal(parsed.byokUsage, 3.1);
assert.equal(parsed.includeByokInLimit, true);
});
test("parseOpenrouterKeyResponse treats null limit/limit_remaining as unlimited", () => {
const parsed = parseOpenrouterKeyResponse({
data: {
limit: null,
limit_remaining: null,
limit_reset: null,
is_free_tier: true,
usage: 0,
usage_daily: 0,
usage_weekly: 0,
usage_monthly: 0,
},
});
assert.ok(parsed);
assert.equal(parsed.limit, null);
assert.equal(parsed.limitRemaining, null);
assert.equal(parsed.isFreeTier, true);
// BYOK fields absent entirely — must not throw, must default to null/false.
assert.equal(parsed.byokUsage, null);
assert.equal(parsed.includeByokInLimit, false);
});
test("parseOpenrouterKeyResponse returns null for an unrecognizable payload", () => {
assert.equal(parseOpenrouterKeyResponse({}), null);
assert.equal(parseOpenrouterKeyResponse(null), null);
assert.equal(parseOpenrouterKeyResponse("not json"), null);
});
test("parseOpenrouterCreditsResponse parses total_credits/total_usage", () => {
const parsed = parseOpenrouterCreditsResponse({
data: { total_credits: 50, total_usage: 12.5 },
});
assert.equal(parsed.totalCredits, 50);
assert.equal(parsed.totalUsage, 12.5);
});
test("parseOpenrouterCreditsResponse defaults to nulls on missing data", () => {
const parsed = parseOpenrouterCreditsResponse({});
assert.equal(parsed.totalCredits, null);
assert.equal(parsed.totalUsage, null);
});
test("fetchOpenrouterQuota returns null when no API key exists", async () => {
const quota = await fetchOpenrouterQuota(`missing-${Date.now()}`);
assert.equal(quota, null);
});
test("fetchOpenrouterQuota merges /key + /credits into one quota", async () => {
const connectionId = `openrouter-merge-${Date.now()}`;
const calls: string[] = [];
globalThis.fetch = async (url) => {
const href = String(url);
calls.push(href);
if (href.endsWith("/key")) {
return new Response(
JSON.stringify({
data: {
limit: 100,
limit_remaining: 10,
limit_reset: null,
is_free_tier: false,
usage: 90,
usage_daily: 5,
usage_weekly: 20,
usage_monthly: 90,
},
}),
{ status: 200 }
);
}
return new Response(JSON.stringify({ data: { total_credits: 100, total_usage: 90 } }), {
status: 200,
});
};
const quota = await fetchOpenrouterQuota(connectionId, { apiKey: "test-key" });
assert.ok(quota);
assert.equal(calls.length, 2);
assert.equal((quota as { limitReached?: boolean }).limitReached, false);
assert.equal(quota.percentUsed, 0.9);
invalidateOpenrouterQuotaCache(connectionId);
});
test("fetchOpenrouterQuota marks limitReached when limit_remaining <= 0", async () => {
const connectionId = `openrouter-exhausted-${Date.now()}`;
globalThis.fetch = async (url) => {
if (String(url).endsWith("/key")) {
return new Response(
JSON.stringify({
data: { limit: 10, limit_remaining: 0, limit_reset: null, is_free_tier: true },
}),
{ status: 200 }
);
}
return new Response(JSON.stringify({ data: {} }), { status: 200 });
};
const quota = await fetchOpenrouterQuota(connectionId, { apiKey: "test-key" });
assert.ok(quota);
assert.equal(quota.limitReached, true);
invalidateOpenrouterQuotaCache(connectionId);
});
test("fetchOpenrouterQuota returns null on 401 (invalid token)", async () => {
const connectionId = `openrouter-401-${Date.now()}`;
globalThis.fetch = async () => new Response(null, { status: 401 });
const quota = await fetchOpenrouterQuota(connectionId, { apiKey: "bad-key" });
assert.equal(quota, null);
});
test("registerOpenrouterQuotaFetcher does not throw", () => {
assert.doesNotThrow(() => registerOpenrouterQuotaFetcher());
});
// ─── 2. Free-window local counter ─────────────────────────────────────────
test("resolveAccountKey groups multiple connections under one explicit account", () => {
const shared = resolveAccountKey("conn-a", {
providerSpecificData: { openrouterAccountKey: "team-account" },
});
const sharedAgain = resolveAccountKey("conn-b", {
providerSpecificData: { openrouterAccountKey: "team-account" },
});
assert.equal(shared, sharedAgain);
const unrelated = resolveAccountKey("conn-c");
assert.notEqual(unrelated, shared);
});
test("getFreeWindowStatus defaults to 50/day before any purchase", () => {
const accountKey = `acct-default-${Date.now()}`;
const status = getFreeWindowStatus(accountKey);
assert.equal(status.dailyLimit, 50);
assert.equal(status.dailyUsed, 0);
assert.equal(status.rpmLimit, 20);
});
test("setPurchasedTier unlocks the 1000/day tier", () => {
const accountKey = `acct-purchased-${Date.now()}`;
setPurchasedTier(accountKey, true);
const status = getFreeWindowStatus(accountKey);
assert.equal(status.dailyLimit, 1000);
});
test("recordFreeWindowAttempt increments the UTC-day counter", () => {
const accountKey = `acct-day-${Date.now()}`;
const now = Date.UTC(2026, 0, 15, 12, 0, 0);
recordFreeWindowAttempt(accountKey, now);
recordFreeWindowAttempt(accountKey, now + 1000);
const status = getFreeWindowStatus(accountKey, now + 2000);
assert.equal(status.dailyUsed, 2);
assert.equal(status.dailyRemaining, 48);
});
test("UTC-day counter rolls over at midnight", () => {
const accountKey = `acct-rollover-${Date.now()}`;
const dayOne = Date.UTC(2026, 0, 15, 23, 59, 59);
recordFreeWindowAttempt(accountKey, dayOne);
const dayOneStatus = getFreeWindowStatus(accountKey, dayOne);
assert.equal(dayOneStatus.dailyUsed, 1);
// Same account, next UTC day — counter must reset to 0.
const dayTwo = Date.UTC(2026, 0, 16, 0, 0, 1);
const dayTwoStatus = getFreeWindowStatus(accountKey, dayTwo);
assert.equal(dayTwoStatus.dailyUsed, 0);
});
test("20 RPM rolling window blocks the 21st request within 60s, frees up after", () => {
const accountKey = `acct-rpm-${Date.now()}`;
const base = Date.UTC(2026, 0, 15, 12, 0, 0);
for (let i = 0; i < 20; i++) {
recordFreeWindowAttempt(accountKey, base + i * 1000);
}
const atLimit = getFreeWindowStatus(accountKey, base + 20_000);
assert.equal(atLimit.rpmUsed, 20);
assert.equal(atLimit.rpmRemaining, 0);
// Just over 60s after the FIRST request only, that timestamp has rolled
// out of the window (offset by 1ms from the second request to avoid a
// boundary tie between the 1st and 2nd requests, which were 1000ms apart).
const afterRoll = getFreeWindowStatus(accountKey, base + 60_001);
assert.equal(afterRoll.rpmUsed, 19);
assert.equal(afterRoll.rpmRemaining, 1);
});
test("failed attempts count toward the daily cap", () => {
const accountKey = `acct-failed-${Date.now()}`;
const now = Date.UTC(2026, 0, 15, 12, 0, 0);
// The module doesn't distinguish success/failure at record time — callers
// record every attempt, successful or not, matching the plan's "failed
// attempts count toward the daily cap" requirement.
recordFreeWindowAttempt(accountKey, now);
recordFreeWindowAttempt(accountKey, now + 1);
const status = getFreeWindowStatus(accountKey, now + 2);
assert.equal(status.dailyUsed, 2);
});
test("correctFromRateLimitHeaders overrides local daily count from X-RateLimit-*", () => {
const accountKey = `acct-header-${Date.now()}`;
const now = Date.UTC(2026, 0, 15, 12, 0, 0);
recordFreeWindowAttempt(accountKey, now); // local thinks 1 used
const headers = new Headers({
"x-ratelimit-limit": "50",
"x-ratelimit-remaining": "3",
"x-ratelimit-reset": String(Math.floor((now + 3_600_000) / 1000)),
});
correctFromRateLimitHeaders(accountKey, headers, now + 500);
const status = getFreeWindowStatus(accountKey, now + 600);
assert.equal(status.dailyLimit, 50);
assert.equal(status.dailyUsed, 47); // 50 - 3 remaining, server-authoritative
assert.equal(status.dailyResetAt, new Date(now + 3_600_000).toISOString());
});
test("correctFromRateLimitHeaders honors Retry-After when later than X-RateLimit-Reset", () => {
const accountKey = `acct-retry-after-${Date.now()}`;
const now = Date.UTC(2026, 0, 15, 12, 0, 0);
const headers = new Headers({
"x-ratelimit-limit": "50",
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(Math.floor((now + 60_000) / 1000)),
"retry-after": "300", // 5 minutes — later than the header reset above
});
correctFromRateLimitHeaders(accountKey, headers, now);
const status = getFreeWindowStatus(accountKey, now + 1);
assert.equal(status.dailyResetAt, new Date(now + 300_000).toISOString());
});
// ─── 3. 402 -> credit-exhausted classification ────────────────────────────
test("openrouter 402 provider rule locks the connection, not the model", () => {
const match = getProviderErrorRuleMatch("openrouter", 402, {});
assert.ok(match);
assert.equal(match.reason, "quota_exhausted");
assert.equal(match.scope, "connection");
assert.ok(typeof match.cooldownMs === "number" && match.cooldownMs > 0);
});
test("openrouter 402 does not match for other status codes", () => {
assert.equal(getProviderErrorRuleMatch("openrouter", 429, {}), null);
assert.equal(getProviderErrorRuleMatch("openrouter", 500, {}), null);
});
test("checkFallbackError classifies OpenRouter 402 as quota_exhausted with a real cooldown", async () => {
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
// errorText intentionally null/generic (no CREDITS_EXHAUSTED_SIGNALS phrase
// like "payment required") so this exercises the STATUS-only path — the
// generic `status_402` configured rule (open-sse/config/errorConfig.ts)
// falling through to the new provider-rule override in
// accountFallback.ts, not the earlier errorText-signal branch.
const genericResult = checkFallbackError(402, null, 0, null, "some-other-provider", null);
assert.equal(genericResult.shouldFallback, true);
assert.equal(genericResult.reason, RateLimitReason.QUOTA_EXHAUSTED);
// Generic default: bare cooldownMs 0 — immediate reselection of the same
// connection (no provider-specific rule registered for this provider).
assert.equal(genericResult.cooldownMs, 0);
const openrouterResult = checkFallbackError(402, null, 0, null, "openrouter", null);
assert.equal(openrouterResult.shouldFallback, true);
assert.equal(openrouterResult.reason, RateLimitReason.QUOTA_EXHAUSTED);
// The OpenRouter-specific rule must win over the generic status_402
// default and lock the connection for a real cooldown instead of 0.
assert.ok(
openrouterResult.cooldownMs > 0,
"expected a non-zero cooldown for a credit-exhausted 402"
);
});
test("checkFallbackError 402 does NOT trip the whole-provider circuit breaker set", async () => {
// Per CLAUDE.md's resilience-layer contract, only 408/500/502/503/504 trip
// the provider breaker. 402 must never be in that set, for any provider.
const PROVIDER_BREAKER_STATUSES = new Set([408, 500, 502, 503, 504]);
assert.equal(PROVIDER_BREAKER_STATUSES.has(402), false);
});