Files
OmniRoute/tests/unit/combo-breaker-429.test.ts
Jan Leon c1a3d83c27 fix(combo): reject known context overflow without exhausting providers (#7177)
* Fix context-window exhaustion classification

* fix(combo): keep chat.ts/comboStructure.ts under the file-size ratchet + fix context-overflow boundary bug

- Extract getKnownContextOverflow (+ its KnownContextOverflow type) out of
  comboStructure.ts into a new open-sse/services/combo/knownContextOverflow.ts
  leaf, so the file-size ratchet (cap 800 for new files) passes.
- Extract the skipConnectionDisable predicate out of handleSingleModelChat in
  chat.ts into open-sse/services/combo/comboPredicates.ts::shouldSkipConnDisable,
  and consolidate the new combo-failure-handling imports, to keep chat.ts under
  its frozen file-size baseline (1796) after the #7177 request-scoped-failure
  wiring.
- Fix a real boundary bug in getKnownContextOverflow surfaced by the merge:
  estimateRequestInputTokens counted a caller-omitted `messages: []` (which some
  combo entrypoints default in) as real content, charging a few phantom
  "structural" JSON.stringify tokens toward the estimate. That was enough to
  falsely trip the new known-context-overflow rejection for a request with no
  real input when max_tokens exactly equals the target's context window (a
  common config where limit_input === limit_output === limit_context),
  regressing tests/unit/combo-routing-engine.test.ts's pre-existing #3587
  "non-reasoning model does not get max_tokens buffer" case. Empty
  arrays/objects no longer count as estimable content.
- Add a regression test for the exact-boundary empty-content case.

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>

* refactor(combo): move overflow logic into knownContextOverflow module (file-size cap)

comboStructure.ts is not frozen in the file-size baseline but is capped at 800
lines; this PR's net +29 on that file alone would push it over once merged.

knownContextOverflow.ts already exists in this PR as the dedicated home for
"known context limit" logic, so move the genuinely new pieces there instead
of leaving them in comboStructure.ts:

- hasEstimableContent (new): its own doc comment already frames it purely in
  terms of the known-context-overflow boundary check, so it belongs next to
  that check, not in the general request-compatibility file.
- getKnownContextLimit (new, requestedOutputTokens-aware): this *is* the
  "how big is a target's known context window" primitive knownContextOverflow
  already consumes; hosting it there is a better fit than comboStructure.ts.
- getLegacyKnownContextLimit: kept alongside its sibling rather than split
  across two files, since both are alternate implementations of the same
  concept (used only by comboStructure.ts's hasKnownCompatibleContextLimit).

comboStructure.ts now imports all three back for its own internal callers
(estimateRequestInputTokens, getTargetCompatibilityFailures,
hasKnownCompatibleContextLimit). deriveRequestCompatibilityRequirements and
the RequestCompatibilityRequirements type stay in comboStructure.ts exactly
as this PR already has them (still consumed internally there), so
knownContextOverflow.ts keeps importing those two, same as before.

No behavior change — pure relocation, verified by the existing PR test
suite (combo-context-window-filter, combo-breaker-429, combo-failure-log-message,
combo-target-exhaustion, diagnostics) plus the pre-existing
combo-vision-aware-routing/combo-context-requirements/combo-roundrobin-compat-fallback-6238
suites, all green.

Net effect on open-sse/services/combo/comboStructure.ts vs. this PR's merge
base: -2 lines (was +29). typecheck:core, lint, and the complexity ratchets
(check:complexity, check:cognitive-complexity) are unchanged from this PR's
current HEAD — the 4 pre-existing complexity/max-lines findings in
valueContainsImagePart/filterTargetsByRequestCompatibility are untouched by
this move (same violations, same total ratchet counts, just shifted line
numbers).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-18 15:12:24 -03:00

81 lines
2.5 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
/**
* Combo path must NOT trip the whole-provider circuit breaker on a plain rate-limit 429.
*
* Documented policy (docs/architecture/RESILIENCE_GUIDE.md + CLAUDE.md): only
* 408/500/502/503/504 trip the whole-provider breaker. A plain 429 is connection-cooldown
* / model-lockout scope, never a whole-provider outage. The single-model path already
* excludes 429 via `PROVIDER_BREAKER_FAILURE_STATUSES` (src/sse/handlers/chat.ts:206). This
* asserts the combo predicate `shouldRecordProviderBreakerFailure` is aligned — it must NOT
* gate on `isProviderFailureCode` (accountFallback.ts), which INCLUDES 429 for the separate
* connection-cooldown scope.
*/
const { shouldRecordProviderBreakerFailure } =
await import("../../open-sse/services/combo/comboPredicates.ts");
const OTHER_ARGS = {
isStreamReadinessFailure: false,
sameProviderNext: false,
skipProviderBreaker: false,
} as const;
test("429 (plain rate limit) does NOT record a whole-provider breaker failure", () => {
assert.equal(
shouldRecordProviderBreakerFailure({ ...OTHER_ARGS, status: 429 }),
false,
"a plain 429 must not open the whole-provider breaker (cooldown/lockout scope)"
);
});
for (const status of [408, 500, 502, 503, 504]) {
test(`whole-provider failure status ${status} DOES record a breaker failure`, () => {
assert.equal(
shouldRecordProviderBreakerFailure({ ...OTHER_ARGS, status }),
true,
`status ${status} must trip the whole-provider breaker`
);
});
}
test("sameProviderNext:true suppresses recording regardless of status", () => {
for (const status of [408, 429, 500, 502, 503, 504]) {
assert.equal(
shouldRecordProviderBreakerFailure({
...OTHER_ARGS,
sameProviderNext: true,
status,
}),
false,
`sameProviderNext must suppress recording for status ${status}`
);
}
});
test("skipProviderBreaker:true suppresses recording regardless of status", () => {
for (const status of [408, 429, 500, 502, 503, 504]) {
assert.equal(
shouldRecordProviderBreakerFailure({
...OTHER_ARGS,
skipProviderBreaker: true,
status,
}),
false,
`skipProviderBreaker must suppress recording for status ${status}`
);
}
});
test("request-scoped synthetic 502 does NOT record a whole-provider breaker failure", () => {
assert.equal(
shouldRecordProviderBreakerFailure({
...OTHER_ARGS,
status: 502,
requestScopedFailure: true,
}),
false
);
});