mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 21:02:50 +03:00
* fix(resilience): lock the exact model, not the quota family, on 5xx model-lockout failures A 5xx model-lockout failure — a transport error (terminated, EHOSTUNREACH, connect timeout), an upstream server error, or OmniRoute's own synthesized 502 from quality validation — is evidence about one model endpoint at that moment, not about the account's quota family. recordModelLockoutFailure() wrote it under the quota-family key regardless, so for codex (whose family key is the whole `codex` scope, i.e. every gpt-5* model) one empty stream on gpt-5.6-luna removed gpt-5.6-sol and gpt-5.6-terra from routing too, for 2–30 min with exponential escalation, while the quota was untouched. - exactModelLock.ts: resolveLockoutScope(status, explicit) — 429/403/402 (and 404, already narrowed by getModelLockKey) keep the family key; any other status uses the exact provider/connection/model key. An explicit `scope` option still wins. - recordModelLockoutFailure() resolves the scope once for key + lock fn. - decayModelFailureCount() now walks every key shape (family, not_found, exact) so success-decay reaches exact-scope locks; null model stays a no-op. - getAllModelLockouts() parses the `exact:` marker out of the key so the Model Cooldowns card lists the bare model and can clear it by that name. - docs: RESILIENCE_GUIDE §3 key-scope-by-status; changelog fragment. * chore(changelog): name the fragment after PR #12957 and link issue #12955 --------- Co-authored-by: insoln <is@careerum.com>
227 lines
8.4 KiB
TypeScript
227 lines
8.4 KiB
TypeScript
/**
|
||
* accountFallback/exactModelLock.ts — exact-model (non-family-scoped) lockout key + entry math.
|
||
*
|
||
* Extracted from services/accountFallback.ts (file-size gate, #8630): pure helpers for the
|
||
* opt-in "exact model" lockout scope introduced for Antigravity — a confirmed exhaustion on
|
||
* one specific model (e.g. one Claude model) must not lock the whole quota family (Gemini or
|
||
* other Claude models on the same account). Pure w.r.t. module state — accountFallback.ts
|
||
* still owns the modelLockouts/modelFailureState maps, canonical-provider resolution, and the
|
||
* cleanup timer; it calls into these with its own map instances.
|
||
*/
|
||
|
||
import type { ModelLockoutEntry, ModelFailureState } from "../accountFallback.ts";
|
||
|
||
/** Build the "exact" scoped lockout key — a distinct namespace from the quota-family key. */
|
||
export function buildExactModelLockKey(
|
||
canonicalProvider: string,
|
||
connectionId: string,
|
||
model: string
|
||
): string {
|
||
return `${canonicalProvider}:${connectionId}:exact:${model.trim().toLowerCase()}`;
|
||
}
|
||
|
||
/** Dedupe the 3 lockout key shapes callers must check: quota-family, #8050 not_found, exact. */
|
||
export function collectModelLockKeys(
|
||
familyKey: string,
|
||
notFoundKey: string,
|
||
exactKey: string
|
||
): string[] {
|
||
return Array.from(new Set([familyKey, notFoundKey, exactKey]));
|
||
}
|
||
|
||
/**
|
||
* DI factory for `getModelLockKeys` — accountFallback.ts's own `getModelLockKey` (quota-family
|
||
* scoping) and `getCanonicalLockProvider` (alias resolution) are private, so this closes over
|
||
* them here rather than duplicating that logic in the leaf.
|
||
*/
|
||
export function createGetModelLockKeys(
|
||
getModelLockKey: (
|
||
provider: string,
|
||
connectionId: string,
|
||
model: string,
|
||
reason?: string | null,
|
||
status?: number | null
|
||
) => string,
|
||
getCanonicalLockProvider: (provider: string) => string
|
||
) {
|
||
return function getModelLockKeys(provider: string, connectionId: string, model: string) {
|
||
return collectModelLockKeys(
|
||
getModelLockKey(provider, connectionId, model),
|
||
getModelLockKey(provider, connectionId, model, "not_found", 404),
|
||
buildExactModelLockKey(getCanonicalLockProvider(provider), connectionId, model)
|
||
);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute the next ModelLockoutEntry for an exact-model lock, merging with any existing entry
|
||
* the same way lockModel() does (extend failureCount on a shorter re-lock instead of shrinking
|
||
* the remaining cooldown). Returns null when the caller should leave state untouched.
|
||
*/
|
||
export function computeExactModelLockEntry(
|
||
existing: ModelLockoutEntry | undefined,
|
||
reason: string,
|
||
cooldownMs: number,
|
||
metadata: Partial<ModelLockoutEntry>
|
||
): ModelLockoutEntry | null {
|
||
const now = Date.now();
|
||
const newUntil = now + cooldownMs;
|
||
if (existing && existing.until > newUntil) {
|
||
if (!metadata.failureCount || metadata.failureCount <= existing.failureCount) return null;
|
||
return {
|
||
...existing,
|
||
failureCount: metadata.failureCount,
|
||
lastFailureAt: metadata.lastFailureAt ?? existing.lastFailureAt,
|
||
resetAfterMs: metadata.resetAfterMs ?? existing.resetAfterMs,
|
||
};
|
||
}
|
||
return {
|
||
reason,
|
||
until: newUntil,
|
||
lockedAt: now,
|
||
failureCount: metadata.failureCount ?? existing?.failureCount ?? 1,
|
||
lastFailureAt: metadata.lastFailureAt ?? now,
|
||
resetAfterMs: metadata.resetAfterMs ?? existing?.resetAfterMs ?? 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Delete every one of the 3 lockout key shapes from both maps — a success on any one
|
||
* of them must clear the lock regardless of which reason originally wrote it.
|
||
*/
|
||
export function clearMultiKeyLock(
|
||
modelLockouts: Map<string, ModelLockoutEntry>,
|
||
modelFailureState: Map<string, ModelFailureState>,
|
||
keys: string[]
|
||
): boolean {
|
||
let cleared = false;
|
||
for (const key of keys) {
|
||
cleared = modelLockouts.delete(key) || cleared;
|
||
cleared = modelFailureState.delete(key) || cleared;
|
||
}
|
||
return cleared;
|
||
}
|
||
|
||
/** True when any of the 3 lockout key shapes is currently active (post-cleanup). */
|
||
export function isAnyKeyLocked(
|
||
modelLockouts: Map<string, ModelLockoutEntry>,
|
||
cleanup: (key: string) => void,
|
||
keys: string[]
|
||
): boolean {
|
||
return keys.some((key) => {
|
||
cleanup(key);
|
||
return modelLockouts.has(key);
|
||
});
|
||
}
|
||
|
||
/** The active entry with the most remaining time across the 3 lockout key shapes. */
|
||
export function findLatestLockEntry(
|
||
modelLockouts: Map<string, ModelLockoutEntry>,
|
||
cleanup: (key: string) => void,
|
||
keys: string[]
|
||
): ModelLockoutEntry | undefined {
|
||
return keys
|
||
.map((key) => {
|
||
cleanup(key);
|
||
return modelLockouts.get(key);
|
||
})
|
||
.filter((value): value is ModelLockoutEntry => Boolean(value))
|
||
.sort((a, b) => b.until - a.until)[0];
|
||
}
|
||
|
||
/**
|
||
* DI factory for the exported `lockExactModel` — accountFallback.ts owns the
|
||
* modelLockouts map + cleanup timer/key private functions and closes over them here so
|
||
* the full lock-only-this-exact-tuple implementation lives in this leaf, not the god-file.
|
||
*/
|
||
export function createLockExactModel(
|
||
modelLockouts: Map<string, ModelLockoutEntry>,
|
||
ensureCleanupTimer: () => void,
|
||
cleanupModelLockKey: (key: string) => void,
|
||
getCanonicalLockProvider: (provider: string) => string
|
||
) {
|
||
return function lockExactModel(
|
||
provider: string,
|
||
connectionId: string,
|
||
model: string | null | undefined,
|
||
reason: string,
|
||
cooldownMs: number,
|
||
metadata: Partial<ModelLockoutEntry> = {}
|
||
): void {
|
||
if (!model) return;
|
||
ensureCleanupTimer();
|
||
const key = buildExactModelLockKey(getCanonicalLockProvider(provider), connectionId, model);
|
||
cleanupModelLockKey(key);
|
||
const next = computeExactModelLockEntry(modelLockouts.get(key), reason, cooldownMs, metadata);
|
||
if (next) modelLockouts.set(key, next);
|
||
};
|
||
}
|
||
|
||
/** Which key namespace a lockout writes to — see resolveLockoutScope(). */
|
||
export type LockoutScope = "exact" | "quota_family";
|
||
|
||
/**
|
||
* Statuses that are evidence about the account's quota / entitlement and therefore
|
||
* lock the quota family (codex: the whole `codex` / `spark` scope; other providers:
|
||
* getQuotaScopedModelForProvider). 404 stays on this side only because
|
||
* getModelLockKey() already narrows a not_found lock to the bare model.
|
||
*/
|
||
const QUOTA_FAMILY_LOCKOUT_STATUSES: ReadonlySet<number> = new Set([402, 403, 404, 429]);
|
||
|
||
/**
|
||
* A 5xx — a transport failure (`terminated`, EHOSTUNREACH, connect timeout), an
|
||
* upstream server error, or OmniRoute's own synthesized 502 from quality
|
||
* validation — says something about one model endpoint at that moment, not about
|
||
* the account's quota family. Locking the family on it let a single empty stream
|
||
* on one `gpt-5.6-*` model remove every `gpt-5*` model of the codex connection
|
||
* from routing for 2–30 min (escalating) while its quota was untouched. Such
|
||
* failures lock the exact provider/connection/model tuple instead. A caller's
|
||
* explicit `scope` always wins (Antigravity passes "exact" for its own reasons).
|
||
*/
|
||
export function resolveLockoutScope(status: number, explicit?: LockoutScope): LockoutScope {
|
||
if (explicit) return explicit;
|
||
return QUOTA_FAMILY_LOCKOUT_STATUSES.has(status) ? "quota_family" : "exact";
|
||
}
|
||
|
||
/** Split a `provider:connectionId:[exact:]model` key back into the parts the dashboard lists. */
|
||
export function parseModelLockKey(key: string): {
|
||
provider: string;
|
||
connectionId: string;
|
||
model: string;
|
||
scope: LockoutScope;
|
||
} {
|
||
const [provider, connectionId, ...modelParts] = key.split(":");
|
||
const scope: LockoutScope = modelParts[0] === "exact" ? "exact" : "quota_family";
|
||
const model = (scope === "exact" ? modelParts.slice(1) : modelParts).join(":");
|
||
return { provider, connectionId, model, scope };
|
||
}
|
||
|
||
/**
|
||
* Success-decay across every key shape (quota-family, not_found, exact): halve each
|
||
* stored failureCount, dropping the entry once it reaches 0. `cleared` is true only
|
||
* when every entry that existed was dropped; `newFailureCount` is the largest count
|
||
* still stored.
|
||
*/
|
||
export function decayFailureCounts(
|
||
modelFailureState: Map<string, ModelFailureState>,
|
||
keys: string[]
|
||
): { cleared: boolean; newFailureCount: number } {
|
||
let seen = 0;
|
||
let dropped = 0;
|
||
let newFailureCount = 0;
|
||
for (const key of keys) {
|
||
const failure = modelFailureState.get(key);
|
||
if (!failure) continue;
|
||
seen += 1;
|
||
const next = Math.floor(failure.failureCount / 2);
|
||
if (next === 0) {
|
||
modelFailureState.delete(key);
|
||
dropped += 1;
|
||
} else {
|
||
modelFailureState.set(key, { ...failure, failureCount: next });
|
||
newFailureCount = Math.max(newFailureCount, next);
|
||
}
|
||
}
|
||
return { cleared: seen > 0 && dropped === seen, newFailureCount };
|
||
}
|