Files
OmniRoute/open-sse/services/combo/knownContextOverflow.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

98 lines
3.9 KiB
TypeScript

/**
* Known context-overflow rejection, extracted from comboStructure.ts to keep
* that file under the file-size cap (#7177).
*
* Fixes: routing a request to a combo whose targets all have a KNOWN (not
* unknown/fail-open) context window too small for the request used to be
* discovered only after every target was tried and failed upstream — burning
* retries/cooldowns on a request that could never succeed. This lets the
* combo dispatcher reject it up front, before exhausting providers.
*
* getKnownContextLimit/hasEstimableContent also
* live here (moved from comboStructure.ts, same file-size-cap motivation):
* they are the "how big is a target's known context window" primitives, so
* they belong next to the overflow check that is their main consumer.
* comboStructure.ts's own compatibility filter now decides fit via its
* evaluateContextLimit (#7052); only hasEstimableContent is imported back.
*/
import { getResolvedModelCapabilities } from "../modelCapabilities.ts";
import { deriveRequestCompatibilityRequirements } from "./comboStructure.ts";
import type { ResolvedComboTarget } from "./types.ts";
export type KnownContextOverflow = {
estimatedInputTokens: number;
requestedOutputTokens: number;
requiredContextTokens: number;
maxKnownContextTokens: number;
targetCount: number;
};
// #7177: an empty array/object (e.g. a default `messages: []` some combo entrypoints inject
// when the caller sent none) has no real content — counting it would charge a few phantom
// "structural" tokens (JSON.stringify braces/brackets) toward the estimate, which is enough
// to falsely trip the exact-boundary known-context-overflow check for a request that has no
// actual input at all.
export function hasEstimableContent(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "object") return Object.keys(value).length > 0;
return true;
}
// #7177: known context limit that accounts for the request's own requested
// output tokens — a target whose input+output would together exceed
// maxInputTokens is exactly as incompatible as one whose contextWindow is too
// small, so both bounds go through the same min() so far the tightest wins.
export function getKnownContextLimit(
capabilities: {
maxInputTokens?: number | null;
contextWindow?: number | null;
},
requestedOutputTokens = 0
): number | null {
const limits: number[] = [];
if (capabilities.maxInputTokens != null) {
limits.push(capabilities.maxInputTokens + requestedOutputTokens);
}
if (capabilities.contextWindow != null) {
limits.push(capabilities.contextWindow);
}
return limits.length > 0 ? Math.min(...limits) : null;
}
/**
* Return a hard context-overflow decision only when every target has a known
* context limit and every one of those limits is too small for the request.
* Unknown metadata deliberately keeps the legacy fail-open behavior.
*/
export function getKnownContextOverflow(
targets: ResolvedComboTarget[],
body: Record<string, unknown>
): KnownContextOverflow | null {
if (targets.length === 0) return null;
const requirements = deriveRequestCompatibilityRequirements(body);
if (requirements.requiredContextTokens <= 0) return null;
const limits = targets.map((target) =>
getKnownContextLimit(
getResolvedModelCapabilities(target.modelStr),
requirements.requestedOutputTokens
)
);
if (limits.some((limit) => limit === null)) return null;
const knownLimits = limits as number[];
const maxKnownContextTokens = Math.max(...knownLimits);
if (maxKnownContextTokens >= requirements.requiredContextTokens) return null;
return {
estimatedInputTokens: requirements.estimatedInputTokens,
requestedOutputTokens: requirements.requestedOutputTokens,
requiredContextTokens: requirements.requiredContextTokens,
maxKnownContextTokens,
targetCount: targets.length,
};
}