From 9e084e18a7ed43cc94ca2ba5362ba4dfeaa1e117 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:11:01 -0300 Subject: [PATCH] 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) --- .../6842-openrouter-quota-tracking.md | 1 + config/quality/file-size-baseline.json | 3 +- docs/architecture/CODEBASE_DOCUMENTATION.md | 2 +- docs/reference/FREE_TIERS.md | 2 +- open-sse/config/providerErrorRules.ts | 22 ++ open-sse/executors/base.ts | 24 ++ open-sse/services/accountFallback.ts | 12 +- open-sse/services/openrouterFreeWindow.ts | 255 +++++++++++++ open-sse/services/openrouterQuotaFetcher.ts | 357 ++++++++++++++++++ open-sse/services/usage.ts | 4 + open-sse/services/usage/openrouter.ts | 90 +++++ src/sse/handlers/chat.ts | 3 +- src/sse/services/auth.ts | 7 +- ...openrouter-free-window-wiring-6842.test.ts | 223 +++++++++++ tests/unit/openrouter-quota-6842.test.ts | 345 +++++++++++++++++ 15 files changed, 1341 insertions(+), 9 deletions(-) create mode 100644 changelog.d/features/6842-openrouter-quota-tracking.md create mode 100644 open-sse/services/openrouterFreeWindow.ts create mode 100644 open-sse/services/openrouterQuotaFetcher.ts create mode 100644 open-sse/services/usage/openrouter.ts create mode 100644 tests/unit/openrouter-free-window-wiring-6842.test.ts create mode 100644 tests/unit/openrouter-quota-6842.test.ts diff --git a/changelog.d/features/6842-openrouter-quota-tracking.md b/changelog.d/features/6842-openrouter-quota-tracking.md new file mode 100644 index 0000000000..5cd075a818 --- /dev/null +++ b/changelog.d/features/6842-openrouter-quota-tracking.md @@ -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). diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d77d9569f9..061dc32e7a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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, diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index ccf2ea0d36..513b144ee0 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -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` | diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index 775586ab0a..936af9e78c 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -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… diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index dde56a53d2..ce5c74e702 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -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([ ["minimax", buildMinimaxRules()], ["minimax-passthrough", buildMinimaxRules()], ["cloudflare-ai", buildCloudflareAiRules()], + ["openrouter", buildOpenrouterRules()], ]); /** diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 3d82de7680..6b4b5d841c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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 && diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 32562fc526..10347ec0f3 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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, }; } diff --git a/open-sse/services/openrouterFreeWindow.ts b/open-sse/services/openrouterFreeWindow.ts new file mode 100644 index 0000000000..4f05061a18 --- /dev/null +++ b/open-sse/services/openrouterFreeWindow.ts @@ -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(); + +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 | null +): string { + const psd = connection?.providerSpecificData as Record | 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, name: string): string | null { + if (typeof (headers as Headers).get === "function") { + return (headers as Headers).get(name); + } + const record = headers as Record; + 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, + 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(); +} diff --git a/open-sse/services/openrouterQuotaFetcher.ts b/open-sse/services/openrouterQuotaFetcher.ts new file mode 100644 index 0000000000..136e0d16aa --- /dev/null +++ b/open-sse/services/openrouterQuotaFetcher.ts @@ -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(); + +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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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 | 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 +): Promise { + 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 +): Promise { + 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); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 1083748e9b..4e24f00322 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 || ""); diff --git a/open-sse/services/usage/openrouter.ts b/open-sse/services/usage/openrouter.ts new file mode 100644 index 0000000000..815dd083ef --- /dev/null +++ b/open-sse/services/usage/openrouter.ts @@ -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) { + 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 | 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 = {}; + 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, + }; +} diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 0bee6caa42..32b5163fef 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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 diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index aff185ef83..3d7bff3d1e 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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, diff --git a/tests/unit/openrouter-free-window-wiring-6842.test.ts b/tests/unit/openrouter-free-window-wiring-6842.test.ts new file mode 100644 index 0000000000..ec357da6a2 --- /dev/null +++ b/tests/unit/openrouter-free-window-wiring-6842.test.ts @@ -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); +}); diff --git a/tests/unit/openrouter-quota-6842.test.ts b/tests/unit/openrouter-quota-6842.test.ts new file mode 100644 index 0000000000..20b4818f40 --- /dev/null +++ b/tests/unit/openrouter-quota-6842.test.ts @@ -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); +});