diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 838f954ce7..ae245f0472 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PeakHourProtectionEditor.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PeakHourProtectionEditor.tsx new file mode 100644 index 0000000000..ad45e5669f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PeakHourProtectionEditor.tsx @@ -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 = { + 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; + 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) => { + 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 ( +
+ 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." + )} + /> + updateWindow(window.id, { name: event.target.value })} + placeholder={providerText( + t, + "peakHourWindowNamePlaceholder", + "e.g. weekday peak" + )} + /> +
+
+ updateWindow(window.id, { startUtc: event.target.value })} + /> + updateWindow(window.id, { endUtc: event.target.value })} + /> +
+
+

+ {providerText(t, "peakHourDays", "Days (empty = every day)")} +

+
+ {PEAK_HOUR_PROTECTION_DAYS.map((day) => { + const active = (window.days || []).includes(day); + return ( + + ); + })} +
+
+ + )) + )} + + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 9c18a066bd..29d9996c04 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -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")} /> + setFormData({ ...formData, peakHourProtection })} + t={t} + /> + {formatPeakHourSummary(formData.peakHourProtection) && ( +

+ {formatPeakHourSummary(formData.peakHourProtection)} +

+ )} ; + +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(); +} diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index b54bf3669d..b26b5ebc29 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -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; } diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index c75eef108b..14d321ede3 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -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; + 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; + 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, 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({ diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index d8dc99bd24..5a3c046efa 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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, 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(); 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; diff --git a/tests/unit/peak-hour-protection.test.ts b/tests/unit/peak-hour-protection.test.ts new file mode 100644 index 0000000000..0b58ef6479 --- /dev/null +++ b/tests/unit/peak-hour-protection.test.ts @@ -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" }], + } + ); +});