mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
feat(resilience): add provider peak-hour protection (#11622)
Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
@@ -3093,11 +3093,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/sse/services/auth.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/sse/services/model.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Input, Select, Toggle } from "@/shared/components";
|
||||
import {
|
||||
PEAK_HOUR_PROTECTION_DAYS,
|
||||
type PeakHourProtectionConfig,
|
||||
type PeakHourProtectionDay,
|
||||
type PeakHourProtectionMode,
|
||||
type PeakHourWindow,
|
||||
} from "@/lib/providers/peakHourProtection";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
export const EMPTY_PEAK_HOUR_PROTECTION: PeakHourProtectionConfig = {
|
||||
enabled: false,
|
||||
mode: "block",
|
||||
windows: [],
|
||||
};
|
||||
|
||||
const DAY_LABELS: Record<PeakHourProtectionDay, string> = {
|
||||
mon: "Mon",
|
||||
tue: "Tue",
|
||||
wed: "Wed",
|
||||
thu: "Thu",
|
||||
fri: "Fri",
|
||||
sat: "Sat",
|
||||
sun: "Sun",
|
||||
};
|
||||
|
||||
function cloneConfig(value: unknown): PeakHourProtectionConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { ...EMPTY_PEAK_HOUR_PROTECTION, windows: [] };
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const mode: PeakHourProtectionMode = record.mode === "avoid" ? "avoid" : "block";
|
||||
const windows = Array.isArray(record.windows)
|
||||
? record.windows
|
||||
.filter((entry): entry is PeakHourWindow => !!entry && typeof entry === "object")
|
||||
.map((entry) => ({
|
||||
id: typeof entry.id === "string" ? entry.id : crypto.randomUUID(),
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
days: Array.isArray(entry.days)
|
||||
? entry.days.filter((day): day is PeakHourProtectionDay =>
|
||||
(PEAK_HOUR_PROTECTION_DAYS as readonly string[]).includes(day)
|
||||
)
|
||||
: [],
|
||||
startUtc: typeof entry.startUtc === "string" ? entry.startUtc : "06:00",
|
||||
endUtc: typeof entry.endUtc === "string" ? entry.endUtc : "10:00",
|
||||
}))
|
||||
: [];
|
||||
return { enabled: record.enabled === true, mode, windows };
|
||||
}
|
||||
|
||||
function newWindow(): PeakHourWindow {
|
||||
return { id: crypto.randomUUID(), name: "", days: [], startUtc: "06:00", endUtc: "10:00" };
|
||||
}
|
||||
|
||||
function weekdayWindow(startUtc: string, endUtc: string): PeakHourWindow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "Weekday peak",
|
||||
days: ["mon", "tue", "wed", "thu", "fri"],
|
||||
startUtc,
|
||||
endUtc,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePeakHourProtectionForSave(
|
||||
value: PeakHourProtectionConfig
|
||||
): PeakHourProtectionConfig | null {
|
||||
const windows = value.windows
|
||||
.map((window) => ({
|
||||
...(window.name?.trim() ? { name: window.name.trim() } : {}),
|
||||
...(window.days && window.days.length > 0 ? { days: window.days } : {}),
|
||||
startUtc: window.startUtc,
|
||||
endUtc: window.endUtc,
|
||||
}))
|
||||
.filter(
|
||||
(window) => /^\d{2}:\d{2}$/.test(window.startUtc) && /^\d{2}:\d{2}$/.test(window.endUtc)
|
||||
);
|
||||
if (!value.enabled && windows.length === 0) return null;
|
||||
return { enabled: value.enabled, mode: value.mode, windows };
|
||||
}
|
||||
|
||||
export function formatPeakHourSummary(value: unknown): string | null {
|
||||
const config = cloneConfig(value);
|
||||
if (!config.enabled || config.windows.length === 0) return null;
|
||||
const mode = config.mode === "avoid" ? "Avoid" : "Block";
|
||||
return `${mode} during ${config.windows.length} peak window${config.windows.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
export default function PeakHourProtectionEditor({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: PeakHourProtectionConfig;
|
||||
onChange: (next: PeakHourProtectionConfig) => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}) {
|
||||
const updateWindow = (id: string | undefined, patch: Partial<PeakHourWindow>) => {
|
||||
onChange({
|
||||
...value,
|
||||
windows: value.windows.map((window) => (window.id === id ? { ...window, ...patch } : window)),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDay = (window: PeakHourWindow, day: PeakHourProtectionDay) => {
|
||||
const days = new Set(window.days || []);
|
||||
if (days.has(day)) days.delete(day);
|
||||
else days.add(day);
|
||||
updateWindow(window.id, { days: Array.from(days) });
|
||||
};
|
||||
|
||||
const applyPreset = (provider: "deepseek" | "zai") => {
|
||||
const windows =
|
||||
provider === "deepseek"
|
||||
? [weekdayWindow("01:00", "04:00"), weekdayWindow("06:00", "10:00")]
|
||||
: [{ ...newWindow(), name: "Daily peak", startUtc: "06:00", endUtc: "10:00" }];
|
||||
onChange({ enabled: true, mode: value.mode, windows });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<Toggle
|
||||
checked={value.enabled}
|
||||
onChange={(enabled) => onChange({ ...value, enabled })}
|
||||
label={providerText(t, "peakHourProtectionLabel", "Peak-hour protection")}
|
||||
description={providerText(
|
||||
t,
|
||||
"peakHourProtectionDescription",
|
||||
"Block this connection during configured UTC peak-hour windows. This avoids uncertain peak multipliers instead of trying to price them."
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
label={providerText(t, "peakHourProtectionModeLabel", "Protection mode")}
|
||||
value={value.mode}
|
||||
options={[
|
||||
{
|
||||
value: "block",
|
||||
label: providerText(t, "peakHourProtectionModeBlock", "Block requests"),
|
||||
},
|
||||
{
|
||||
value: "avoid",
|
||||
label: providerText(t, "peakHourProtectionModeAvoid", "Avoid in routing"),
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, mode: event.target.value === "avoid" ? "avoid" : "block" })
|
||||
}
|
||||
hint={providerText(
|
||||
t,
|
||||
"peakHourProtectionModeHint",
|
||||
"Direct requests fail while active; combo/auto routing skips protected connections when alternatives exist."
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => applyPreset("deepseek")}>
|
||||
{providerText(t, "peakHourDeepSeekPreset", "Use DeepSeek preset")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => applyPreset("zai")}>
|
||||
{providerText(t, "peakHourZaiPreset", "Use Z.ai preset")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
onClick={() => onChange({ ...value, windows: [...value.windows, newWindow()] })}
|
||||
>
|
||||
{providerText(t, "peakHourAddWindow", "Add window")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{value.windows.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
{providerText(t, "peakHourNoWindows", "No peak-hour windows configured.")}
|
||||
</p>
|
||||
) : (
|
||||
value.windows.map((window) => (
|
||||
<div key={window.id} className="rounded-lg border border-border/70 bg-surface/50 p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<Input
|
||||
label={providerText(t, "peakHourWindowName", "Window name")}
|
||||
value={window.name || ""}
|
||||
onChange={(event) => updateWindow(window.id, { name: event.target.value })}
|
||||
placeholder={providerText(
|
||||
t,
|
||||
"peakHourWindowNamePlaceholder",
|
||||
"e.g. weekday peak"
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="delete"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
windows: value.windows.filter((entry) => entry.id !== window.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label={providerText(t, "peakHourStartUtc", "Start UTC")}
|
||||
type="time"
|
||||
value={window.startUtc}
|
||||
onChange={(event) => updateWindow(window.id, { startUtc: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={providerText(t, "peakHourEndUtc", "End UTC")}
|
||||
type="time"
|
||||
value={window.endUtc}
|
||||
onChange={(event) => updateWindow(window.id, { endUtc: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-xs font-medium text-text-muted">
|
||||
{providerText(t, "peakHourDays", "Days (empty = every day)")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PEAK_HOUR_PROTECTION_DAYS.map((day) => {
|
||||
const active = (window.days || []).includes(day);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={day}
|
||||
onClick={() => toggleDay(window, day)}
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "bg-amber-500 text-white"
|
||||
: "bg-muted/60 text-text-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{DAY_LABELS[day]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,12 @@ import AgentrouterConsoleFields from "./AgentrouterConsoleFields";
|
||||
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
|
||||
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
|
||||
import ProviderRegionField, { getProviderRegionConfig } from "./AlibabaProviderRegionField";
|
||||
import PeakHourProtectionEditor, {
|
||||
EMPTY_PEAK_HOUR_PROTECTION,
|
||||
formatPeakHourSummary,
|
||||
normalizePeakHourProtectionForSave,
|
||||
} from "../PeakHourProtectionEditor";
|
||||
import type { PeakHourProtectionConfig } from "@/lib/providers/peakHourProtection";
|
||||
export interface EditConnectionModalConnection {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -154,6 +160,7 @@ export default function EditConnectionModal({
|
||||
runtimeKey: "",
|
||||
connectorName: stringField(connectionProviderSpecificData?.connectorName) || "OmniRoute Codex",
|
||||
m365Tier: normalizeM365TierValue(connectionProviderSpecificData?.tier) as M365TierValue,
|
||||
peakHourProtection: { ...EMPTY_PEAK_HOUR_PROTECTION, windows: [] } as PeakHourProtectionConfig,
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -391,6 +398,22 @@ export default function EditConnectionModal({
|
||||
connectorName:
|
||||
stringField(connection.providerSpecificData?.connectorName) || "OmniRoute Codex",
|
||||
m365Tier: normalizeM365TierValue(connection.providerSpecificData?.tier) as M365TierValue,
|
||||
peakHourProtection: {
|
||||
...EMPTY_PEAK_HOUR_PROTECTION,
|
||||
...((connection.providerSpecificData?.peakHourProtection as PeakHourProtectionConfig) ||
|
||||
{}),
|
||||
windows: Array.isArray(
|
||||
(
|
||||
connection.providerSpecificData?.peakHourProtection as
|
||||
PeakHourProtectionConfig | undefined
|
||||
)?.windows
|
||||
)
|
||||
? [
|
||||
...(connection.providerSpecificData?.peakHourProtection as PeakHourProtectionConfig)
|
||||
.windows,
|
||||
]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
setExtraApiKeys(Array.isArray(existing) ? existing : []);
|
||||
@@ -699,6 +722,9 @@ export default function EditConnectionModal({
|
||||
}
|
||||
if (updates.providerSpecificData) {
|
||||
updates.providerSpecificData.disableCooling = formData.disableCooling ? true : undefined;
|
||||
updates.providerSpecificData.peakHourProtection = normalizePeakHourProtectionForSave(
|
||||
formData.peakHourProtection
|
||||
);
|
||||
// Explicit `null`, not `undefined`: the PUT route merges
|
||||
// { ...existing, ...incoming }, so omitting the key would keep the previous
|
||||
// choice and switching back to the default would never take effect.
|
||||
@@ -844,6 +870,16 @@ export default function EditConnectionModal({
|
||||
label={t("disableCoolingLabel")}
|
||||
description={t("disableCoolingDescription")}
|
||||
/>
|
||||
<PeakHourProtectionEditor
|
||||
value={formData.peakHourProtection}
|
||||
onChange={(peakHourProtection) => setFormData({ ...formData, peakHourProtection })}
|
||||
t={t}
|
||||
/>
|
||||
{formatPeakHourSummary(formData.peakHourProtection) && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{formatPeakHourSummary(formData.peakHourProtection)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<QuotaScrapingFields
|
||||
provider={provider}
|
||||
|
||||
165
src/lib/providers/peakHourProtection.ts
Normal file
165
src/lib/providers/peakHourProtection.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export const PEAK_HOUR_PROTECTION_MODES = ["block", "avoid"] as const;
|
||||
export const PEAK_HOUR_PROTECTION_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
export type PeakHourProtectionMode = (typeof PEAK_HOUR_PROTECTION_MODES)[number];
|
||||
export type PeakHourProtectionDay = (typeof PEAK_HOUR_PROTECTION_DAYS)[number];
|
||||
|
||||
export type PeakHourWindow = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
days?: PeakHourProtectionDay[];
|
||||
startUtc: string;
|
||||
endUtc: string;
|
||||
};
|
||||
|
||||
export type PeakHourProtectionConfig = {
|
||||
enabled: boolean;
|
||||
mode: PeakHourProtectionMode;
|
||||
windows: PeakHourWindow[];
|
||||
};
|
||||
|
||||
export type ActivePeakHourProtection = {
|
||||
active: true;
|
||||
mode: PeakHourProtectionMode;
|
||||
retryAfter: string;
|
||||
retryAfterSeconds: number;
|
||||
window: PeakHourWindow;
|
||||
};
|
||||
|
||||
export type InactivePeakHourProtection = { active: false };
|
||||
export type PeakHourProtectionState = ActivePeakHourProtection | InactivePeakHourProtection;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const DAY_BY_UTC_INDEX: PeakHourProtectionDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
const MAX_WINDOWS = 16;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function parseUtcTimeMinutes(value: unknown): number | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || !Number.isInteger(minute)) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function formatUtcTimeMinutes(minutes: number): string {
|
||||
const normalized = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
||||
const hour = Math.floor(normalized / 60);
|
||||
const minute = normalized % 60;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normalizeDays(value: unknown): PeakHourProtectionDay[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const days = value
|
||||
.map((day) => (typeof day === "string" ? day.trim().toLowerCase() : ""))
|
||||
.filter((day): day is PeakHourProtectionDay =>
|
||||
(PEAK_HOUR_PROTECTION_DAYS as readonly string[]).includes(day)
|
||||
);
|
||||
const unique = Array.from(new Set(days));
|
||||
return unique.length > 0 ? unique : undefined;
|
||||
}
|
||||
|
||||
export function normalizePeakHourProtection(value: unknown): PeakHourProtectionConfig | null {
|
||||
const record = asRecord(value);
|
||||
const rawWindows = Array.isArray(record.windows) ? record.windows : [];
|
||||
const windows: PeakHourWindow[] = [];
|
||||
|
||||
for (const entry of rawWindows.slice(0, MAX_WINDOWS)) {
|
||||
const window = asRecord(entry);
|
||||
const start = parseUtcTimeMinutes(window.startUtc);
|
||||
const end = parseUtcTimeMinutes(window.endUtc);
|
||||
if (start === null || end === null || start === end) continue;
|
||||
windows.push({
|
||||
...(typeof window.id === "string" && window.id.trim()
|
||||
? { id: window.id.trim().slice(0, 80) }
|
||||
: {}),
|
||||
...(typeof window.name === "string" && window.name.trim()
|
||||
? { name: window.name.trim().slice(0, 120) }
|
||||
: {}),
|
||||
...(normalizeDays(window.days) ? { days: normalizeDays(window.days) } : {}),
|
||||
startUtc: formatUtcTimeMinutes(start),
|
||||
endUtc: formatUtcTimeMinutes(end),
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = record.enabled === true;
|
||||
const mode = record.mode === "avoid" ? "avoid" : "block";
|
||||
if (!enabled && windows.length === 0) return null;
|
||||
return { enabled, mode, windows };
|
||||
}
|
||||
|
||||
export function getPeakHourProtectionConfig(
|
||||
providerSpecificData: unknown
|
||||
): PeakHourProtectionConfig | null {
|
||||
return normalizePeakHourProtection(asRecord(providerSpecificData).peakHourProtection);
|
||||
}
|
||||
|
||||
function isDayAllowed(window: PeakHourWindow, date: Date): boolean {
|
||||
if (!window.days || window.days.length === 0) return true;
|
||||
return window.days.includes(DAY_BY_UTC_INDEX[date.getUTCDay()]);
|
||||
}
|
||||
|
||||
function windowActiveAt(window: PeakHourWindow, date: Date): boolean {
|
||||
if (!isDayAllowed(window, date)) return false;
|
||||
const start = parseUtcTimeMinutes(window.startUtc);
|
||||
const end = parseUtcTimeMinutes(window.endUtc);
|
||||
if (start === null || end === null || start === end) return false;
|
||||
const now = date.getUTCHours() * 60 + date.getUTCMinutes();
|
||||
return start < end ? now >= start && now < end : now >= start || now < end;
|
||||
}
|
||||
|
||||
function nextWindowEndMs(window: PeakHourWindow, date: Date): number | null {
|
||||
const start = parseUtcTimeMinutes(window.startUtc);
|
||||
const end = parseUtcTimeMinutes(window.endUtc);
|
||||
if (start === null || end === null || start === end) return null;
|
||||
|
||||
const midnight = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
const now = date.getUTCHours() * 60 + date.getUTCMinutes();
|
||||
let endDayOffset = 0;
|
||||
if (start > end && now >= start) endDayOffset = 1;
|
||||
return midnight + (endDayOffset * MINUTES_PER_DAY + end) * 60_000;
|
||||
}
|
||||
|
||||
export function evaluatePeakHourProtection(
|
||||
providerSpecificData: unknown,
|
||||
now: Date = new Date()
|
||||
): PeakHourProtectionState {
|
||||
const config = getPeakHourProtectionConfig(providerSpecificData);
|
||||
if (!config?.enabled || config.windows.length === 0) return { active: false };
|
||||
|
||||
const active = config.windows
|
||||
.filter((window) => windowActiveAt(window, now))
|
||||
.map((window) => ({ window, endMs: nextWindowEndMs(window, now) }))
|
||||
.filter(
|
||||
(entry): entry is { window: PeakHourWindow; endMs: number } =>
|
||||
typeof entry.endMs === "number" &&
|
||||
Number.isFinite(entry.endMs) &&
|
||||
entry.endMs > now.getTime()
|
||||
)
|
||||
.sort((a, b) => a.endMs - b.endMs)[0];
|
||||
|
||||
if (!active) return { active: false };
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((active.endMs - now.getTime()) / 1000));
|
||||
return {
|
||||
active: true,
|
||||
mode: config.mode,
|
||||
retryAfter: new Date(active.endMs).toISOString(),
|
||||
retryAfterSeconds,
|
||||
window: active.window,
|
||||
};
|
||||
}
|
||||
|
||||
export function describePeakHourWindow(window: PeakHourWindow): string {
|
||||
const name = window.name ? `${window.name} ` : "";
|
||||
const days = window.days && window.days.length > 0 ? `${window.days.join(",")} ` : "daily ";
|
||||
return `${name}${days}${window.startUtc}-${window.endUtc} UTC`.trim();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { normalizeExcludedModelPatterns } from "@/domain/connectionModelRules";
|
||||
import { normalizeRoutingTags } from "@/domain/tagRouter";
|
||||
import { normalizeOpenRouterPreset } from "@/shared/constants/openRouterPreset";
|
||||
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
import { normalizePeakHourProtection } from "@/lib/providers/peakHourProtection";
|
||||
|
||||
export const CODEX_REASONING_EFFORT_VALUES = [
|
||||
"none",
|
||||
@@ -214,6 +215,15 @@ export function normalizeProviderSpecificData(
|
||||
delete normalized.disableCooling;
|
||||
}
|
||||
|
||||
if ("peakHourProtection" in normalized) {
|
||||
const peakHourProtection = normalizePeakHourProtection(normalized.peakHourProtection);
|
||||
if (peakHourProtection) {
|
||||
normalized.peakHourProtection = peakHourProtection;
|
||||
} else {
|
||||
delete normalized.peakHourProtection;
|
||||
}
|
||||
}
|
||||
|
||||
if ("autoFetchModels" in normalized && typeof normalized.autoFetchModels !== "boolean") {
|
||||
delete normalized.autoFetchModels;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,81 @@ const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high",
|
||||
const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["default", "priority", "fast", "flex"]);
|
||||
const CODEX_FINGERPRINT_MODE_VALUES = new Set(["off", "device", "session", "full"]);
|
||||
const CACHE_PASSTHROUGH_VALUES = new Set(["strip", "openai-format", "claude-format"]);
|
||||
const PEAK_HOUR_PROTECTION_MODES = new Set(["block", "avoid"]);
|
||||
const PEAK_HOUR_PROTECTION_DAYS = new Set(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]);
|
||||
export const MAX_PROVIDER_SPECIFIC_TIMEOUT_MS = 86_400_000; // 24h — operator cap, anti-DoS
|
||||
|
||||
// #6880 — per-connection prompt-cache capability override, extracted so
|
||||
// validateProviderSpecificData() stays under the complexity gate.
|
||||
function validatePeakHourProtectionBlock(value: unknown, ctx: z.RefinementCtx): void {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.peakHourProtection must be an object",
|
||||
path: ["peakHourProtection"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.enabled !== undefined && typeof record.enabled !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.peakHourProtection.enabled must be a boolean",
|
||||
path: ["peakHourProtection", "enabled"],
|
||||
});
|
||||
}
|
||||
if (record.mode !== undefined && !PEAK_HOUR_PROTECTION_MODES.has(String(record.mode))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.peakHourProtection.mode must be block or avoid",
|
||||
path: ["peakHourProtection", "mode"],
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(record.windows)) return;
|
||||
if (record.windows.length > 16) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.peakHourProtection.windows supports at most 16 windows",
|
||||
path: ["peakHourProtection", "windows"],
|
||||
});
|
||||
}
|
||||
record.windows.slice(0, 16).forEach((window, index) => {
|
||||
if (!window || typeof window !== "object" || Array.isArray(window)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "peak-hour windows must be objects",
|
||||
path: ["peakHourProtection", "windows", index],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = window as Record<string, unknown>;
|
||||
for (const key of ["startUtc", "endUtc"] as const) {
|
||||
if (typeof entry[key] !== "string" || !/^\d{1,2}:\d{2}$/.test(entry[key])) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.peakHourProtection.windows.${key} must be HH:MM UTC`,
|
||||
path: ["peakHourProtection", "windows", index, key],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entry.days !== undefined) {
|
||||
if (!Array.isArray(entry.days)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.peakHourProtection.windows.days must be an array",
|
||||
path: ["peakHourProtection", "windows", index, "days"],
|
||||
});
|
||||
} else if (entry.days.some((day) => !PEAK_HOUR_PROTECTION_DAYS.has(String(day)))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.peakHourProtection.windows.days contains an invalid day",
|
||||
path: ["peakHourProtection", "windows", index, "days"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateCacheBlock(data: Record<string, unknown>, ctx: z.RefinementCtx): void {
|
||||
const cache = data.cache;
|
||||
if (cache === undefined) return;
|
||||
@@ -188,6 +259,11 @@ export function validateProviderSpecificData(
|
||||
});
|
||||
}
|
||||
|
||||
const peakHourProtection = data.peakHourProtection;
|
||||
if (peakHourProtection !== undefined && peakHourProtection !== null) {
|
||||
validatePeakHourProtectionBlock(peakHourProtection, ctx);
|
||||
}
|
||||
|
||||
const autoFetchModels = data.autoFetchModels;
|
||||
if (autoFetchModels !== undefined && typeof autoFetchModels !== "boolean") {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
type ExclusiveConnectionLease,
|
||||
} from "@/lib/db/exclusiveConnectionLeases";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import {
|
||||
describePeakHourWindow,
|
||||
evaluatePeakHourProtection,
|
||||
} from "@/lib/providers/peakHourProtection";
|
||||
import { buildJinaEnvCredentials } from "@/lib/providers/jina";
|
||||
import { buildGeminiEnvCredentials } from "@/lib/providers/gemini";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
@@ -208,11 +212,6 @@ function asRecord(value: unknown): JsonRecord {
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
function toNullableNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const parsed = toNumber(value, Number.NaN);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
@@ -886,6 +885,32 @@ function formatConnectionPrefixesForLog(ids: Iterable<string>, max = 6): string
|
||||
.map((id) => `${id.slice(0, 8)}...`);
|
||||
return prefixes.length > 0 ? prefixes.join(",") : "none";
|
||||
}
|
||||
function buildPeakHourProtectionRateLimitedResult(
|
||||
provider: string,
|
||||
blockedByPeakHour: Array<{
|
||||
id: string;
|
||||
retryAfter: string;
|
||||
windowSummary: string;
|
||||
}>
|
||||
) {
|
||||
const retryAfter =
|
||||
getEarliestFutureDate(blockedByPeakHour.map((entry) => entry.retryAfter)) ||
|
||||
new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
const blockedSummary = blockedByPeakHour
|
||||
.map((entry) => `${entry.id.slice(0, 8)}(${entry.windowSummary})`)
|
||||
.join("; ");
|
||||
|
||||
log.info("AUTH", `${provider} | peak-hour protection filtered account(s): ${blockedSummary}`);
|
||||
|
||||
return {
|
||||
allRateLimited: true,
|
||||
retryAfter,
|
||||
retryAfterHuman: formatRetryAfter(retryAfter),
|
||||
lastError: `All ${provider} accounts blocked by peak-hour protection`,
|
||||
lastErrorCode: 429,
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuotaPreflightRateLimitedResult(
|
||||
provider: string,
|
||||
blockedByPreflight: Array<{
|
||||
@@ -1743,7 +1768,38 @@ export async function getProviderCredentials(
|
||||
return null;
|
||||
}
|
||||
|
||||
let policyEligibleConnections = availableConnections;
|
||||
let peakHourEligibleConnections = availableConnections;
|
||||
const blockedByPeakHour: Array<{ id: string; retryAfter: string; windowSummary: string }> = [];
|
||||
|
||||
if (!allowSuppressedConnections) {
|
||||
peakHourEligibleConnections = availableConnections.filter((connection) => {
|
||||
const peakHour = evaluatePeakHourProtection(connection.providerSpecificData);
|
||||
if (!peakHour.active) return true;
|
||||
blockedByPeakHour.push({
|
||||
id: connection.id,
|
||||
retryAfter: peakHour.retryAfter,
|
||||
windowSummary: describePeakHourWindow(peakHour.window),
|
||||
});
|
||||
connectionFilterStatus.set(connection.id, "peakHourProtected");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (blockedByPeakHour.length > 0) {
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${provider} | peak-hour protection filtered ${blockedByPeakHour.length} account(s): ${blockedByPeakHour
|
||||
.map((entry) => `${entry.id.slice(0, 8)}(${entry.windowSummary})`)
|
||||
.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (peakHourEligibleConnections.length === 0 && availableConnections.length > 0) {
|
||||
invalidateManagedLease(options, "QUOTA_UNAVAILABLE");
|
||||
return buildPeakHourProtectionRateLimitedResult(provider, blockedByPeakHour);
|
||||
}
|
||||
|
||||
let policyEligibleConnections = peakHourEligibleConnections;
|
||||
const blockedByPolicy: Array<{
|
||||
id: string;
|
||||
reasons: string[];
|
||||
@@ -1752,13 +1808,13 @@ export async function getProviderCredentials(
|
||||
const quotaResults = new Map<string, { blocked: boolean; exhausted: boolean }>();
|
||||
|
||||
if (provider === "codex") {
|
||||
for (const connection of availableConnections) {
|
||||
for (const connection of peakHourEligibleConnections) {
|
||||
hydrateCodexQuotaCacheForRequest(connection, requestedModel);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bypassQuotaPolicy) {
|
||||
policyEligibleConnections = availableConnections.filter((connection) => {
|
||||
policyEligibleConnections = peakHourEligibleConnections.filter((connection) => {
|
||||
const evaluation = evaluateQuotaLimitPolicy(provider, connection, requestedModel);
|
||||
quotaResults.set(connection.id, { blocked: evaluation.blocked, exhausted: false });
|
||||
if (!evaluation.blocked) return true;
|
||||
|
||||
91
tests/unit/peak-hour-protection.test.ts
Normal file
91
tests/unit/peak-hour-protection.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
describePeakHourWindow,
|
||||
evaluatePeakHourProtection,
|
||||
normalizePeakHourProtection,
|
||||
} from "../../src/lib/providers/peakHourProtection.ts";
|
||||
|
||||
test("peak-hour protection activates inside weekday UTC windows", () => {
|
||||
const state = evaluatePeakHourProtection(
|
||||
{
|
||||
peakHourProtection: {
|
||||
enabled: true,
|
||||
mode: "block",
|
||||
windows: [
|
||||
{
|
||||
days: ["mon", "tue", "wed", "thu", "fri"],
|
||||
startUtc: "01:00",
|
||||
endUtc: "04:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
new Date("2026-08-24T01:30:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(state.active, true);
|
||||
assert.equal(state.mode, "block");
|
||||
assert.equal(state.retryAfter, "2026-08-24T04:00:00.000Z");
|
||||
assert.equal(state.retryAfterSeconds, 9000);
|
||||
});
|
||||
|
||||
test("peak-hour protection honors weekdays and end boundary", () => {
|
||||
const providerSpecificData = {
|
||||
peakHourProtection: {
|
||||
enabled: true,
|
||||
windows: [
|
||||
{
|
||||
days: ["mon", "tue", "wed", "thu", "fri"],
|
||||
startUtc: "06:00",
|
||||
endUtc: "10:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
evaluatePeakHourProtection(providerSpecificData, new Date("2026-08-22T06:30:00.000Z")),
|
||||
{ active: false }
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluatePeakHourProtection(providerSpecificData, new Date("2026-08-24T10:00:00.000Z")),
|
||||
{ active: false }
|
||||
);
|
||||
});
|
||||
|
||||
test("peak-hour protection supports daily Z.ai-style windows", () => {
|
||||
const state = evaluatePeakHourProtection(
|
||||
{
|
||||
peakHourProtection: {
|
||||
enabled: true,
|
||||
mode: "avoid",
|
||||
windows: [{ name: "Z.ai peak", startUtc: "06:00", endUtc: "10:00" }],
|
||||
},
|
||||
},
|
||||
new Date("2026-08-23T06:30:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(state.active, true);
|
||||
assert.equal(state.mode, "avoid");
|
||||
assert.equal(describePeakHourWindow(state.window), "Z.ai peak daily 06:00-10:00 UTC");
|
||||
});
|
||||
|
||||
test("normalizer drops malformed windows but keeps operator intent", () => {
|
||||
assert.deepEqual(
|
||||
normalizePeakHourProtection({
|
||||
enabled: true,
|
||||
mode: "avoid",
|
||||
windows: [
|
||||
{ startUtc: "bad", endUtc: "10:00" },
|
||||
{ days: ["mon", "nope", "mon"], startUtc: "6:00", endUtc: "10:00" },
|
||||
],
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
mode: "avoid",
|
||||
windows: [{ days: ["mon"], startUtc: "06:00", endUtc: "10:00" }],
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user