mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
# Conflicts: # CHANGELOG.md # Dockerfile # docs/i18n/ar/CHANGELOG.md # docs/i18n/bg/CHANGELOG.md # docs/i18n/bn/CHANGELOG.md # docs/i18n/cs/CHANGELOG.md # docs/i18n/da/CHANGELOG.md # docs/i18n/de/CHANGELOG.md # docs/i18n/es/CHANGELOG.md # docs/i18n/fa/CHANGELOG.md # docs/i18n/fi/CHANGELOG.md # docs/i18n/fr/CHANGELOG.md # docs/i18n/gu/CHANGELOG.md # docs/i18n/he/CHANGELOG.md # docs/i18n/hi/CHANGELOG.md # docs/i18n/hu/CHANGELOG.md # docs/i18n/id/CHANGELOG.md # docs/i18n/it/CHANGELOG.md # docs/i18n/ja/CHANGELOG.md # docs/i18n/ko/CHANGELOG.md # docs/i18n/mr/CHANGELOG.md # docs/i18n/ms/CHANGELOG.md # docs/i18n/nl/CHANGELOG.md # docs/i18n/no/CHANGELOG.md # docs/i18n/phi/CHANGELOG.md # docs/i18n/pl/CHANGELOG.md # docs/i18n/pt-BR/CHANGELOG.md # docs/i18n/pt/CHANGELOG.md # docs/i18n/ro/CHANGELOG.md # docs/i18n/ru/CHANGELOG.md # docs/i18n/sk/CHANGELOG.md # docs/i18n/sv/CHANGELOG.md # docs/i18n/sw/CHANGELOG.md # docs/i18n/ta/CHANGELOG.md # docs/i18n/te/CHANGELOG.md # docs/i18n/th/CHANGELOG.md # docs/i18n/tr/CHANGELOG.md # docs/i18n/uk-UA/CHANGELOG.md # docs/i18n/ur/CHANGELOG.md # docs/i18n/vi/CHANGELOG.md # docs/i18n/zh-CN/CHANGELOG.md # open-sse/config/providerRegistry.ts # open-sse/handlers/chatCore.ts # open-sse/services/usage.ts # open-sse/utils/streamReadiness.ts # scripts/check-docs-sync.mjs # src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx # src/app/(dashboard)/dashboard/providers/[id]/page.tsx # src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx # src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx # src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx # src/app/api/usage/analytics/route.ts # src/i18n/messages/zh-CN.json # src/lib/embeddings/service.ts # src/lib/usage/providerLimits.ts # src/mitm/cert/install.ts # src/shared/constants/providers.ts # src/sse/handlers/chat.ts # tests/unit/compression/rtk-code-stripper.test.ts # tests/unit/usage-service-hardening.test.ts
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
classify429,
|
|
looksLikeQuotaExhausted,
|
|
parseRetryAfter,
|
|
retryAfterFromResponse,
|
|
type FailureKind,
|
|
} from "../../src/shared/utils/classify429.ts";
|
|
|
|
test("classify429: non-429 status returns 'transient'", () => {
|
|
const out: FailureKind = classify429({ status: 500 });
|
|
assert.equal(out, "transient");
|
|
});
|
|
|
|
test("classify429: 429 with no body or hints returns 'rate_limit'", () => {
|
|
assert.equal(classify429({ status: 429 }), "rate_limit");
|
|
assert.equal(classify429({ status: 429, body: "" }), "rate_limit");
|
|
assert.equal(classify429({ status: 429, body: undefined }), "rate_limit");
|
|
});
|
|
|
|
test("classify429: 429 with quota keyword in string body returns 'quota_exhausted'", () => {
|
|
assert.equal(
|
|
classify429({ status: 429, body: "You exceeded your daily limit." }),
|
|
"quota_exhausted"
|
|
);
|
|
assert.equal(
|
|
classify429({ status: 429, body: "Monthly quota reached. Resets on the 1st." }),
|
|
"quota_exhausted"
|
|
);
|
|
assert.equal(
|
|
classify429({ status: 429, body: "Out of credits — top up your account." }),
|
|
"quota_exhausted"
|
|
);
|
|
assert.equal(classify429({ status: 429, body: "plan limit reached" }), "quota_exhausted");
|
|
});
|
|
|
|
test("classify429: 429 with quota keyword in nested object body returns 'quota_exhausted'", () => {
|
|
assert.equal(
|
|
classify429({
|
|
status: 429,
|
|
body: { error: { message: "You have exceeded your monthly quota." } },
|
|
}),
|
|
"quota_exhausted"
|
|
);
|
|
assert.equal(
|
|
classify429({
|
|
status: 429,
|
|
body: { error: { type: "insufficient_quota", message: "..." } },
|
|
}),
|
|
"quota_exhausted"
|
|
);
|
|
});
|
|
|
|
test("classify429: 429 without quota keyword returns 'rate_limit'", () => {
|
|
// Plain rate-limit message — keyword 'rate' alone is NOT in QUOTA_PATTERNS
|
|
// so classifier should default to "rate_limit" for any 429.
|
|
assert.equal(
|
|
classify429({ status: 429, body: "Too many requests. Try again in 60s." }),
|
|
"rate_limit"
|
|
);
|
|
assert.equal(
|
|
classify429({
|
|
status: 429,
|
|
body: "Rate limit reached for requests. Please retry.",
|
|
}),
|
|
"rate_limit"
|
|
);
|
|
});
|
|
|
|
test("looksLikeQuotaExhausted: detects all known keyword variants", () => {
|
|
for (const body of [
|
|
"daily limit exceeded",
|
|
"daily quota reached",
|
|
"per-day limit reached",
|
|
"monthly limit",
|
|
"monthly quota",
|
|
"per-month limit",
|
|
"quota exceeded",
|
|
"exceeded quota",
|
|
"insufficient_quota",
|
|
"billing cap reached",
|
|
"credit exhausted",
|
|
"out of credits",
|
|
"hard limit",
|
|
"hard-limit",
|
|
"plan limit",
|
|
]) {
|
|
assert.equal(looksLikeQuotaExhausted(body), true, `failed for: ${body}`);
|
|
}
|
|
});
|
|
|
|
test("looksLikeQuotaExhausted: rejects empty / null / non-quota text", () => {
|
|
assert.equal(looksLikeQuotaExhausted(undefined), false);
|
|
assert.equal(looksLikeQuotaExhausted(null), false);
|
|
assert.equal(looksLikeQuotaExhausted(""), false);
|
|
assert.equal(looksLikeQuotaExhausted("rate limit, please retry in 60s"), false);
|
|
assert.equal(looksLikeQuotaExhausted("server error 500"), false);
|
|
});
|
|
|
|
test("ambiguous 'daily rate limit' messages classify as quota_exhausted (intentional)", () => {
|
|
// Codex audit LOW: messages combining 'daily' or 'monthly' with 'limit'
|
|
// match the quota regex even when paired with 'rate'. This is intentional
|
|
// because daily/monthly caps semantically warrant a long cooldown — even
|
|
// when the upstream calls them "rate limits". Locking it down here so a
|
|
// future regex tweak doesn't silently change the behavior.
|
|
assert.equal(classify429({ status: 429, body: "daily rate limit exceeded" }), "quota_exhausted");
|
|
assert.equal(
|
|
classify429({ status: 429, body: "monthly rate limit exceeded" }),
|
|
"quota_exhausted"
|
|
);
|
|
});
|
|
|
|
test("parseRetryAfter: integer seconds", () => {
|
|
assert.equal(parseRetryAfter("60"), 60);
|
|
assert.equal(parseRetryAfter("3600"), 3600);
|
|
assert.equal(parseRetryAfter("0"), 0);
|
|
});
|
|
|
|
test("parseRetryAfter: Groq-style relative units", () => {
|
|
// Regression for the parseInt-trap: parseInt("5m", 10) returns 5,
|
|
// which would be wrong (5s instead of 300s). The relative-unit
|
|
// pattern must be checked BEFORE plain integer parse.
|
|
assert.equal(parseRetryAfter("60s"), 60);
|
|
assert.equal(parseRetryAfter("5m"), 300);
|
|
assert.equal(parseRetryAfter("2h"), 7200);
|
|
assert.equal(parseRetryAfter("1H"), 3600);
|
|
});
|
|
|
|
test("parseRetryAfter: HTTP-date in the future", () => {
|
|
const future = new Date(Date.now() + 60_000).toUTCString();
|
|
const secs = parseRetryAfter(future);
|
|
assert.ok(secs !== null);
|
|
assert.ok(secs! >= 50 && secs! <= 65, `expected ~60, got ${secs}`);
|
|
});
|
|
|
|
test("parseRetryAfter: HTTP-date in the past clamps to 0", () => {
|
|
const past = new Date(Date.now() - 5 * 60_000).toUTCString();
|
|
assert.equal(parseRetryAfter(past), 0);
|
|
});
|
|
|
|
test("parseRetryAfter: unparseable returns null", () => {
|
|
assert.equal(parseRetryAfter(undefined), null);
|
|
assert.equal(parseRetryAfter(""), null);
|
|
assert.equal(parseRetryAfter(" "), null);
|
|
assert.equal(parseRetryAfter("not-a-date"), null);
|
|
assert.equal(parseRetryAfter("60xyz"), null);
|
|
});
|
|
|
|
test("retryAfterFromResponse: case-insensitive header lookup", () => {
|
|
assert.equal(retryAfterFromResponse({ headers: { "Retry-After": "30" } }), 30);
|
|
assert.equal(retryAfterFromResponse({ headers: { "retry-after": "45" } }), 45);
|
|
assert.equal(retryAfterFromResponse({ headers: { "RETRY-AFTER": "60s" } }), 60);
|
|
assert.equal(retryAfterFromResponse({ headers: {} }), null);
|
|
assert.equal(retryAfterFromResponse({}), null);
|
|
});
|