OmniRoute/tests/unit/error-classification.test.mjs
Diego Rodrigues de Sa e Souza 70a4d38d04
Some checks are pending
CI / Lint (push) Waiting to run
CI / Security Audit (push) Waiting to run
CI / Build (push) Waiting to run
CI / Build-1 (push) Waiting to run
CI / Unit Tests (push) Blocked by required conditions
CI / Unit Tests-1 (push) Blocked by required conditions
CI / Coverage (push) Blocked by required conditions
CI / E2E Tests (push) Blocked by required conditions
CI / Integration Tests (push) Blocked by required conditions
CI / Security Tests (push) Blocked by required conditions
Publish to Docker Hub / Build and Push Docker (multi-arch) (push) Waiting to run
Release v3.4.0 (Integration) (#861)
* test(settings): add unit tests for debugMode and hiddenSidebarItems

Tests cover:
- PATCH debugMode=true/false
- PATCH hiddenSidebarItems with array values
- Combined updates with both fields

* test(e2e): add Playwright tests for settings toggles

Tests cover:
- Debug mode toggle on/off
- Sidebar visibility toggle
- Settings persistence after page reload

* fix(tests): address code review issues

- Unit tests: fix async/await for getSettings, use direct db functions
- E2E tests: remove conditional logic, use Playwright auto-waiting assertions

* feat(logging): unify request log retention and artifacts

* docs: add dashboard settings toggles to CONTRIBUTING

Add section documenting:
- Debug Mode toggle (Settings → Advanced)
- Sidebar Visibility toggle (Settings → General)

* fix(cache): only inject prompt_cache_key for supported providers

Only inject prompt_cache_key for providers that support prompt caching
(Claude, Anthropic, ZAI, Qwen, DeepSeek). This fixes issue #848 where
NVIDIA API rejected the parameter.

* fix(model-sync): log only channel-level model changes

* feat(providers): add 4 free models to opencode-zen

* feat(providers): add explicit contextLength for opencode-zen free models

* feat(providers): add contextLength for all opencode-zen models

* feat: Improve the Chinese translation

* fix: preserve client cache_control for all Claude-protocol providers

Previously, the cache control preservation logic only recognized a
hardcoded list of providers (claude, anthropic, zai, qwen, deepseek).
This caused OmniRoute to inject its own cache_control markers for
Claude-protocol providers not in that list (bailian-coding-plan, glm,
minimax, minimax-cn, etc.), overwriting the client's cache markers.

The fix checks both:
1. Known caching providers list (existing behavior)
2. Whether targetFormat === 'claude' (all Claude-protocol providers)

This ensures all Claude-compatible providers properly preserve client
cache_control headers when appropriate (Claude Code client, deterministic
routing, etc.).

Also removes unused CacheStatsCard from settings/components (duplicate
of the one in cache/ page).

Fixes cache token calculation for GLM, Minimax, and other Claude-compatible providers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: pure passthrough for Claude→Claude when cache_control preserved

The Claude passthrough path round-trips through OpenAI format
(claude→openai→claude) for structural normalization. This strips
cache_control markers from every content block since OpenAI format
has no equivalent, causing ~42k cache creation tokens per request
with zero cache reads.

When preserveCacheControl is true (Claude Code client, "always"
setting, or deterministic combo), skip the round-trip entirely and
forward the body as-is. Claude Code sends well-formed Messages API
payloads — the normalization was only needed for non-Code clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restore CacheStatsCard — was not a duplicate

The first commit incorrectly deleted CacheStatsCard from
settings/components/ as a "duplicate". It's the only copy — both
settings/page.tsx and cache/page.tsx import from this location.

Restored the i18n-ized version from main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(429): parse long quota reset times from error body

- Parse XhYmZs format from antigravity error messages (e.g., 27h41m36s)
- Dynamic retry-after threshold (60s default) instead of hardcoded 10s
- Add parseRetryFromErrorText() in accountFallback.ts for body parsing
- Fix 403 'verify your account' to trigger permanent deactivation
- Add keyword matching for 'quota will reset', 'exhausted capacity'
- Add unit tests for retry parsing and keyword matching

Fixes #858 (Antigravity 429 handling)
Fixes #832 (Qwen quota 429 - same underlying bug)

* chore: bump version to v3.4.0-dev

* fix(migrations): rename 013 to 014 to avoid collision with v3.3.11

* chore(docs): update CHANGELOG for v3.4.0 integrations

* fix: Claude token refresh, Antigravity quota, and 429 rate-limit handling

- Fix Claude OAuth token refresh to use form-urlencoded format (standard OAuth2)
- Add anthropic-beta header required by Claude OAuth API
- Switch Antigravity quota to use retrieveUserQuota API (same as Gemini CLI)
- Parse quota reset time for all providers (not just Antigravity)
- Add quota reset keywords to error classifier
- Cap maximum retry time at 24 hours to prevent infinite wait

Closes #836, #857, #858, #832

* fix(dashboard): resolve /dashboard/limits hanging UI with 70+ accounts via chunk parallelization (#784)

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: R.D. <rogerproself@gmail.com>
Co-authored-by: kang-heewon <heewon.dev@gmail.com>
Co-authored-by: gmw <rorschach1167@qq.com>
Co-authored-by: tombii <github@tombii.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-03-31 10:22:52 -03:00

190 lines
7.9 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
const { checkFallbackError, getProviderProfile, parseRetryFromErrorText } =
await import("../../open-sse/services/accountFallback.ts");
const { getProviderCategory } = await import("../../open-sse/config/providerRegistry.ts");
const { COOLDOWN_MS, PROVIDER_PROFILES, RateLimitReason } =
await import("../../open-sse/config/constants.ts");
// ─── Provider Category Tests ────────────────────────────────────────────────
test("getProviderCategory: OAuth providers return 'oauth'", () => {
assert.equal(getProviderCategory("claude"), "oauth");
assert.equal(getProviderCategory("codex"), "oauth");
assert.equal(getProviderCategory("github"), "oauth");
assert.equal(getProviderCategory("antigravity"), "oauth");
assert.equal(getProviderCategory("cursor"), "oauth");
assert.equal(getProviderCategory("kiro"), "oauth");
assert.equal(getProviderCategory("gemini-cli"), "oauth");
assert.equal(getProviderCategory("cline"), "oauth");
});
test("getProviderCategory: API key providers return 'apikey'", () => {
assert.equal(getProviderCategory("groq"), "apikey");
assert.equal(getProviderCategory("fireworks"), "apikey");
assert.equal(getProviderCategory("cerebras"), "apikey");
assert.equal(getProviderCategory("nvidia"), "apikey");
assert.equal(getProviderCategory("openai"), "apikey");
assert.equal(getProviderCategory("anthropic"), "apikey");
assert.equal(getProviderCategory("deepseek"), "apikey");
assert.equal(getProviderCategory("gemini"), "apikey");
});
test("getProviderCategory: unknown provider defaults to 'apikey'", () => {
assert.equal(getProviderCategory("nonexistent"), "apikey");
});
// ─── Provider Profile Tests ─────────────────────────────────────────────────
test("getProviderProfile: OAuth provider returns oauth profile", () => {
const profile = getProviderProfile("claude");
assert.deepEqual(profile, PROVIDER_PROFILES.oauth);
});
test("getProviderProfile: API provider returns apikey profile", () => {
const profile = getProviderProfile("groq");
assert.deepEqual(profile, PROVIDER_PROFILES.apikey);
});
test("getProviderProfile: profiles have different thresholds", () => {
const oauth = getProviderProfile("claude");
const api = getProviderProfile("groq");
assert.ok(
oauth.circuitBreakerThreshold < api.circuitBreakerThreshold,
"OAuth should have lower threshold than API"
);
assert.ok(
oauth.maxBackoffLevel > api.maxBackoffLevel,
"OAuth should have higher max backoff level"
);
});
// ─── Exponential Backoff for Transient Errors ───────────────────────────────
test("502 transient: exponential backoff 5s → 10s → 20s → 40s → 60s (capped)", () => {
const cooldowns = [];
for (let level = 0; level < 6; level++) {
const result = checkFallbackError(502, "", level, null, null);
cooldowns.push(result.cooldownMs);
assert.equal(result.shouldFallback, true);
assert.equal(result.newBackoffLevel, level + 1);
assert.equal(result.reason, RateLimitReason.SERVER_ERROR);
}
// Without provider: uses COOLDOWN_MS.transientInitial (5s) as base
assert.equal(cooldowns[0], COOLDOWN_MS.transientInitial); // 5s
assert.equal(cooldowns[1], COOLDOWN_MS.transientInitial * 2); // 10s
assert.equal(cooldowns[2], COOLDOWN_MS.transientInitial * 4); // 20s
assert.equal(cooldowns[3], COOLDOWN_MS.transientInitial * 8); // 40s
// Level 4: 5s * 16 = 80s → capped at 60s
assert.equal(cooldowns[4], COOLDOWN_MS.transientMax); // 60s
assert.equal(cooldowns[5], COOLDOWN_MS.transientMax); // 60s (stays capped)
});
test("502 with OAuth provider: uses oauth profile transientCooldown", () => {
const result = checkFallbackError(502, "", 0, null, "claude");
assert.equal(result.cooldownMs, PROVIDER_PROFILES.oauth.transientCooldown); // 5s
assert.equal(result.newBackoffLevel, 1);
});
test("502 with API provider: uses apikey profile transientCooldown", () => {
const result = checkFallbackError(502, "", 0, null, "groq");
assert.equal(result.cooldownMs, PROVIDER_PROFILES.apikey.transientCooldown); // 3s
assert.equal(result.newBackoffLevel, 1);
});
test("502 with API provider: backoff respects apikey maxBackoffLevel", () => {
const maxLevel = PROVIDER_PROFILES.apikey.maxBackoffLevel;
const result = checkFallbackError(502, "", maxLevel, null, "groq");
assert.equal(result.newBackoffLevel, maxLevel); // Capped
});
test("502 with OAuth provider: backoff respects oauth maxBackoffLevel", () => {
const maxLevel = PROVIDER_PROFILES.oauth.maxBackoffLevel;
const result = checkFallbackError(502, "", maxLevel, null, "claude");
assert.equal(result.newBackoffLevel, maxLevel); // Capped
});
// ─── Other Error Types Still Work ───────────────────────────────────────────
test("429 rate limit: still uses quota-based exponential backoff", () => {
const result = checkFallbackError(429, "", 0, null, "groq");
assert.equal(result.shouldFallback, true);
assert.equal(result.newBackoffLevel, 1);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
test("401 auth error: still uses flat cooldown, no backoff", () => {
const result = checkFallbackError(401, "", 0, null, "groq");
assert.equal(result.shouldFallback, true);
assert.equal(result.cooldownMs, COOLDOWN_MS.unauthorized);
assert.equal(result.newBackoffLevel, undefined);
});
test("400 bad request: still returns shouldFallback false", () => {
const result = checkFallbackError(400, "", 0, null, "groq");
assert.equal(result.shouldFallback, false);
});
// ─── T07: Retry Time Parsing from Error Text ─────────────────────────────────
test("parseRetryFromErrorText: parses 27h41m36s format", () => {
const result = parseRetryFromErrorText("Your quota will reset after 27h41m36s");
assert.equal(result, 27 * 3600 * 1000 + 41 * 60 * 1000 + 36 * 1000);
});
test("parseRetryFromErrorText: parses 2h30m format", () => {
const result = parseRetryFromErrorText("quota will reset after 2h30m");
assert.equal(result, 2 * 3600 * 1000 + 30 * 60 * 1000);
});
test("parseRetryFromErrorText: parses 45m format", () => {
const result = parseRetryFromErrorText("reset after 45m");
assert.equal(result, 45 * 60 * 1000);
});
test("parseRetryFromErrorText: parses 30s format", () => {
const result = parseRetryFromErrorText("reset after 30s");
assert.equal(result, 30 * 1000);
});
test("parseRetryFromErrorText: returns null for invalid format", () => {
const result = parseRetryFromErrorText("invalid error message");
assert.equal(result, null);
});
test("parseRetryFromErrorText: parses will reset after variant", () => {
const result = parseRetryFromErrorText("quota will reset after 5h");
assert.equal(result, 5 * 3600 * 1000);
});
// ─── T06: Keyword Matching for Long Cooldowns ────────────────────────────────
test("quota will reset keyword triggers long cooldown from body", () => {
const result = checkFallbackError(
429,
"Your quota will reset after 27h41m36s",
0,
null,
"antigravity",
null
);
assert.equal(result.shouldFallback, true);
assert.ok(result.cooldownMs > 60_000, "cooldownMs should be > 60s");
assert.equal(result.newBackoffLevel, 0, "backoffLevel should reset to 0");
});
test("exhausted your capacity keyword triggers long cooldown", () => {
const result = checkFallbackError(
429,
"You have exhausted your capacity. Your quota will reset after 2h",
0,
null,
"antigravity",
null
);
assert.equal(result.shouldFallback, true);
assert.ok(result.cooldownMs > 60_000);
});