mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
b28331307e
commit
9e084e18a7
1
changelog.d/features/6842-openrouter-quota-tracking.md
Normal file
1
changelog.d/features/6842-openrouter-quota-tracking.md
Normal 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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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…
|
||||
|
||||
@@ -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()],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
255
open-sse/services/openrouterFreeWindow.ts
Normal file
255
open-sse/services/openrouterFreeWindow.ts
Normal 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();
|
||||
}
|
||||
357
open-sse/services/openrouterQuotaFetcher.ts
Normal file
357
open-sse/services/openrouterQuotaFetcher.ts
Normal 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);
|
||||
}
|
||||
@@ -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 || "");
|
||||
|
||||
90
open-sse/services/usage/openrouter.ts
Normal file
90
open-sse/services/usage/openrouter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
223
tests/unit/openrouter-free-window-wiring-6842.test.ts
Normal file
223
tests/unit/openrouter-free-window-wiring-6842.test.ts
Normal 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);
|
||||
});
|
||||
345
tests/unit/openrouter-quota-6842.test.ts
Normal file
345
tests/unit/openrouter-quota-6842.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user