Compare commits

...

8 Commits

Author SHA1 Message Date
diegosouzapw
b343d44512 feat(sse): relay upstream 4xx error bodies verbatim on the Anthropic request path
createErrorResult() gains an opt-in 7th param opts.passthrough; when set and
the upstream body is eligible (per shouldPassthroughUpstreamError), the
returned response body is the upstream 4xx body verbatim instead of the
sanitized wrapper. Internal classification fields (error/rawMessage/
errorType/errorCode) are never affected.

Wired into chatCore.ts's 7 upstream-error createErrorResult call sites
(model_unavailable / context_overflow / generic upstream-error branches),
gated on sourceFormat === FORMATS.CLAUDE so only /v1/messages requests get
the verbatim body — OpenAI-format paths are unchanged.
2026-07-25 18:44:21 -03:00
diegosouzapw
043756bc02 feat(sse): selective upstream 4xx error passthrough util 2026-07-25 18:35:08 -03:00
Alex
4053e2314a chore(quality): rebaseline file-size for inherited base growth (#8561)
check:file-size fails on the pristine release/v3.8.49 tip (30709255),
which takes the whole Fast Quality Gates job down for every PR against
the branch. Both offenders grew after the last rebaseline
(_rebaseline_2026_07_25_v3849_basered_filesize, measured at 36f8fd10)
and both came from already-merged PRs, so there is no offending branch
left to fix:

  src/lib/tokenHealthCheck.ts                     841 -> 843  (#8426, 4528fc455)
  src/app/(dashboard)/dashboard/providers/page.tsx 1927 -> 1990 (#8349, 58ab8b1d2)

Trust-but-verify: both values measured on the pristine tip with an empty
working tree. Same situation and remedy as the entry cited above and as
_rebaseline_2026_07_02_5798_release_green.

This commit is deliberately last and self-contained: drop it if the
captain would rather move the frozen values separately. Structural
reduction of the 1990-line providers page is not attempted here.

Co-authored-by: alexey.nazarov@softmg.ru <alexey.nazarov@softmg.ru>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:25:14 -03:00
MumuTW
cc63ac9f53 test(sse): register #8396 and #8376 unit tests in stryker tap.testFiles (#8538)
* test(sse): register #8396 and #8376 unit tests in stryker tap.testFiles

check:mutation-test-coverage fails on release/v3.8.49 at its own HEAD:
two unit tests cover mutated modules but are absent from tap.testFiles,
so their mutant kills do not count and the drift gate blocks every
PR->release run.

- tests/unit/8396-cooldown-429-cap.test.ts covers
  open-sse/services/accountFallback.ts (imports checkFallbackError)
- tests/unit/8376-econnrefused-breaker.test.ts covers
  open-sse/services/combo/comboPredicates.ts (imports
  shouldRecordProviderBreakerFailure)

Both inserted in the array's existing sorted position; no other key
touched.

* test(sse): register repro-7503-no-choices in stryker tap.testFiles
2026-07-25 11:10:52 -03:00
MumuTW
c447be4329 fix(db): classify compressionDetailNormalizers as db-internal in check-db-rules (#8534)
* fix(db): classify compressionDetailNormalizers as db-internal in check-db-rules

check:db-rules fails on release/v3.8.49 at its own HEAD: the module added
by #8404 is neither re-exported from localDb.ts nor listed in
INTENTIONALLY_INTERNAL, so the gate blocks every PR->release run and
tests/unit/check-db-rules.test.ts fails its live-repo case.

Its only importer is its sibling src/lib/db/compression.ts, via a
relative import inside src/lib/db/ — the db-internal classification the
list already uses for apiKeyColumnFallbacks and caseMapping. Re-exporting
it from localDb.ts would instead advertise pure normalizer helpers as
part of the compat surface, which Hard Rule #2 discourages.

* test(db): mirror compressionDetailNormalizers in the INTENTIONALLY_INTERNAL audit

The classification guard asserts the exact audited set. Adding the module to
check-db-rules.mjs without the mirror left the exact-list/exact-size assertion
red; both assertions stay exact (37 entries).

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-25 11:10:48 -03:00
MumuTW
d06d3fb67d test(sse): update three backoff assertions stale since the #8396 cooldown cap (#8539)
These three cases assert that a transient-error cooldown keeps doubling
to baseCooldownMs * 2^maxLevel — roughly 45.5h at the default constants.
That is precisely the blackout #8396 removed: capScaledCooldownMs
(open-sse/services/accountFallback/cooldownCap.ts) now bounds every
scaled cooldown by profile.maxCooldownMs, falling back to
BACKOFF_CONFIG.max when the profile does not configure one. All three
call checkFallbackError with a null provider, so the fallback ceiling
applies and the observed value is BACKOFF_CONFIG.max.

The backoff-level clamping each case was written to guard is unchanged
and still asserted; only the expected duration moved. The expressions
keep the original formula wrapped in the cap so the relationship stays
readable, and the first case gains a precondition assertion so it cannot
silently become vacuous if the constants change.

Fixes the error-classification (x2) and thundering-herd (x1) failures
that are red on release/v3.8.49 at its own HEAD.
2026-07-25 11:10:44 -03:00
MumuTW
278640b438 chore(ci): resync stale no-explicit-any suppression count for proxy-registry.test.ts (#8544)
* chore(ci): resync stale no-explicit-any suppression count for proxy-registry.test.ts

tests/unit/proxy-registry.test.ts is frozen at 55 no-explicit-any
violations but only has 54 since #8447 (d7f947586) removed one. ESLint
fails the run with "There are suppressions left that do not occur
anymore", making `npm run lint` exit 2 on release/v3.8.49 for every PR
that branches off it.

Same class as #8007 / #8490, different file: no code to fix here — the
count simply drifted down, so this resyncs it via --prune-suppressions.

Base-red inherited from release/v3.8.49; both the suppressions file and
proxy-registry.test.ts are byte-identical to that branch.

* docs(changelog): add fragment for this PR
2026-07-25 11:10:33 -03:00
Diego Rodrigues de Sa e Souza
ec0be07682 chore(deps): patch js-yaml + postcss for 2 high Dependabot alerts (#8572)
js-yaml 5.2.1 -> 5.2.2 (GHSA-pm4m-ph32-ghv5, CWE-407: exponential parsing time in flow collections — a <200-byte payload hangs load()).
postcss 8.5.14 -> 8.5.23 (GHSA-r28c-9q8g-f849, CWE-22: path traversal via auto-loaded sourceMappingURL discloses arbitrary .map files).

Scoped override keeps promptfoo off its exact js-yaml@5.2.1 pin while holding
@apidevtools/json-schema-ref-parser on js-yaml ^4.2.0, so the nested override does
not major that subtree. Lockfile diff is exactly three versions (plus postcss's own
nanoid); nothing added or removed. check:lockfile passes.

Closes Dependabot #146 and #148.
2026-07-25 10:35:05 -03:00
14 changed files with 239 additions and 31 deletions

View File

@@ -0,0 +1 @@
- chore(ci): resync the stale `no-explicit-any` suppression count for `tests/unit/proxy-registry.test.ts` (frozen at 55, actual 54 since #8447) — ESLint was failing the whole run with "There are suppressions left that do not occur anymore", turning `npm run lint` red on `release/v3.8.49` for every PR branched off it

View File

@@ -1937,7 +1937,7 @@
},
"tests/unit/proxy-registry.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 55
"count": 54
}
},
"tests/unit/proxy-resolution-status-filter.test.ts": {

View File

@@ -253,7 +253,7 @@
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1054,
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 948,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1927,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1990,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819,
"src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx": 903,
@@ -324,7 +324,7 @@
"open-sse/executors/huggingchat.ts": 813,
"_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.",
"src/lib/providers/validation/webProvidersA.ts": 809,
"src/lib/tokenHealthCheck.ts": 841,
"src/lib/tokenHealthCheck.ts": 843,
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.",
"_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.",
"_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \"./db/autoCandidateOverrides\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.",
@@ -470,5 +470,6 @@
"_rebaseline_2026_07_23_v3849_merge_train_15": "Own-growth do merge-train de 15 PRs (2026-07-23), medido na tip combinada, release pura abaixo do baseline (auth.ts 2448, muse-spark 1393, translator-test 1523). auth.ts 2462->2475 (#8321 cookie-auth 401 cooldown-em-vez-de-terminal + #8324 noauth opencode-zen via proxy — wiring de classificação no chokepoint getProviderCredentials/markAccountUnavailable, não extraível), muse-spark-web.ts 1394->1396 (#8298 sanitizeErrorMessage runtime repairs isolados do #8177), tests/unit/translator-openai-to-gemini.test.ts 1553->1616 (#8312 cobertura do cap de thinking budget no path budget_tokens explícito). Owner-approved. Frozen; shrink estrutural em #3501.",
"_rebaseline_2026_07_22_providerLimits_webcookie_chain": "providerLimits.ts 1003->1005: own-growth from web-cookie provider usage-fetcher entries (#7994/#8006/#8027 chain) landing after the prior rebaseline.",
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_25_v3849_basered_filesize": "Base-red unblock (2026-07-25): check:file-size was failing on release/v3.8.49 at its own HEAD (36f8fd10), so the quality.yml fast-gates job was red for EVERY PR->release regardless of content — growth inherited from already-merged PRs, with no offending PR branch left to fix (same situation as _rebaseline_2026_07_02_5798_release_green). Prod frozen raised to the current base values: src/lib/tokenHealthCheck.ts 832->841, src/sse/handlers/chat.ts 1865->1866, src/sse/services/auth.ts 2475->2486, open-sse/services/accountFallback.ts 1941->1966, open-sse/services/combo.ts 3630->3642. accountFallback.ts was first frozen here at 1960 (the base value at 36f8fd10) and re-measured to 1966 at base tip 1cafd328c a few hours later — the same inherited drift this entry exists for, since check:file-size does not run on the PR->release fast path and so accrues unmeasured between release rebaselines. These files remain frozen and cannot grow further; any in-flight PR that adds lines to them (e.g. #8482 touches accountFallback.ts and combo.ts) bumps its own entry as usual. The release captain rebaseline-at-release supersedes this note."
"_rebaseline_2026_07_25_v3849_basered_filesize": "Base-red unblock (2026-07-25): check:file-size was failing on release/v3.8.49 at its own HEAD (36f8fd10), so the quality.yml fast-gates job was red for EVERY PR->release regardless of content — growth inherited from already-merged PRs, with no offending PR branch left to fix (same situation as _rebaseline_2026_07_02_5798_release_green). Prod frozen raised to the current base values: src/lib/tokenHealthCheck.ts 832->841, src/sse/handlers/chat.ts 1865->1866, src/sse/services/auth.ts 2475->2486, open-sse/services/accountFallback.ts 1941->1966, open-sse/services/combo.ts 3630->3642. accountFallback.ts was first frozen here at 1960 (the base value at 36f8fd10) and re-measured to 1966 at base tip 1cafd328c a few hours later — the same inherited drift this entry exists for, since check:file-size does not run on the PR->release fast path and so accrues unmeasured between release rebaselines. These files remain frozen and cannot grow further; any in-flight PR that adds lines to them (e.g. #8482 touches accountFallback.ts and combo.ts) bumps its own entry as usual. The release captain rebaseline-at-release supersedes this note.",
"_rebaseline_2026_07_25_v3849_basered_filesize_2": "Base-red unblock (2026-07-25, second pass): after _rebaseline_2026_07_25_v3849_basered_filesize (measured at 36f8fd10) two more already-merged PRs grew frozen files on release/v3.8.49, so check:file-size — and with it the whole Fast Quality Gates job — is red for EVERY PR->release again, with no offending PR branch left to fix. src/lib/tokenHealthCheck.ts 841->843 (#8426 4528fc455, excludes local CLI providers from expiration) and src/app/(dashboard)/dashboard/providers/page.tsx 1927->1990 (#8349 58ab8b1d2, scroll-position restore on provider-card back-navigation). Trust-but-verify: both values measured on the pristine release tip 30709255 with no working-tree changes. Same situation and remedy as _rebaseline_2026_07_02_5798_release_green. Structural reduction of providers/page.tsx stays tracked separately — it is a 1990-line page, not something to extract inside a base-repair PR."
}

View File

@@ -3766,7 +3766,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} catch {
@@ -3785,7 +3786,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else {
@@ -3804,7 +3806,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else if (isContextOverflowError(statusCode, message)) {
@@ -3852,7 +3855,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} catch {
@@ -3871,7 +3875,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else {
@@ -3890,7 +3895,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else {
@@ -3916,7 +3922,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
// ── End T5 ───────────────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
/**
* Sanitize an error message to prevent stack trace exposure in API responses.
@@ -520,7 +521,8 @@ export function createErrorResult(
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string,
upstreamDetails?: unknown
upstreamDetails?: unknown,
opts?: { passthrough?: boolean }
) {
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
@@ -568,6 +570,22 @@ export function createErrorResult(
result.retryAfterMs = retryAfterMs;
}
// Opt-in relay of the verbatim upstream error body (Claude Code auto-recover
// contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so
// server-side classification (checkFallbackError, combo retry logic, etc.)
// never sees a different value depending on this flag.
if (opts?.passthrough) {
const passthroughResponse = buildPassthroughErrorResponse(
statusCode,
upstreamDetails,
retryAfterMs ? { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) } : undefined
);
if (passthroughResponse) {
result.response = passthroughResponse;
}
}
return result;
}

View File

@@ -0,0 +1,38 @@
/**
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
*
* Claude Code matches the upstream error WORDING to auto-disable capabilities
* (thinking / output_config) for the rest of the conversation. Wrapping the body
* via buildErrorBody() truncates the message and breaks that recovery. For
* upstream-originated 4xx errors the body is the provider's public API message —
* not our internals — so it is safe and required to relay it verbatim.
* OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12).
*/
const PASSTHROUGH_MIN = 400;
const PASSTHROUGH_MAX = 499;
// 401/403/407: auth-adjacent — our own credential context may leak via provider
// echoes; keep those sanitized. 400/404/408/413/422/429 carry the capability and
// quota wording the client needs.
const EXCLUDED_STATUSES = new Set([401, 403, 407]);
const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean {
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
if (EXCLUDED_STATUSES.has(statusCode)) return false;
if (!upstreamBody || typeof upstreamBody !== "object") return false;
const text = JSON.stringify(upstreamBody);
if (INTERNAL_LEAK_RE.test(text)) return false;
return true;
}
export function buildPassthroughErrorResponse(
statusCode: number,
upstreamBody: unknown,
headers?: Record<string, string>
): Response | null {
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
return new Response(JSON.stringify(upstreamBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
}

24
package-lock.json generated
View File

@@ -44,7 +44,7 @@
"ink-text-input": "^6.0.0",
"ioredis": "^5.10.1",
"jose": "^6.2.3",
"js-yaml": "^5.0.0",
"js-yaml": "^5.2.2",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"lucide-react": "^1.21.0",
@@ -103,7 +103,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "*",
"@types/bun": "latest",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
@@ -23683,9 +23683,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz",
"integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==",
"funding": [
{
"type": "github",
@@ -27778,9 +27778,9 @@
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -30080,9 +30080,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -30099,7 +30099,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View File

@@ -265,7 +265,7 @@
"ink-text-input": "^6.0.0",
"ioredis": "^5.10.1",
"jose": "^6.2.3",
"js-yaml": "^5.0.0",
"js-yaml": "^5.2.2",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"lucide-react": "^1.21.0",
@@ -394,7 +394,7 @@
"dompurify": "^3.4.12",
"fast-xml-parser": "^5.10.1",
"sharp": "^0.35.0",
"postcss": "^8.5.14",
"postcss": "^8.5.18",
"ip-address": "10.2.0",
"qs": "^6.15.2",
"uuid": "^14.0.0",
@@ -418,6 +418,12 @@
"concurrently": {
"shell-quote": "^1.9.0"
},
"adm-zip": "^0.6.0"
"adm-zip": "^0.6.0",
"promptfoo": {
"js-yaml": "^5.2.2",
"@apidevtools/json-schema-ref-parser": {
"js-yaml": "^4.2.0"
}
}
}
}

View File

@@ -48,6 +48,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404)
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery

View File

@@ -49,6 +49,8 @@
"tests/unit/8247-accountfallback-model-unhealthy.test.ts",
"tests/unit/8248-accountfallback-nvidia-degraded.test.ts",
"tests/unit/8332-combo-vision-fallback.test.ts",
"tests/unit/8376-econnrefused-breaker.test.ts",
"tests/unit/8396-cooldown-429-cap.test.ts",
"tests/unit/account-fallback-anthropic-quota.test.ts",
"tests/unit/account-fallback-lockout-eviction.test.ts",
"tests/unit/account-fallback-retry-after-json.test.ts",
@@ -251,6 +253,7 @@
"tests/unit/rate-limit-enhanced.test.ts",
"tests/unit/rate-limit-manager.test.ts",
"tests/unit/rate-limit-queue-timeout-lockout.test.ts",
"tests/unit/repro-7503-no-choices.test.ts",
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/rotation-config-omniroute.test.ts",

View File

@@ -121,7 +121,7 @@ test("INTENTIONALLY_INTERNAL is exported from check-db-rules.mjs", () => {
assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty");
});
test("INTENTIONALLY_INTERNAL contains the expected 36 audited modules", () => {
test("INTENTIONALLY_INTERNAL contains the expected 37 audited modules", () => {
const expected = [
"_rowTypes",
"accessTokens",
@@ -133,6 +133,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 36 audited modules", () => {
"comboForecast",
"commandCodeAuth",
"compression",
"compressionDetailNormalizers",
"detailedLogs",
"discovery",
"domainState",

View File

@@ -98,13 +98,21 @@ test("502 transient: exponential backoff doubles until the configured max backof
assert.equal(result.newBackoffLevel, level + 1);
assert.equal(result.reason, RateLimitReason.SERVER_ERROR);
}
// #8396: the scaled cooldown is now clamped by capScaledCooldownMs
// (open-sse/services/accountFallback/cooldownCap.ts). With no provider the
// ceiling is BACKOFF_CONFIG.max, so the doubling stops there instead of
// running on to transientInitial * 32.
assert.ok(
COOLDOWN_MS.transientInitial * 32 > BACKOFF_CONFIG.max,
"precondition: the 6th step must exceed the cap, or this test proves nothing"
);
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,
BACKOFF_CONFIG.max,
]);
});
@@ -237,8 +245,14 @@ test("subscription quota uses long cooldown when upstream retry hints are disabl
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);
// #8396: the level still clamps at maxLevel, but the resulting duration is
// additionally capped — unclamped this would be ~45.5h, which is the blackout
// that PR removed.
assert.equal(
result.cooldownMs,
COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel)
Math.min(
COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel),
BACKOFF_CONFIG.max
)
);
});

View File

@@ -39,9 +39,15 @@ test("API profile has shorter transient cooldown", () => {
test("Exponential backoff clamps to the configured maxBackoffLevel", () => {
const result = checkFallbackError(502, "", 20, null, null);
assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel);
// #8396: the level still clamps at maxLevel, but capScaledCooldownMs also
// bounds the duration — with no provider profile the ceiling is
// BACKOFF_CONFIG.max rather than the unbounded baseCooldownMs * 2^level.
assert.equal(
result.cooldownMs,
COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel)
Math.min(
COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel),
BACKOFF_CONFIG.max
)
);
});

View File

@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
shouldPassthroughUpstreamError,
buildPassthroughErrorResponse,
} from "../../open-sse/utils/upstreamErrorPassthrough.ts";
test("upstream error passthrough", async (t) => {
await t.test("4xx com corpo JSON de erro do provider é elegível", () => {
const body = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" },
};
assert.equal(shouldPassthroughUpstreamError(400, body), true);
});
await t.test("5xx NÃO é elegível (segue sanitizado)", () => {
assert.equal(shouldPassthroughUpstreamError(500, { error: { message: "x" } }), false);
});
await t.test("corpo com cara de vazamento interno (stack trace) NÃO é elegível", () => {
assert.equal(
shouldPassthroughUpstreamError(400, {
error: { message: "Error\n at /usr/lib/node_modules/omniroute/x.js:1" },
}),
false
);
});
await t.test(
"401/407 NÃO são elegíveis (credencial nossa pode vazar em www-authenticate)",
() => {
assert.equal(shouldPassthroughUpstreamError(401, { error: { message: "bad key" } }), false);
}
);
await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => {
const body = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: nope" },
};
const res = buildPassthroughErrorResponse(400, body);
assert.ok(res);
assert.equal(res.status, 400);
assert.deepEqual(await res.json(), body);
});
await t.test("retorna null quando inelegível", () => {
assert.equal(buildPassthroughErrorResponse(500, {}), null);
});
});
test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => {
await t.test(
"com opts.passthrough e corpo elegível, result.response é o corpo upstream verbatim",
async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: {
type: "invalid_request_error",
message: "thinking.type: adaptive is not supported",
},
};
const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody, {
passthrough: true,
});
assert.deepEqual(await result.response.json(), upstreamBody);
assert.equal(result.status, 400);
// Internal classification fields must never be affected by passthrough.
assert.equal(typeof result.error, "string");
assert.notEqual(result.error, JSON.stringify(upstreamBody));
}
);
await t.test("sem opts, comportamento atual (corpo sanitizado) é preservado", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" },
};
const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody);
const body = (await result.response.json()) as { error?: { message?: string } };
assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape");
assert.ok(
!JSON.stringify(body).includes(" at /"),
"sanitized body never leaks stack-trace-like text"
);
});
await t.test("com retryAfterMs e passthrough elegível, header Retry-After é setado", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: { type: "rate_limit_error", message: "slow down" },
};
const result = createErrorResult(429, "msg", 5000, "code", "type", upstreamBody, {
passthrough: true,
});
assert.equal(result.response.headers.get("Retry-After"), "5");
assert.deepEqual(await result.response.json(), upstreamBody);
});
await t.test(
"opts.passthrough true mas corpo inelegível (401) cai no corpo sanitizado atual",
async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = { error: { message: "bad key" } };
const result = createErrorResult(401, "unauthorized", null, "code", "type", upstreamBody, {
passthrough: true,
});
const body = (await result.response.json()) as { error?: { message?: string } };
assert.notDeepEqual(body, upstreamBody);
assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape");
}
);
});