mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
* 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>
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
/**
|
|
* Pure combo predicates + tuning constants extracted from combo.ts.
|
|
*
|
|
* Side-effect-free helpers and the combo-loop tuning constants moved out of the
|
|
* combo.ts god-file (Quality Gate v2 / Fase 9). Logic unchanged; the public
|
|
* predicates are re-exported from combo.ts for backward compatibility.
|
|
*/
|
|
|
|
import { errorResponse } from "../../utils/error.ts";
|
|
import { parseModel } from "../model.ts";
|
|
import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts";
|
|
import type { ResolvedComboTarget } from "./types.ts";
|
|
|
|
// Status codes that should mark round-robin target semaphores as cooling down.
|
|
export const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504];
|
|
// Patterns that signal all accounts for a provider are rate-limited / exhausted.
|
|
// Used to detect 503 responses from handleNoCredentials so combo can fallback.
|
|
export const ALL_ACCOUNTS_RATE_LIMITED_PATTERNS = [
|
|
/unavailable/i,
|
|
/service temporarily unavailable/i,
|
|
];
|
|
|
|
export function isAllAccountsRateLimitedResponse(
|
|
status: number,
|
|
contentType: string | null,
|
|
errorText: string
|
|
): boolean {
|
|
if (status !== 503) return false;
|
|
if (!contentType?.includes("application/json")) return false;
|
|
return ALL_ACCOUNTS_RATE_LIMITED_PATTERNS.some((p) => p.test(errorText));
|
|
}
|
|
|
|
// #1731v2 guard: a provider circuit-breaker-open response (503 + `X-OmniRoute-Provider-Breaker`
|
|
// header / `provider_circuit_open` error code, see providerCircuitOpenResponse) is an OmniRoute
|
|
// resilience signal, NOT a per-connection upstream failure. It must keep being treated as an
|
|
// ordinary target failure (try the next target, including same-provider ones) — so it must NOT
|
|
// poison exhaustedConnections/exhaustedProviders, otherwise remaining same-provider targets get
|
|
// wrongly skipped while the breaker is open.
|
|
export function isProviderCircuitOpenResult(
|
|
result: { headers?: Headers | null; status?: number },
|
|
errorText: string
|
|
): boolean {
|
|
const breakerHeader = result.headers?.get?.("x-omniroute-provider-breaker");
|
|
if (typeof breakerHeader === "string" && breakerHeader.toLowerCase() === "open") return true;
|
|
return /provider_circuit_open/i.test(errorText);
|
|
}
|
|
|
|
/**
|
|
* Skip reason for a combo target already known-exhausted THIS request, or null if it is not.
|
|
*
|
|
* De-duplicates the byte-identical #1731 / #1731v2 pre-dispatch skip checks that BOTH combo
|
|
* dispatchers run per target (handleComboChat's speculative loop and handleRoundRobinCombo's
|
|
* rotation): a target whose `provider:connectionId` pair already had a connection-level error
|
|
* (`exhaustedConnections`), or whose provider already signaled full quota exhaustion
|
|
* (`exhaustedProviders`), is skipped for the rest of the request. Returns the log message the
|
|
* caller emits with its OWN tag ("COMBO" / "COMBO-RR"); each caller keeps its own control flow
|
|
* (return null vs continue) and its own fallbackCount bookkeeping.
|
|
*/
|
|
export function getExhaustedTargetSkipReason(
|
|
target: ResolvedComboTarget,
|
|
exhaustedProviders: ReadonlySet<string>,
|
|
exhaustedConnections: ReadonlySet<string>
|
|
): string | null {
|
|
const { provider, modelStr, connectionId } = target;
|
|
// #1731v2: skip targets whose provider:connection pair had a connection-level error.
|
|
if (provider && connectionId) {
|
|
if (exhaustedConnections.has(`${provider}:${connectionId}`)) {
|
|
return `Skipping ${modelStr} — connection ${connectionId} for provider ${provider} had connection error (#1731v2)`;
|
|
}
|
|
}
|
|
// #1731: skip targets from a provider that already signaled full quota exhaustion this request.
|
|
if (provider && exhaustedProviders.has(provider)) {
|
|
return `Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const MAX_COMBO_DEPTH = 3;
|
|
// Absolute safety ceiling for operator-configured nesting depth. config.maxComboDepth
|
|
// can raise the default (3) up to this cap, or lower it, but never above — runaway
|
|
// nested-combo expansion is a real DoS/perf risk.
|
|
export const MAX_COMBO_DEPTH_HARD_CAP = 10;
|
|
export const MAX_FALLBACK_WAIT_MS = 5000;
|
|
export const MAX_GLOBAL_ATTEMPTS = 30;
|
|
|
|
/**
|
|
* Clamp an operator-configured combo nesting depth (config.maxComboDepth) to a
|
|
* safe integer in [1, MAX_COMBO_DEPTH_HARD_CAP]. Anything non-numeric, < 1, or
|
|
* NaN falls back to the default MAX_COMBO_DEPTH so a bad config never disables
|
|
* nesting or blows past the safety ceiling.
|
|
*/
|
|
export function clampComboDepth(value: unknown): number {
|
|
const n = Math.floor(Number(value));
|
|
if (!Number.isFinite(n) || n < 1) return MAX_COMBO_DEPTH;
|
|
return Math.min(n, MAX_COMBO_DEPTH_HARD_CAP);
|
|
}
|
|
|
|
/** Minimum recorded requests before the predictive-TTFT breaker trusts the average. */
|
|
export const PREDICTIVE_TTFT_MIN_SAMPLES = 5;
|
|
|
|
/**
|
|
* Predictive-TTFT circuit-breaker decision: skip a target whose recent average
|
|
* latency — measured over a statistically meaningful sample — exceeds the
|
|
* configured ceiling, so the combo fails over before paying a slow first byte.
|
|
* Returns false when disabled (ceiling <= 0), when there is no metric, or when
|
|
* the sample is too small to trust.
|
|
*/
|
|
export function shouldSkipForPredictedTtft(
|
|
metric: { requests?: number; avgLatencyMs?: number } | null | undefined,
|
|
predictiveTtftMs: number
|
|
): boolean {
|
|
if (!metric || !(predictiveTtftMs > 0)) return false;
|
|
return (
|
|
(metric.requests ?? 0) >= PREDICTIVE_TTFT_MIN_SAMPLES &&
|
|
(metric.avgLatencyMs ?? 0) > predictiveTtftMs
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whole-provider circuit-breaker failure statuses for the combo path. Kept byte-identical
|
|
* to the single-model path's `PROVIDER_BREAKER_FAILURE_STATUSES` (src/sse/handlers/chat.ts:206)
|
|
* — the source of truth. 429 is deliberately EXCLUDED: a plain rate-limit must not open the
|
|
* whole-provider breaker (it's connection-cooldown / model-lockout scope). Defined locally
|
|
* rather than imported to avoid a cross-layer (open-sse → src/sse) import cycle.
|
|
*/
|
|
const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
|
|
|
|
/**
|
|
* Decide whether a failed combo target should record a whole-provider circuit-breaker
|
|
* failure (#1731 / #2743 gap-d). This is the consumer side of `skipProviderBreaker`:
|
|
*
|
|
* - Stream-readiness failures (pre-flight zombie/ping probes) never count as provider
|
|
* failures — they are a connection-readiness signal, not an upstream outage.
|
|
* - Only whole-provider failure statuses (408/500/502/503/504) count. A plain rate-limit
|
|
* 429 is deliberately EXCLUDED — it belongs to connection cooldown / model lockout scope
|
|
* (a genuine quota/token-limit 429 is handled there), NOT the whole-provider breaker. This
|
|
* mirrors the single-model path's `PROVIDER_BREAKER_FAILURE_STATUSES` (src/sse/handlers/
|
|
* chat.ts:206) — the source of truth — and the documented RESILIENCE_GUIDE policy. NOTE:
|
|
* this intentionally differs from `isProviderFailureCode` (accountFallback.ts), which
|
|
* INCLUDES 429 for connection-cooldown purposes and must not be changed here.
|
|
* - When the next combo target is on the SAME provider, don't trip the provider breaker:
|
|
* a different model on that provider may still succeed.
|
|
* - G-02 / #2743: when the fallback result carries `skipProviderBreaker` (an embedded
|
|
* service supervisor outage signalled via `X-Omni-Fallback-Hint: connection_cooldown`)
|
|
* apply connection cooldown ONLY — never trip the whole-provider breaker.
|
|
*
|
|
* Pure predicate so the breaker decision is unit-testable without the full combo harness.
|
|
*/
|
|
export function shouldRecordProviderBreakerFailure(args: {
|
|
isStreamReadinessFailure: boolean;
|
|
status: number;
|
|
sameProviderNext: boolean;
|
|
skipProviderBreaker?: boolean;
|
|
requestScopedFailure?: boolean;
|
|
}): boolean {
|
|
return (
|
|
!args.isStreamReadinessFailure &&
|
|
PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) &&
|
|
!args.sameProviderNext &&
|
|
!args.skipProviderBreaker &&
|
|
!args.requestScopedFailure
|
|
);
|
|
}
|
|
|
|
const REQUEST_SCOPED_UPSTREAM_ERROR_CODES = new Set([
|
|
"context_length_exceeded",
|
|
"upstream_empty_response",
|
|
"upstream_response_failed",
|
|
]);
|
|
|
|
/** Request/model-specific failures must not poison provider-wide resilience state. */
|
|
export function isRequestScopedUpstreamFailure(error?: {
|
|
code?: string | null;
|
|
type?: string | null;
|
|
}): boolean {
|
|
const code = typeof error?.code === "string" ? error.code.toLowerCase() : "";
|
|
const type = typeof error?.type === "string" ? error.type.toLowerCase() : "";
|
|
return REQUEST_SCOPED_UPSTREAM_ERROR_CODES.has(code) || type === "context_length_exceeded";
|
|
}
|
|
|
|
/**
|
|
* #7177: whether handleSingleModelChat should skip the connection-level cooldown
|
|
* (markAccountUnavailable) for a failed attempt — client disconnects, a 401 when the
|
|
* connection has extra keys to rotate through, a known request-scoped upstream failure
|
|
* (e.g. context overflow — not a connection health signal), or our own self-inflicted
|
|
* timeout all mean the connection itself is healthy and should not be cooled down.
|
|
*/
|
|
export function shouldSkipConnDisable(
|
|
result: { status: number; errorCode?: string | null; errorType?: string | null },
|
|
is401: boolean,
|
|
hasExtraKeys: boolean,
|
|
provider: string
|
|
): boolean {
|
|
return (
|
|
result.status === 499 ||
|
|
result.errorCode === "client_disconnected" ||
|
|
result.errorType === "client_disconnected" ||
|
|
(is401 && hasExtraKeys) ||
|
|
isRequestScopedUpstreamFailure({ code: result.errorCode, type: result.errorType }) ||
|
|
isSelfInflictedUpstreamTimeout(result.status, result.errorType, provider)
|
|
);
|
|
}
|
|
|
|
export function resolveDelayMs(value: unknown, fallback: number): number {
|
|
const numericValue = Number(value);
|
|
if (!Number.isFinite(numericValue) || numericValue < 0) return fallback;
|
|
return numericValue;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective semaphore `maxConcurrency` for a round-robin combo
|
|
* target from its connection's per-account concurrency cap.
|
|
*
|
|
* `cap` is the connection's `maxConcurrent` (provider_connections.max_concurrent).
|
|
* A positive cap is honored (floored to a whole slot count); null / undefined /
|
|
* <= 0 / non-finite all mean "no per-connection limit" and fall back to the
|
|
* combo-level concurrency. This keeps subscription accounts with a tiny
|
|
* concurrency ceiling (e.g. GLM/MiniMax ≈ 1) from being flooded.
|
|
*/
|
|
export function effectiveMaxConcurrency(cap: number | null | undefined, fallback: number): number {
|
|
if (typeof cap === "number" && Number.isFinite(cap) && cap > 0) {
|
|
return Math.floor(cap);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function comboModelNotFoundResponse(message: string) {
|
|
return errorResponse(404, message);
|
|
}
|
|
|
|
export function getTargetProvider(modelStr: string, providerId?: string | null): string {
|
|
const parsed = parseModel(modelStr);
|
|
return providerId || parsed.provider || parsed.providerAlias || "unknown";
|
|
}
|
|
|
|
export function isStreamReadinessFailureErrorBody(errorBody: unknown): boolean {
|
|
if (!errorBody || typeof errorBody !== "object") return false;
|
|
const error = (errorBody as Record<string, unknown>).error;
|
|
if (!error || typeof error !== "object") return false;
|
|
const code = (error as Record<string, unknown>).code;
|
|
return code === "STREAM_READINESS_TIMEOUT" || code === "STREAM_EARLY_EOF";
|
|
}
|
|
|
|
/**
|
|
* A local per-API-key token-limit breach surfaces as a 429 tagged with
|
|
* errorCode "TOKEN_LIMIT_EXCEEDED" (see chatCore.ts Tier 2 early return). This
|
|
* is NOT an upstream rate limit, so the combo loop must not cool the shared
|
|
* account/provider, must not add it to transientRateLimitedProviders, and must
|
|
* not retry it transiently — it propagates to the client as a terminal 429.
|
|
*/
|
|
export function isTokenLimitBreachErrorBody(errorBody: unknown): boolean {
|
|
if (!errorBody || typeof errorBody !== "object") return false;
|
|
const error = (errorBody as Record<string, unknown>).error;
|
|
if (!error || typeof error !== "object") return false;
|
|
return (error as Record<string, unknown>).code === "TOKEN_LIMIT_EXCEEDED";
|
|
}
|
|
|
|
export function toRecordedTarget(target: ResolvedComboTarget) {
|
|
return {
|
|
executionKey: target.executionKey,
|
|
stepId: target.stepId,
|
|
provider: target.provider,
|
|
providerId: target.providerId,
|
|
connectionId: target.connectionId,
|
|
label: target.label,
|
|
};
|
|
}
|