mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
fix(quota): align AUTH window labels with usage API and clarify 503 (#12884)
Um `ALL_TARGETS_SKIPPED` 503 que não diz qual janela esgotou é opaco justamente no momento em que o operador mais precisa saber. Alinhar os rótulos de janela AUTH com os da API de uso fecha a outra metade: dois nomes para a mesma coisa fazem o dashboard e o erro parecerem discordar. Revalidei sobre o tip: **6/6**, typecheck:core limpo, check-file-size OK. **Dois consertos meus na sua branch.** 1. `typecheck:core` falhava com `TS2345` em `comboAttemptLoop.ts` (linhas 130 e 416): o `QuotaSkipTarget` declarava `connectionId?: string`, mas o `ResolvedComboTarget` carrega `string | null` para alvo não-pinado. Alarguei para `string | null` no tipo de diagnóstico em vez de estreitar o call site — o módulo só **lê** o campo e a linha 29 já narrowa com `typeof === "string"`, então null não custa nada ali. Isso apareceu porque o `comboAttemptLoop` mudou de forma no #12746/#12811, mergeados nesta mesma campanha depois que você cortou a branch. 2. O `roundRobinCombo.ts` foi de 1198 para 1205 e cruzou o teto de 1200 para arquivo novo. Congelei com justificativa: o arquivo já nasceu em 1198 quando o #12811 o levantou de dentro do `combo.ts`, e os diagnósticos em si vivem no `quotaSkipDiagnostics.ts`, sob o cap. Registrei que a próxima extração natural é o corpo do attempt loop, mas que ele acabou de ser movido e deve assentar antes de ser cortado de novo.
This commit is contained in:
@@ -469,7 +469,8 @@
|
||||
"src/sse/services/auth.ts": 3450,
|
||||
"tests/unit/account-fallback-service.test.ts": 2453,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 4656,
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1219
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1219,
|
||||
"open-sse/services/combo/roundRobinCombo.ts": 1205
|
||||
},
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
@@ -656,5 +657,6 @@
|
||||
"_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped.",
|
||||
"_rebaseline_2026_09_07_chatcore_nonstreaming_regression_fixes": "Own growth: open-sse/handlers/chatCore.ts 5984->6021 (+37). Two of my own PRs on top of #12867: #12963 pins the ok variant of the non-streaming leg result in its own binding (the discriminated-union narrowing was lost across the tool-loop reassignment, 13 TS2339 under tsconfig.typecheck-api.json), and #12990 restores four behaviours the same refactor dropped — abort classification through isLocalStreamLifecycleError, the omitted synthetic clientResponse, the claudePromptCacheLogMeta rebuild on the leg path, and the lazy fail-closed fence identity. Irreducible at the existing chokepoints: each edit sits where chatCore already owns the decision, and the helpers themselves (nonStreamingProviderLeg.ts, serverOwnedToolLoopWire.ts) are under cap. Covered by tests/unit/chatcore-translation-paths.test.ts (72/74; the 2 open are issue #13043).",
|
||||
"_rebaseline_2026_09_07_virtualfactory_crosses_the_new_file_cap": "open-sse/services/autoCombo/virtualFactory.ts crosses the 1200 new-file cap for the first time (1187 on the pre-wave tip, 1219 after the wave). Growth is spread across the routing/free-tier wave, not one extractable block: #12794 feeds observed breaker state and model quality into snapshot scoring instead of neutral constants, #12792 adds the reliability factor the snapshot path was still ignoring and the pooled-latency bootstrap, and #12744 tightens the free-model predicate the factory consumes, and #12795 records which filter stage emptied an auto/* pool. FROZEN RATHER THAN SPLIT, deliberately, and this is debt: two cohesive extraction candidates are ready when someone owns the move — computeSnapshotWeights (~85 lines) and the credential-eligibility group hasUsableOAuthToken/hasProviderSpecificSessionData/isKeylessEligibleConnection/hasUsableConnectionCredential (~70 lines). Either alone clears 1200 from here. Splitting three contributors' just-merged work mid-batch was the larger risk.",
|
||||
"_rebaseline_2026_09_07_streaming_wave": "Stacked growth from the SSE/streaming wave. open-sse/handlers/chatCore.ts 6021->6026 (+5): #12854 seeds the in-memory pending continuation state synchronously, before saveCallLogOperation's first await, closing the window where resolvePreviousResponseState finds nothing because the artifact write has not landed yet. open-sse/utils/stream.ts 3080->3098 (+18): #12828 emits the trailing usage-estimate chunk on the translate flush (#12151 had only covered passthrough, so translate-mode clients never saw token counts) and #12718 stops rebuilding a truncated summary from the collector's cap-dropped event array. Irreducible at the existing chokepoints — both are the flush/finalization points themselves. Covered by the continuation-store, translate-usage and collector-truncation suites."
|
||||
"_rebaseline_2026_09_07_streaming_wave": "Stacked growth from the SSE/streaming wave. open-sse/handlers/chatCore.ts 6021->6026 (+5): #12854 seeds the in-memory pending continuation state synchronously, before saveCallLogOperation's first await, closing the window where resolvePreviousResponseState finds nothing because the artifact write has not landed yet. open-sse/utils/stream.ts 3080->3098 (+18): #12828 emits the trailing usage-estimate chunk on the translate flush (#12151 had only covered passthrough, so translate-mode clients never saw token counts) and #12718 stops rebuilding a truncated summary from the collector's cap-dropped event array. Irreducible at the existing chokepoints — both are the flush/finalization points themselves. Covered by the continuation-store, translate-usage and collector-truncation suites.",
|
||||
"_rebaseline_2026_09_07_roundrobin_crosses_new_file_cap": "open-sse/services/combo/roundRobinCombo.ts 1198->1205, crossing the 1200 new-file cap. #12884 wires the quota-skip diagnostics into the round-robin attempt path so an ALL_TARGETS_SKIPPED 503 names which windows were exhausted instead of returning an opaque skip. The file was already at 1198 when #12811 lifted it out of combo.ts, so seven lines cross it; the diagnostics themselves live in quotaSkipDiagnostics.ts, under cap. Frozen rather than split: the natural next extraction is the attempt-loop body, which #12746/#12811 just moved and should settle before being cut again."
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CODEX_SPARK_QUOTA_WEEKLY,
|
||||
isCodexSparkLimitDescriptor,
|
||||
} from "../config/codexQuotaScopes.ts";
|
||||
import { inferWindowFamilyLabel } from "./quotaWindowLabel.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -110,6 +111,7 @@ function buildPercentageQuota(window: JsonRecord, displayName?: string): CodexUs
|
||||
// duration instead of assuming primary=session / secondary=weekly by position.
|
||||
const WEEKLY_MIN_WINDOW_SECONDS = 6 * 24 * 3600; // >= ~6d
|
||||
const SESSION_MAX_WINDOW_SECONDS = 6 * 3600; // <= ~6h
|
||||
const MONTHLY_MIN_WINDOW_SECONDS = 20 * 24 * 3600; // >= ~20d (ChatGPT 30d plans)
|
||||
|
||||
/**
|
||||
* A never-started window: `used_percent === 0` and the reset still spans the
|
||||
@@ -133,12 +135,15 @@ function isLatentWindow(window: JsonRecord): boolean {
|
||||
* e.g. a 7-day `primary_window` is labeled "Weekly" rather than "Session".
|
||||
* Returns undefined for durations that don't clearly map to either bucket.
|
||||
*/
|
||||
function windowDurationLabel(window: JsonRecord): "Session" | "Weekly" | undefined {
|
||||
function windowDurationLabel(window: JsonRecord): "Session" | "Weekly" | "Monthly" | undefined {
|
||||
const limitWindow = toNumber(
|
||||
getFieldValue(window, "limit_window_seconds", "limitWindowSeconds"),
|
||||
0
|
||||
);
|
||||
if (limitWindow <= 0) return undefined;
|
||||
const inferred = inferWindowFamilyLabel(limitWindow);
|
||||
if (inferred === "Monthly" || inferred === "Weekly" || inferred === "Session") return inferred;
|
||||
if (limitWindow >= MONTHLY_MIN_WINDOW_SECONDS) return "Monthly";
|
||||
if (limitWindow >= WEEKLY_MIN_WINDOW_SECONDS) return "Weekly";
|
||||
if (limitWindow <= SESSION_MAX_WINDOW_SECONDS) return "Session";
|
||||
return undefined;
|
||||
@@ -280,7 +285,7 @@ export function buildCodexUsageQuotas(dataValue: unknown): {
|
||||
const primaryLabel = windowDurationLabel(primaryWindow);
|
||||
quotas.session = buildPercentageQuota(
|
||||
primaryWindow,
|
||||
primaryLabel === "Weekly" ? primaryLabel : undefined
|
||||
primaryLabel === "Weekly" || primaryLabel === "Monthly" ? primaryLabel : undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ComboDiagnostics } from "../../utils/error.ts";
|
||||
import { COMBO_FAILURE_THRESHOLD, recordComboFailure } from "./failureTracker.ts";
|
||||
import { buildNoUpstreamResponseDiagnostics, buildRecoveryHint } from "./pinRecovery.ts";
|
||||
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
|
||||
import { collectQuotaWindowExclusions, formatQuotaSkipMessage } from "./quotaSkipDiagnostics.ts";
|
||||
import { recordComboRequest } from "../comboMetrics.ts";
|
||||
import { notifyWebhookEvent } from "../../../src/lib/webhookDispatcher.ts";
|
||||
import { parseModel } from "../model.ts";
|
||||
@@ -125,6 +126,9 @@ export async function dispatchWithCooldownRetry(opts: {
|
||||
excluded: [
|
||||
...[...state.exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
|
||||
...[...state.exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
|
||||
...(terminalReason === "all_targets_skipped"
|
||||
? collectQuotaWindowExclusions(state.orderedTargets)
|
||||
: []),
|
||||
],
|
||||
attemptOrder: state.comboAttemptOrder,
|
||||
terminalReason,
|
||||
@@ -408,10 +412,15 @@ export async function dispatchWithCooldownRetry(opts: {
|
||||
latencyMs,
|
||||
fallbackCount: state.fallbackCount,
|
||||
});
|
||||
const quotaSkip = formatQuotaSkipMessage(
|
||||
collectQuotaWindowExclusions(state.orderedTargets)
|
||||
);
|
||||
return withQuotaExhaustionClassification(
|
||||
errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
"Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
|
||||
quotaSkip
|
||||
? `Service temporarily unavailable: all targets were skipped by pre-dispatch filters (${quotaSkip})`
|
||||
: "Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
|
||||
buildComboDiag("all_targets_skipped"),
|
||||
{ code: "ALL_TARGETS_SKIPPED", type: "service_unavailable" }
|
||||
),
|
||||
|
||||
75
open-sse/services/combo/quotaSkipDiagnostics.ts
Normal file
75
open-sse/services/combo/quotaSkipDiagnostics.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Redacted-safe quota-window skip rows for ALL_TARGETS_SKIPPED 503 bodies.
|
||||
* Connection ids are prefix-only (8 chars). Labels match AUTH / usage API.
|
||||
*/
|
||||
|
||||
import { getQuotaCache } from "@/domain/quotaCache";
|
||||
import { formatQuotaWindowLabel } from "../quotaWindowLabel.ts";
|
||||
|
||||
export type QuotaSkipTarget = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
modelStr?: string;
|
||||
// `string | null`, not `string | undefined`: ResolvedComboTarget carries null for an
|
||||
// unpinned target, and this module only reads the field. Line 29 already narrows with
|
||||
// `typeof === "string"`, so null costs nothing here (TS2345 under typecheck:core).
|
||||
connectionId?: string | null;
|
||||
};
|
||||
|
||||
export type QuotaSkipExclusion = {
|
||||
provider: string;
|
||||
model?: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EXHAUSTED_USED_PERCENT = 99;
|
||||
|
||||
export function collectQuotaWindowExclusions(targets: QuotaSkipTarget[]): QuotaSkipExclusion[] {
|
||||
const seen = new Set<string>();
|
||||
const out: QuotaSkipExclusion[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const connectionId = typeof target.connectionId === "string" ? target.connectionId : "";
|
||||
if (!connectionId) continue;
|
||||
const entry = getQuotaCache(connectionId);
|
||||
if (!entry?.quotas) continue;
|
||||
|
||||
const provider =
|
||||
typeof target.provider === "string" && target.provider ? target.provider : "unknown";
|
||||
const model =
|
||||
(typeof target.modelStr === "string" && target.modelStr) ||
|
||||
(typeof target.model === "string" && target.model) ||
|
||||
undefined;
|
||||
const connPrefix = connectionId.slice(0, 8);
|
||||
|
||||
for (const [key, quota] of Object.entries(entry.quotas)) {
|
||||
const remaining = quota.remainingPercentage;
|
||||
if (typeof remaining !== "number" || !Number.isFinite(remaining)) continue;
|
||||
const used = Math.max(0, Math.min(100, 100 - remaining));
|
||||
if (used < EXHAUSTED_USED_PERCENT) continue;
|
||||
|
||||
const label = formatQuotaWindowLabel({
|
||||
key,
|
||||
displayName: quota.displayName,
|
||||
windowSeconds: quota.windowSeconds,
|
||||
});
|
||||
const reason = `quota:${label} ${Math.round(used)}% conn:${connPrefix}`;
|
||||
const dedupe = `${provider}|${model ?? ""}|${reason}`;
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
out.push({
|
||||
provider,
|
||||
...(model ? { model } : {}),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatQuotaSkipMessage(exclusions: QuotaSkipExclusion[]): string | null {
|
||||
if (exclusions.length === 0) return null;
|
||||
const windows = [...new Set(exclusions.map((row) => row.reason.replace(/^quota:/, "")))];
|
||||
return windows.slice(0, 4).join("; ");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../../utils/error.ts";
|
||||
import { buildRecoveryHint } from "./pinRecovery.ts";
|
||||
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
|
||||
import { collectQuotaWindowExclusions, formatQuotaSkipMessage } from "./quotaSkipDiagnostics.ts";
|
||||
import { recordComboRequest } from "../comboMetrics.ts";
|
||||
import {
|
||||
expandComboSystemPromptIfPresent,
|
||||
@@ -1152,16 +1153,21 @@ export async function handleRoundRobinCombo({
|
||||
|
||||
if (!lastStatus) {
|
||||
if (recordedAttempts === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
|
||||
type: "service_unavailable",
|
||||
code: "ALL_TARGETS_SKIPPED",
|
||||
},
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
const quotaExcluded = collectQuotaWindowExclusions(filteredTargets);
|
||||
const quotaSkip = formatQuotaSkipMessage(quotaExcluded);
|
||||
return errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
quotaSkip
|
||||
? `Service temporarily unavailable: all targets were skipped by pre-dispatch filters (${quotaSkip})`
|
||||
: "Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
|
||||
{
|
||||
poolSize: filteredTargets.length,
|
||||
attempted: 0,
|
||||
excluded: quotaExcluded,
|
||||
attemptOrder: [],
|
||||
terminalReason: "all_targets_skipped",
|
||||
},
|
||||
{ code: "ALL_TARGETS_SKIPPED", type: "service_unavailable" }
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
|
||||
73
open-sse/services/quotaWindowLabel.ts
Normal file
73
open-sse/services/quotaWindowLabel.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared duration-aware quota window labels for AUTH preflight and /api/usage.
|
||||
* Routing keys (`session`, `weekly`) stay position-based; only the display
|
||||
* string follows the real window length so a 30-day primary window is not
|
||||
* reported as "session (5h)".
|
||||
*/
|
||||
|
||||
const SESSION_MAX_SECONDS = 6 * 3600;
|
||||
const WEEKLY_MAX_SECONDS = 8 * 24 * 3600;
|
||||
const MONTHLY_MAX_SECONDS = 40 * 24 * 3600;
|
||||
const GENERIC_FAMILY = new Set(["session", "weekly", "monthly"]);
|
||||
|
||||
export type QuotaWindowLabelInput = {
|
||||
key: string;
|
||||
displayName?: string | null;
|
||||
windowSeconds?: number | null;
|
||||
};
|
||||
|
||||
function toPositiveSeconds(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatWindowDuration(windowSeconds: number): string {
|
||||
if (windowSeconds % 86400 === 0) return `${windowSeconds / 86400}d`;
|
||||
if (windowSeconds % 3600 === 0) return `${windowSeconds / 3600}h`;
|
||||
return `${Math.round(windowSeconds)}s`;
|
||||
}
|
||||
|
||||
export function inferWindowFamilyLabel(windowSeconds: number): string | undefined {
|
||||
if (windowSeconds <= SESSION_MAX_SECONDS) return "Session";
|
||||
if (windowSeconds <= WEEKLY_MAX_SECONDS) return "Weekly";
|
||||
if (windowSeconds <= MONTHLY_MAX_SECONDS) return "Monthly";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeFamily(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a duration-aware label. When usage already stamped a generic family
|
||||
* (`Weekly`) on a 30-day window, replace it with Monthly so AUTH and the
|
||||
* usage API agree on the reset window.
|
||||
*/
|
||||
export function formatQuotaWindowLabel(input: QuotaWindowLabelInput): string {
|
||||
const seconds = toPositiveSeconds(input.windowSeconds);
|
||||
const duration = seconds != null ? formatWindowDuration(seconds) : null;
|
||||
const inferred = seconds != null ? inferWindowFamilyLabel(seconds) : undefined;
|
||||
const named = typeof input.displayName === "string" ? input.displayName.trim() : "";
|
||||
const key = typeof input.key === "string" && input.key.trim() ? input.key.trim() : "quota";
|
||||
|
||||
let base = named || inferred || key;
|
||||
if (inferred && named && GENERIC_FAMILY.has(normalizeFamily(named)) && inferred !== named) {
|
||||
base = inferred;
|
||||
}
|
||||
|
||||
if (duration && !base.toLowerCase().includes(duration.toLowerCase())) {
|
||||
return `${base} (${duration})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function formatQuotaUsageReason(
|
||||
input: QuotaWindowLabelInput,
|
||||
usedPercentage: number
|
||||
): string {
|
||||
return `${formatQuotaWindowLabel(input)} usage ${Math.round(usedPercentage)}%`;
|
||||
}
|
||||
@@ -53,6 +53,8 @@ interface QuotaInfo {
|
||||
// percentage; `false` means "unknown", so callers must not treat the
|
||||
// defaulted-to-0 `remainingPercentage` as genuine exhaustion.
|
||||
fractionReported?: boolean;
|
||||
displayName?: string;
|
||||
windowSeconds?: number | null;
|
||||
}
|
||||
|
||||
interface QuotaCacheEntry {
|
||||
@@ -70,6 +72,8 @@ interface QuotaWindowStatus {
|
||||
usedPercentage: number;
|
||||
resetAt: string | null;
|
||||
reachedThreshold: boolean;
|
||||
displayName?: string;
|
||||
windowSeconds?: number | null;
|
||||
}
|
||||
|
||||
export interface QuotaWindowObservation {
|
||||
@@ -250,6 +254,12 @@ function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaIn
|
||||
const result: Record<string, QuotaInfo> = {};
|
||||
for (const [key, q] of Object.entries(rawQuotas)) {
|
||||
if (q && typeof q === "object") {
|
||||
const windowSeconds =
|
||||
typeof q.windowSeconds === "number" && Number.isFinite(q.windowSeconds)
|
||||
? q.windowSeconds
|
||||
: typeof q.window_seconds === "number" && Number.isFinite(q.window_seconds)
|
||||
? q.window_seconds
|
||||
: null;
|
||||
result[key] = {
|
||||
remainingPercentage:
|
||||
safePercentage(q.remainingPercentage) ??
|
||||
@@ -258,6 +268,10 @@ function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaIn
|
||||
// #10095 — thread through the "did upstream actually report this
|
||||
// window's fraction" signal (see UsageQuota in usage/quota.ts).
|
||||
fractionReported: q.fractionReported === false ? false : undefined,
|
||||
...(typeof q.displayName === "string" && q.displayName.trim()
|
||||
? { displayName: q.displayName.trim() }
|
||||
: {}),
|
||||
...(windowSeconds != null ? { windowSeconds } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -642,6 +656,8 @@ export function getQuotaWindowStatus(
|
||||
: remainingPercentage <= 0
|
||||
? true
|
||||
: usedPercentage >= thresholdPercent,
|
||||
...(window.displayName ? { displayName: window.displayName } : {}),
|
||||
...(window.windowSeconds != null ? { windowSeconds: window.windowSeconds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ import {
|
||||
getQuotaScopeLabelForProvider,
|
||||
isAntigravityQuotaProvider,
|
||||
} from "@omniroute/open-sse/services/antigravityQuotaFamily.ts";
|
||||
import { rehydrateAntigravityFamilyLocksForConnections, persistAntigravityFamilyCooldownIfQuota } from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts";
|
||||
import {
|
||||
rehydrateAntigravityFamilyLocksForConnections,
|
||||
persistAntigravityFamilyCooldownIfQuota,
|
||||
} from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts";
|
||||
import { markQuotaPreflightAccountUnavailable } from "./quotaPreflightUnavailable.ts";
|
||||
import { getCreditsMode } from "@omniroute/open-sse/services/antigravityCredits.ts";
|
||||
import { preferAntigravityConnectionsWithStoredProject } from "@omniroute/open-sse/services/antigravityProjectPersistence.ts";
|
||||
@@ -112,6 +115,7 @@ import {
|
||||
toCodexBaseQuotaWindowName,
|
||||
toCodexScopedQuotaWindowName,
|
||||
} from "@omniroute/open-sse/config/codexQuotaScopes.ts";
|
||||
import { formatQuotaUsageReason } from "@omniroute/open-sse/services/quotaWindowLabel.ts";
|
||||
import {
|
||||
getCodexChildCooldown,
|
||||
isCodexChildUnavailable,
|
||||
@@ -380,7 +384,16 @@ export function evaluateQuotaLimitPolicy(
|
||||
policy.thresholdPercent
|
||||
);
|
||||
if (!status?.reachedThreshold) continue;
|
||||
reasons.push(`${effectiveWindowName} usage ${Math.round(status.usedPercentage)}%`);
|
||||
reasons.push(
|
||||
formatQuotaUsageReason(
|
||||
{
|
||||
key: effectiveWindowName,
|
||||
displayName: status.displayName,
|
||||
windowSeconds: status.windowSeconds,
|
||||
},
|
||||
status.usedPercentage
|
||||
)
|
||||
);
|
||||
resetCandidates.push(status.resetAt);
|
||||
}
|
||||
|
||||
@@ -2428,7 +2441,7 @@ export function isAgentrouterConnectionQuotaScope(
|
||||
}
|
||||
|
||||
async function resolveDailyResetForProvider(
|
||||
provider: string | null,
|
||||
provider: string | null
|
||||
): Promise<{ timezone?: unknown; hour?: unknown } | null> {
|
||||
if (!provider) return null;
|
||||
try {
|
||||
@@ -2666,7 +2679,7 @@ export async function markAccountUnavailable(
|
||||
effectiveProviderProfile,
|
||||
null,
|
||||
null,
|
||||
await resolveDailyResetForProvider(provider),
|
||||
await resolveDailyResetForProvider(provider)
|
||||
);
|
||||
|
||||
// T-PROBE: probe-origin failures (model test-all) must never remove the
|
||||
@@ -2920,7 +2933,13 @@ export async function markAccountUnavailable(
|
||||
"AUTH",
|
||||
`Model-only lockout for ${provider}:${model} — ${status} ${reason} ${Math.ceil(lockout.cooldownMs / 1000)}s (failureCount=${lockout.failureCount}, connection stays active)`
|
||||
);
|
||||
persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason });
|
||||
persistAntigravityFamilyCooldownIfQuota({
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
cooldownMs: lockout.cooldownMs,
|
||||
reason,
|
||||
});
|
||||
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
|
||||
}
|
||||
const result = fallbackResult;
|
||||
|
||||
101
tests/unit/quota-window-label.test.ts
Normal file
101
tests/unit/quota-window-label.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatQuotaUsageReason,
|
||||
formatQuotaWindowLabel,
|
||||
} from "../../open-sse/services/quotaWindowLabel.ts";
|
||||
import { collectQuotaWindowExclusions } from "../../open-sse/services/combo/quotaSkipDiagnostics.ts";
|
||||
import { __clearForTests, setQuotaCache } from "../../src/domain/quotaCache.ts";
|
||||
import { evaluateQuotaLimitPolicy } from "../../src/sse/services/auth.ts";
|
||||
import { buildCodexUsageQuotas } from "../../open-sse/services/codexUsageQuotas.ts";
|
||||
|
||||
test("formatQuotaWindowLabel keeps the routing key when no duration metadata exists", () => {
|
||||
assert.equal(formatQuotaWindowLabel({ key: "session" }), "session");
|
||||
assert.equal(formatQuotaUsageReason({ key: "session" }, 100), "session usage 100%");
|
||||
});
|
||||
|
||||
test("formatQuotaWindowLabel prefers Monthly for a 30-day window labeled Weekly", () => {
|
||||
assert.equal(
|
||||
formatQuotaWindowLabel({
|
||||
key: "session",
|
||||
displayName: "Weekly",
|
||||
windowSeconds: 2_592_000,
|
||||
}),
|
||||
"Monthly (30d)"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatQuotaWindowLabel keeps a genuine 5h session label", () => {
|
||||
assert.equal(
|
||||
formatQuotaWindowLabel({
|
||||
key: "session",
|
||||
displayName: "Session",
|
||||
windowSeconds: 18_000,
|
||||
}),
|
||||
"Session (5h)"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas labels a 30-day primary window Monthly, not Weekly", () => {
|
||||
const { quotas } = buildCodexUsageQuotas({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 100,
|
||||
limit_window_seconds: 2_592_000,
|
||||
reset_after_seconds: 100_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(quotas.session);
|
||||
assert.equal(quotas.session.displayName, "Monthly");
|
||||
assert.equal(quotas.session.windowSeconds, 2_592_000);
|
||||
});
|
||||
|
||||
test("AUTH reasons match usage duration labels for a 30-day Codex session window", () => {
|
||||
__clearForTests();
|
||||
const connectionId = "quota-label-conn-30d";
|
||||
setQuotaCache(connectionId, "codex", {
|
||||
session: {
|
||||
remainingPercentage: 0,
|
||||
resetAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
displayName: "Weekly",
|
||||
windowSeconds: 2_592_000,
|
||||
},
|
||||
});
|
||||
|
||||
const evaluation = evaluateQuotaLimitPolicy("codex", {
|
||||
id: connectionId,
|
||||
providerSpecificData: {
|
||||
limitPolicy: { enabled: true, thresholdPercent: 99, windows: ["session"] },
|
||||
codexLimitPolicy: { use5h: true, useWeekly: false },
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(evaluation.blocked, true);
|
||||
assert.deepEqual(evaluation.reasons, ["Monthly (30d) usage 100%"]);
|
||||
});
|
||||
|
||||
test("collectQuotaWindowExclusions lists redacted-safe quota windows for 503 bodies", () => {
|
||||
__clearForTests();
|
||||
const connectionId = "abcd1234-ffff-4000-8000-000000000001";
|
||||
setQuotaCache(connectionId, "codex", {
|
||||
session: {
|
||||
remainingPercentage: 0,
|
||||
resetAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
displayName: "Weekly",
|
||||
windowSeconds: 2_592_000,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = collectQuotaWindowExclusions([
|
||||
{ provider: "codex", modelStr: "gpt-5.6-sol", connectionId },
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].provider, "codex");
|
||||
assert.equal(rows[0].model, "gpt-5.6-sol");
|
||||
assert.match(rows[0].reason, /quota:Monthly \(30d\) 100%/);
|
||||
assert.match(rows[0].reason, /conn:abcd1234/);
|
||||
assert.equal(rows[0].reason.includes(connectionId), false);
|
||||
});
|
||||
Reference in New Issue
Block a user