mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* chore(release): open v3.8.20 development cycle * fix(images): prefer bare combos over image aliases (#3527) Integrated into release/v3.8.20 * fix(translator): map Codex local_shell tool (#3534) Integrated into release/v3.8.20 * fix(usage): make opencode-go quota fetcher fail-open instead of throwing 500 (#3522) Integrated into release/v3.8.20 * Fix Runtime page breaker state rendering (#3533) Integrated into release/v3.8.20 * Expose provider breaker degradation threshold setting (#3535) Integrated into release/v3.8.20 * fix(executor): strip provider prefix from versioned built-in tool model field (#3532) Integrated into release/v3.8.20 * feat(providers): add Claude Fable 5 support (#3524) Integrated into release/v3.8.20 * feat(resilience): add global provider cooldown tracking to prevent combo re-walking (#3556) Integrated into release/v3.8.20 (default OFF, opt-in) * fix(translator): scope thoughtSignature bypass to Antigravity/CLI only (#3560) Integrated into release/v3.8.20. Co-authored-by: Six7Day <six7day@gmail.com> * fix(routing): normalize thinking:disabled for combo-substituted models that reject it (#3554) (#3563) Integrated into release/v3.8.20 * fix(usage): accept 0/empty budget limits so the dashboard can save and clear (#3537) (#3564) Integrated into release/v3.8.20 * docs(changelog): credit @Six7Day for #3560 thoughtSignature fix (#3414) The #3560 squash co-author trailer landed inline (unparsed by GitHub), so add an explicit CHANGELOG credit ensuring @Six7Day (original #3414) and @oyi77 are on the public record for the Gemini thoughtSignature fix. * fix(gamification): dedup badge unlock via user_badges so events don't re-fire every request (#3472) (#3565) Integrated into release/v3.8.20 * fix(routing): pass through 'auto' keyword on codex /v1/responses instead of rewriting to codex/auto (#3509) (#3566) Integrated into release/v3.8.20 * fix(cli-tools): normalize apiKey null in guide-settings schema so cloud-mode config saves (#3552) (#3567) Integrated into release/v3.8.20 * fix(catalog): reclassify PublicAI from keyless to one-time-initial (requires API key) (#3558) (#3568) Integrated into release/v3.8.20 * fix(gemini-web): surface missing Playwright browser as actionable 503 + cooldown hint, not a retryable 500 loop (#3516) (#3570) Integrated into release/v3.8.20 * fix(security): sanitize raw err.message in web executors + embeddings/search response bodies (Rule #12) (#3494, #3495) (#3573) Integrated into release/v3.8.20 * fix(dashboard): point CustomHostsManager + FeatureFlagsGrid at real routes (#3486, #3487) (#3574) Integrated into release/v3.8.20 * chore(providers): remove dead krutrim entry (#3483) + docs(api): fix agent-bridge per-agent state route (#3489) (#3575) Integrated into release/v3.8.20 * docs(api): correct API_REFERENCE.md paths for skills/plugins/admin/cache/acp/system-info (#3497) (#3577) Integrated into release/v3.8.20 * fix(proxy): drive SOCKS5 UI option from runtime ENABLE_SOCKS5_PROXY, not build-time NEXT_PUBLIC (#3508) (#3579) Integrated into release/v3.8.20 * fix(playground): filter playground models by node prefix so custom-endpoint models appear (#3505) (#3581) Integrated into release/v3.8.20 * fix(usage): show an informative message instead of a blank Kiro quota card when no usage breakdown (#3506) (#3582) Integrated into release/v3.8.20 * docs(changelog): add the #3506 Kiro quota entry (missed in #3582 due to a stale-base CHANGELOG anchor) (#3583) Integrated into release/v3.8.20 * fix(auto-update): use stable PROJECT_ROOT walker, not frozen process.cwd() (#3561) Integrated into release/v3.8.20. Auto-update PROJECT_ROOT now uses a stable __dirname-anchored upward walker instead of the no-op process.cwd() resolver. * fix: address PR #3518 review comments (lifecycle hooks, regex, indentation, route params) (#3562) Integrated into release/v3.8.20. Addresses #3518 review: regex literals, logs/[id] route params (Next 16), indentation, and wires plugin lifecycle hooks (onInstall/onActivate/onDeactivate/onUninstall) in the loader so manager.ts can register them. Adds Rule #18 regression test. * docs(changelog): credit @ViFigueiredo (#3423) for PROJECT_ROOT + log #3561/#3562 (v3.8.20) * fix: openai to gemini incorrectly translates historical tool calls into text (#3569) Integrated into release/v3.8.20. Standard Gemini direct path now maps historical tool calls to native functionCall/functionResponse parts (signaturelessToolCallMode: native) instead of inert text — validated against the real Gemini API (gemini-2.5-flash returns 200 for signatureless native functionCall, even with tools+thinking; Hard Rule #18). Eliminates the text-serialization leak. Antigravity/CLI sentinel path (#3560) untouched. * docs(changelog)+test: reconcile standard-Gemini native mode (#3569) — update round-2 rationale comment + log VPS validation * docs(changelog): reconcile v3.8.20 — add 9 missing bullets + move [Unreleased] to versioned section * docs(changelog): complete v3.8.20 reconciliation — 27 bullets, 11 contributors --------- Co-authored-by: Alexander Averyanov <alex@averyan.ru> Co-authored-by: Hakan Kurşun <bykamaka@gmail.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Giorgos Giakoumettis <giorgos@yiakoumettis.gr> Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Markus Hartung <mail@hartmark.se>
244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
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 { BACKOFF_CONFIG, 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("cline"), "oauth");
|
|
});
|
|
|
|
test("getProviderCategory: API key providers return 'apikey'", () => {
|
|
assert.equal(getProviderCategory("gemini-cli"), "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.equal(profile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown);
|
|
assert.equal(profile.useUpstreamRetryHints, false);
|
|
assert.equal(profile.maxBackoffSteps, PROVIDER_PROFILES.oauth.maxBackoffLevel);
|
|
assert.equal(profile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
|
|
assert.equal(profile.degradationThreshold, PROVIDER_PROFILES.oauth.degradationThreshold);
|
|
assert.equal(profile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset);
|
|
assert.equal(profile.transientCooldown, PROVIDER_PROFILES.oauth.transientCooldown);
|
|
assert.equal(
|
|
profile.rateLimitCooldown,
|
|
profile.useUpstreamRetryHints ? 0 : profile.baseCooldownMs
|
|
);
|
|
assert.equal(profile.maxBackoffLevel, PROVIDER_PROFILES.oauth.maxBackoffLevel);
|
|
assert.equal(profile.circuitBreakerThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
|
|
assert.equal(profile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset);
|
|
});
|
|
|
|
test("getProviderProfile: API provider returns apikey profile", () => {
|
|
const profile = getProviderProfile("groq");
|
|
assert.equal(profile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown);
|
|
assert.equal(profile.useUpstreamRetryHints, true);
|
|
assert.equal(profile.maxBackoffSteps, PROVIDER_PROFILES.apikey.maxBackoffLevel);
|
|
assert.equal(profile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
|
|
assert.equal(profile.degradationThreshold, PROVIDER_PROFILES.apikey.degradationThreshold);
|
|
assert.equal(profile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset);
|
|
assert.equal(profile.transientCooldown, PROVIDER_PROFILES.apikey.transientCooldown);
|
|
assert.equal(
|
|
profile.rateLimitCooldown,
|
|
profile.useUpstreamRetryHints ? 0 : profile.baseCooldownMs
|
|
);
|
|
assert.equal(profile.maxBackoffLevel, PROVIDER_PROFILES.apikey.maxBackoffLevel);
|
|
assert.equal(profile.circuitBreakerThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
|
|
assert.equal(profile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset);
|
|
});
|
|
|
|
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 doubles until the configured max backoff step", () => {
|
|
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);
|
|
}
|
|
assert.deepEqual(cooldowns, [
|
|
COOLDOWN_MS.transientInitial,
|
|
COOLDOWN_MS.transientInitial * 2,
|
|
COOLDOWN_MS.transientInitial * 4,
|
|
COOLDOWN_MS.transientInitial * 8,
|
|
COOLDOWN_MS.transientInitial * 16,
|
|
COOLDOWN_MS.transientInitial * 32,
|
|
]);
|
|
});
|
|
|
|
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: returns terminal auth semantics without connection cooldown", () => {
|
|
const result = checkFallbackError(401, "", 0, null, "groq");
|
|
assert.equal(result.shouldFallback, true);
|
|
assert.equal(result.cooldownMs, 0);
|
|
assert.equal(result.baseCooldownMs, 0);
|
|
assert.equal(result.newBackoffLevel, undefined);
|
|
assert.equal(result.reason, RateLimitReason.AUTH_ERROR);
|
|
});
|
|
|
|
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 ────────────────────────────────
|
|
|
|
// Fix #2321: QUOTA_EXHAUSTED text now sets the upstream cooldown duration even when
|
|
// useUpstreamRetryHints = false (e.g., OAuth providers like antigravity). The generic
|
|
// upstream-retry-hint opt-in only governs transient rate-limit hints; subscription
|
|
// quota resets always carry a definite recovery time, so the text is always honored.
|
|
test("quota reset text is honored for oauth providers even when generic retry hints are disabled", () => {
|
|
const result = checkFallbackError(
|
|
429,
|
|
"Your quota will reset after 27h41m36s",
|
|
0,
|
|
null,
|
|
"antigravity",
|
|
null
|
|
);
|
|
// 27*3600 + 41*60 + 36 = 99696 seconds = 99696000 ms
|
|
assert.equal(result.shouldFallback, true);
|
|
assert.equal(result.cooldownMs, 99696000);
|
|
assert.equal(result.usedUpstreamRetryHint, true);
|
|
assert.equal(result.reason, "quota_exhausted");
|
|
});
|
|
|
|
test("quota reset text is honored when upstream retry hints are enabled", () => {
|
|
const result = checkFallbackError(
|
|
429,
|
|
"You have exhausted your capacity. Your quota will reset after 2h",
|
|
0,
|
|
null,
|
|
"groq",
|
|
null
|
|
);
|
|
assert.equal(result.shouldFallback, true);
|
|
assert.equal(result.cooldownMs, 2 * 60 * 60 * 1000);
|
|
assert.equal(result.newBackoffLevel, 0);
|
|
assert.equal(result.usedUpstreamRetryHint, true);
|
|
});
|
|
|
|
test("subscription quota uses long cooldown when upstream retry hints are disabled", () => {
|
|
const result = checkFallbackError(429, "Usage Limit Reached", 0, null, "antigravity", null);
|
|
assert.equal(result.shouldFallback, true);
|
|
assert.equal(result.cooldownMs, 60 * 60 * 1000);
|
|
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
|
assert.equal(result.usedUpstreamRetryHint, false);
|
|
});
|
|
|
|
test("high transient backoff levels clamp to the configured maxBackoffSteps", () => {
|
|
const result = checkFallbackError(502, "", BACKOFF_CONFIG.maxLevel + 5, null, null);
|
|
assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel);
|
|
assert.equal(
|
|
result.cooldownMs,
|
|
COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel)
|
|
);
|
|
});
|