feat(resilience): expose model cooldown list with manual re-enable (#2146)

Integrated into release/v3.8.0 — adds model cooldowns dashboard card with real-time list and re-enable action. Domain module and unit tests added.
This commit is contained in:
Ramel Tecnologia
2026-05-11 21:38:26 -03:00
committed by GitHub
parent 704f686396
commit 95944dad92
6 changed files with 228 additions and 81 deletions

View File

@@ -65,13 +65,13 @@ function SectionDescription({
return (
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
<div>
<span className="font-semibold text-text-main">Scope:</span> {scope}
<span className="font-semibold text-text-main">Escopo:</span> {scope}
</div>
<div>
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
<span className="font-semibold text-text-main">Gatilho:</span> {trigger}
</div>
<div>
<span className="font-semibold text-text-main">Effect:</span> {effect}
<span className="font-semibold text-text-main">Efeito:</span> {effect}
</div>
</div>
);
@@ -216,12 +216,12 @@ function RequestQueueCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">speed</span>
<h2 className="text-lg font-bold">Request Queue &amp; Pacing</h2>
<h2 className="text-lg font-bold">Fila de Requisições e Ritmo</h2>
</div>
<SectionDescription
scope="Per request bucket"
trigger="Before a request is sent upstream"
effect="Queues requests, limits concurrency, and spaces requests out"
scope="Por fila de requisição"
trigger="Antes de enviar para o upstream"
effect="Enfileira requisições, limita concorrência e espaça as chamadas"
/>
</div>
<ActionRow
@@ -240,28 +240,29 @@ function RequestQueueCard({
</div>
<p className="mb-4 text-sm text-text-muted">
This layer only controls queueing and pacing. It does not write cooldowns or open breakers.
Esta camada controla apenas enfileiramento e ritmo. Ela não grava cooldown nem abre circuit
breaker.
</p>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{editing ? (
<>
<BooleanField
label="Auto-enable for API key providers"
description="Enable queue protection by default for active API key connections."
label="Autoativar para provedores com API key"
description="Ativa proteção de fila por padrão para conexões de API key ativas."
checked={draft.autoEnableApiKeyProviders}
onChange={(autoEnableApiKeyProviders) =>
setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders }))
}
/>
<NumberField
label="Requests per minute"
label="Requisições por minuto"
value={draft.requestsPerMinute}
min={1}
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
/>
<NumberField
label="Min time between requests"
label="Tempo mínimo entre requisições"
value={draft.minTimeBetweenRequestsMs}
suffix="ms"
onChange={(minTimeBetweenRequestsMs) =>
@@ -269,7 +270,7 @@ function RequestQueueCard({
}
/>
<NumberField
label="Concurrent requests"
label="Requisições concorrentes"
value={draft.concurrentRequests}
min={1}
onChange={(concurrentRequests) =>
@@ -277,7 +278,7 @@ function RequestQueueCard({
}
/>
<NumberField
label="Max queue wait"
label="Tempo máximo de espera em fila"
value={draft.maxWaitMs}
min={1}
suffix="ms"
@@ -287,31 +288,31 @@ function RequestQueueCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Auto-enable for API key providers</div>
<div className="text-xs text-text-muted">Autoativar para provedores com API key</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
{value.autoEnableApiKeyProviders ? "Ativado" : "Desativado"}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Requests per minute</div>
<div className="text-xs text-text-muted">Requisições por minuto</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.requestsPerMinute}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Min time between requests</div>
<div className="text-xs text-text-muted">Tempo mínimo entre requisições</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.minTimeBetweenRequestsMs)}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Concurrent requests</div>
<div className="text-xs text-text-muted">Requisições concorrentes</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.concurrentRequests}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Max queue wait</div>
<div className="text-xs text-text-muted">Tempo máximo de espera em fila</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.maxWaitMs)}
</div>
@@ -346,7 +347,7 @@ function ConnectionCooldownCard({
{editing ? (
<>
<NumberField
label="Base cooldown"
label="Cooldown base"
value={current.baseCooldownMs}
min={0}
suffix="ms"
@@ -355,8 +356,8 @@ function ConnectionCooldownCard({
}
/>
<BooleanField
label="Use upstream retry hints"
description="Use upstream retry-after/reset values when they are present."
label="Usar dicas de retry do upstream"
description="Usa valores de retry-after/reset do upstream quando disponíveis."
checked={current.useUpstreamRetryHints}
onChange={(useUpstreamRetryHints) =>
setDraft((prev) => ({
@@ -408,7 +409,7 @@ function ConnectionCooldownCard({
</p>
</div>
<NumberField
label="Max backoff steps"
label="Máximo de passos de backoff"
value={current.maxBackoffSteps}
min={0}
onChange={(maxBackoffSteps) =>
@@ -419,13 +420,13 @@ function ConnectionCooldownCard({
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Base cooldown</span>
<span className="text-text-muted">Cooldown base</span>
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Use upstream retry hints</span>
<span className="text-text-muted">Usar dicas de retry do upstream</span>
<span className="font-mono text-text-main">
{current.useUpstreamRetryHints ? "Yes" : "No"}
{current.useUpstreamRetryHints ? "Sim" : "Não"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
@@ -439,7 +440,7 @@ function ConnectionCooldownCard({
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Max backoff steps</span>
<span className="text-text-muted">Máximo de passos de backoff</span>
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
</div>
</>
@@ -454,12 +455,12 @@ function ConnectionCooldownCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">timer_off</span>
<h2 className="text-lg font-bold">Connection Cooldown</h2>
<h2 className="text-lg font-bold">Cooldown de Conexão</h2>
</div>
<SectionDescription
scope="Single connection"
trigger="A connection returns a retryable upstream failure"
effect="Temporarily skips that connection and increases backoff on repeated failures"
scope="Conexão individual"
trigger="Quando uma conexão retorna falha transitória no upstream"
effect="Pula temporariamente essa conexão e aumenta backoff em falhas repetidas"
/>
</div>
<ActionRow
@@ -497,13 +498,13 @@ function ConnectionCooldownCard({
</div>
<p className="mb-4 text-sm text-text-muted">
Base cooldown covers retryable connection failures. When upstream retry hints are enabled,
explicit provider wait windows override the local base cooldown.
O cooldown base cobre falhas transitórias de conexão. Quando as dicas de retry do upstream
estão ativas, a janela explícita do provedor sobrescreve o cooldown local.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{renderProfile("oauth", "OAuth Providers", "lock")}
{renderProfile("apikey", "API Key Providers", "key")}
{renderProfile("oauth", "Provedores OAuth", "lock")}
{renderProfile("apikey", "Provedores API Key", "key")}
</div>
</Card>
);
@@ -532,7 +533,7 @@ function ProviderBreakerCard({
{editing ? (
<>
<NumberField
label="Failure threshold"
label="Limite de falhas"
value={current.failureThreshold}
min={1}
onChange={(failureThreshold) =>
@@ -540,7 +541,7 @@ function ProviderBreakerCard({
}
/>
<NumberField
label="Reset timeout"
label="Tempo para reset"
value={current.resetTimeoutMs}
min={1000}
suffix="ms"
@@ -552,11 +553,11 @@ function ProviderBreakerCard({
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Failure threshold</span>
<span className="text-text-muted">Limite de falhas</span>
<span className="font-mono text-text-main">{current.failureThreshold}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Reset timeout</span>
<span className="text-text-muted">Tempo para reset</span>
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
</div>
</>
@@ -573,12 +574,12 @@ function ProviderBreakerCard({
<span className="material-symbols-outlined text-xl text-primary">
electrical_services
</span>
<h2 className="text-lg font-bold">Provider Circuit Breaker</h2>
<h2 className="text-lg font-bold">Circuit Breaker por Provedor</h2>
</div>
<SectionDescription
scope="Whole provider"
trigger="Provider-level final transport/server failures after connection fallback is exhausted"
effect="Temporarily blocks that provider until the reset timeout elapses"
scope="Provedor inteiro"
trigger="Falhas finais de transporte/servidor após esgotar fallback de conexão"
effect="Bloqueia temporariamente esse provedor até o tempo de reset expirar"
/>
</div>
<ActionRow
@@ -597,13 +598,13 @@ function ProviderBreakerCard({
</div>
<p className="mb-4 text-sm text-text-muted">
Breaker runtime state is shown only on the Health page. Connection-scoped 429 rate limits
stay in Connection Cooldown and do not trip the provider breaker.
O estado em tempo real do breaker aparece apenas na página Saúde. Rate limits 429 no escopo
de conexão ficam no Cooldown de Conexão e não disparam o breaker de provedor.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{renderProfile("oauth", "OAuth Providers", "lock")}
{renderProfile("apikey", "API Key Providers", "key")}
{renderProfile("oauth", "Provedores OAuth", "lock")}
{renderProfile("apikey", "Provedores API Key", "key")}
</div>
</Card>
);
@@ -631,12 +632,12 @@ function WaitForCooldownCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
<h2 className="text-lg font-bold">Wait For Cooldown</h2>
<h2 className="text-lg font-bold">Aguardar Cooldown</h2>
</div>
<SectionDescription
scope="Current client request"
trigger="All candidate connections are already cooling down"
effect="Waits on the server side and retries when the earliest cooldown expires"
scope="Requisição atual do cliente"
trigger="Quando todas as conexões candidatas já estão em cooldown"
effect="Aguarda no servidor e tenta novamente quando o primeiro cooldown expira"
/>
</div>
<ActionRow
@@ -655,26 +656,26 @@ function WaitForCooldownCard({
</div>
<p className="mb-4 text-sm text-text-muted">
This only affects the current request. It does not write connection or provider state.
Isso afeta apenas a requisição atual. Não grava estado de conexão nem de provedor.
</p>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{editing ? (
<>
<BooleanField
label="Enable server-side waiting"
description="When enabled, OmniRoute waits for the earliest cooldown and retries automatically."
label="Ativar espera no servidor"
description="Quando ativo, o OmniRoute aguarda o primeiro cooldown expirar e tenta novamente automaticamente."
checked={draft.enabled}
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/>
<NumberField
label="Max retries"
label="Máximo de tentativas"
value={draft.maxRetries}
min={0}
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
/>
<NumberField
label="Max retry wait"
label="Tempo máximo de espera por tentativa"
value={draft.maxRetryWaitSec}
min={0}
suffix="sec"
@@ -684,17 +685,17 @@ function WaitForCooldownCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Enable server-side waiting</div>
<div className="text-xs text-text-muted">Ativar espera no servidor</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? "Enabled" : "Disabled"}
{value.enabled ? "Ativado" : "Desativado"}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Max retries</div>
<div className="text-xs text-text-muted">Máximo de tentativas</div>
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Max retry wait</div>
<div className="text-xs text-text-muted">Tempo máximo de espera por tentativa</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.maxRetryWaitSec}s
</div>
@@ -708,9 +709,19 @@ function WaitForCooldownCard({
export default function ResilienceTab() {
const notify = useNotificationStore();
const t = useTranslations("settings");
const [data, setData] = useState<ResilienceResponse | null>(null);
const [loading, setLoading] = useState(true);
const [savingSection, setSavingSection] = useState<string | null>(null);
const tx = useCallback(
(key: string, fallback: string) => {
if (typeof t.has === "function" && t.has(key as never)) {
return t(key as never);
}
return fallback;
},
[t]
);
useEffect(() => {
let mounted = true;
@@ -730,7 +741,11 @@ export default function ResilienceTab() {
waitForCooldown: json.waitForCooldown,
});
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to load resilience settings");
notify.error(
error instanceof Error
? error.message
: tx("failedLoadResilience", "Failed to load resilience settings")
);
} finally {
if (mounted) setLoading(false);
}
@@ -740,7 +755,7 @@ export default function ResilienceTab() {
return () => {
mounted = false;
};
}, [notify]);
}, [notify, tx]);
const savePatch = async (section: string, payload: Record<string, unknown>) => {
setSavingSection(section);
@@ -760,9 +775,13 @@ export default function ResilienceTab() {
providerBreaker: json.providerBreaker,
waitForCooldown: json.waitForCooldown,
});
notify.success("Resilience settings updated.");
notify.success(tx("savedSuccessfully", "Resilience settings updated."));
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to save resilience settings");
notify.error(
error instanceof Error
? error.message
: tx("saveFailed", "Failed to save resilience settings")
);
throw error;
} finally {
setSavingSection(null);
@@ -774,7 +793,7 @@ export default function ResilienceTab() {
<Card className="p-6">
<div className="flex items-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
Loading resilience settings...
{tx("loadingResilience", "Loading resilience settings...")}
</div>
</Card>
);
@@ -783,7 +802,9 @@ export default function ResilienceTab() {
if (!data) {
return (
<Card className="p-6">
<p className="text-sm text-text-muted">Unable to load resilience settings.</p>
<p className="text-sm text-text-muted">
{tx("failedLoadResilience", "Unable to load resilience settings.")}
</p>
</Card>
);
}
@@ -796,10 +817,14 @@ export default function ResilienceTab() {
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-xl text-primary">info</span>
<div>
<h2 className="text-lg font-bold text-text-main">Resilience Structure</h2>
<h2 className="text-lg font-bold text-text-main">
{tx("resilienceStructureTitle", "Resilience Structure")}
</h2>
<p className="mt-1 text-sm text-text-muted">
This page only configures behavior. Live breaker state is shown on the Health page.
Combo-specific retry and round-robin slot control remain on combo settings.
{tx(
"resilienceStructureDesc",
"This page only configures behavior. Live breaker state is shown on the Health page. Combo-specific retry and round-robin slot control remain on combo settings."
)}
</p>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import {
getAllModelLockouts,
clearModelLock,
type ModelLockoutInfo,
} from "@omniroute/open-sse/services/accountFallback";
export type AvailabilityReportItem = Pick<
ModelLockoutInfo,
"provider" | "model" | "reason" | "remainingMs" | "failureCount"
> & {
connectionId: string;
};
export function getAvailabilityReport(): AvailabilityReportItem[] {
return getAllModelLockouts().map((entry) => ({
provider: entry.provider,
model: entry.model,
connectionId: entry.connectionId,
reason: entry.reason,
remainingMs: entry.remainingMs,
failureCount: entry.failureCount,
}));
}
export function clearModelUnavailability(provider: string, model: string): boolean {
const all = getAllModelLockouts();
const matching = all.filter((e) => e.provider === provider && e.model === model);
if (matching.length === 0) return false;
let cleared = false;
for (const entry of matching) {
if (clearModelLock(provider, entry.connectionId, model)) cleared = true;
}
return cleared;
}
export function resetAllAvailability(): void {
const all = getAllModelLockouts();
for (const entry of all) {
clearModelLock(entry.provider, entry.connectionId, entry.model);
}
}

View File

@@ -3236,6 +3236,8 @@
"autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.",
"autoDisableThreshold": "Ban Threshold",
"autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.",
"resilienceStructureTitle": "Resilience Structure",
"resilienceStructureDesc": "This page only configures behavior. Live breaker state is shown on the Health page. Combo-specific retry and round-robin slot control remain on combo settings.",
"enableThinking": "Enable Thinking",
"maxThinkingTokens": "Max Thinking Tokens",
"enableProxy": "Enable Proxy",
@@ -3798,7 +3800,11 @@
"qdrantCleanupDesc": "Removes expired and old points based on",
"searching": "Searching...",
"cleaning": "Cleaning...",
"cleanNow": "Clean now"
"cleanNow": "Clean now",
"optional": "Optional",
"current": "Current",
"remove": "Remove",
"search": "Search"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -2413,6 +2413,7 @@
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",
"cooldownCleared": "Cooldown limpo para {model}",
"failedClearCooldown": "Falha ao limpar cooldown",
"freeTier": "Plano gratuito",
"loadingAvailability": "Carregando disponibilidade dos modelos...",
"clearCooldown": "Limpar",
"clearing": "Limpando...",
@@ -2880,10 +2881,12 @@
"timeoutMs": "Timeout (ms)",
"enableSystemPrompt": "Ativar Prompt do Sistema",
"systemPromptText": "Texto do Prompt do Sistema",
"autoDisableBannedAccounts": "Auto-Disable Banned Accounts",
"autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.",
"autoDisableThreshold": "Ban Threshold",
"autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.",
"autoDisableBannedAccounts": "Auto-desativar contas banidas",
"autoDisableDescription": "Marca permanentemente conexões de provedor como desativadas quando retornam sinais terminais de banimento (ex.: HTTP 403 'verify your account'). Isso remove a conexão da rotação de combos.",
"autoDisableThreshold": "Limite de banimento",
"autoDisableThresholdDesc": "Quantidade de sinais consecutivos de banimento antes da desativação permanente.",
"resilienceStructureTitle": "Estrutura de resiliência",
"resilienceStructureDesc": "Esta página configura apenas o comportamento. O estado ao vivo de circuit breaker aparece na página Saúde. Controle de retry específico por combo e slot de round-robin continuam nas configurações de combos.",
"enableThinking": "Ativar Raciocínio",
"maxThinkingTokens": "Máximo de Tokens de Raciocínio",
"enableProxy": "Ativar Proxy",
@@ -3469,7 +3472,11 @@
"qdrantCleanupDesc": "Remove pontos expirados e antigos, baseado em",
"searching": "Buscando...",
"cleaning": "Limpando...",
"cleanNow": "Limpar agora"
"cleanNow": "Limpar agora",
"optional": "Opcional",
"current": "Atual",
"remove": "Remover",
"search": "Buscar"
},
"contextRtk": {
"title": "Motor RTK",

View File

@@ -2374,6 +2374,7 @@
"allModelsNormal": "Todos os modelos estão respondendo normalmente.",
"cooldownCleared": "Tempo de espera liberado para {model}",
"failedClearCooldown": "Falha ao limpar o tempo de espera",
"freeTier": "Plano gratuito",
"loadingAvailability": "Carregando disponibilidade do modelo...",
"clearCooldown": "Limpar",
"clearing": "Limpando...",
@@ -2834,10 +2835,12 @@
"timeoutMs": "Tempo limite (ms)",
"enableSystemPrompt": "Habilitar prompt do sistema",
"systemPromptText": "Texto de prompt do sistema",
"autoDisableBannedAccounts": "Auto-Disable Banned Accounts",
"autoDisableDescription": "Permanently mark provider connections as deactivated if they return specific terminal ban signals (e.g. HTTP 403 'verify your account'). This removes them from the combo rotation.",
"autoDisableThreshold": "Ban Threshold",
"autoDisableThresholdDesc": "Consecutive ban signals required before permanent deactivation.",
"autoDisableBannedAccounts": "Auto-desativar contas banidas",
"autoDisableDescription": "Marca permanentemente conexões de provedor como desativadas quando retornam sinais terminais de banimento (ex.: HTTP 403 'verify your account'). Isso remove a conexão da rotação de combos.",
"autoDisableThreshold": "Limite de banimento",
"autoDisableThresholdDesc": "Quantidade de sinais consecutivos de banimento antes da desativação permanente.",
"resilienceStructureTitle": "Estrutura de resiliência",
"resilienceStructureDesc": "Esta página configura apenas o comportamento. O estado ao vivo de circuit breaker aparece na página Saúde. Controle de retry específico por combo e slot de round-robin continuam nas configurações de combos.",
"enableThinking": "Habilite o pensamento",
"maxThinkingTokens": "Tokens de pensamento máximo",
"enableProxy": "Habilitar proxy",
@@ -3439,7 +3442,11 @@
"qdrantCleanupDesc": "Remove pontos expirados e antigos, baseado em",
"searching": "Buscando...",
"cleaning": "Limpando...",
"cleanNow": "Limpar agora"
"cleanNow": "Limpar agora",
"optional": "Opcional",
"current": "Atual",
"remove": "Remover",
"search": "Buscar"
},
"contextRtk": {
"title": "RTK Engine",

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getAvailabilityReport,
clearModelUnavailability,
resetAllAvailability,
} from "../../src/domain/modelAvailability.ts";
import { lockModel, clearModelLock } from "@omniroute/open-sse/services/accountFallback";
const TEST_CONN = "test-conn-001";
function seed(provider: string, model: string, cooldownMs = 60_000) {
lockModel(provider, TEST_CONN, model, "quota_exhausted", cooldownMs, {});
}
function cleanup(provider: string, model: string) {
clearModelLock(provider, TEST_CONN, model);
}
test("getAvailabilityReport: returns empty array when no lockouts", () => {
const report = getAvailabilityReport();
const forProvider = report.filter((e) => e.provider === "test-empty-provider");
assert.equal(forProvider.length, 0);
});
test("getAvailabilityReport: returns active lockout with positive remainingMs", () => {
seed("test-prov", "test-model");
try {
const report = getAvailabilityReport();
const entry = report.find((e) => e.provider === "test-prov" && e.model === "test-model");
assert.ok(entry, "lockout should appear in report");
assert.ok(entry.remainingMs > 0, "remainingMs should be positive");
} finally {
cleanup("test-prov", "test-model");
}
});
test("clearModelUnavailability: removes matching lockout and returns true", () => {
seed("prov-clear", "model-clear");
const removed = clearModelUnavailability("prov-clear", "model-clear");
assert.equal(removed, true);
const report = getAvailabilityReport();
const stillThere = report.find((e) => e.provider === "prov-clear" && e.model === "model-clear");
assert.equal(stillThere, undefined);
});
test("clearModelUnavailability: returns false when no matching lockout", () => {
const removed = clearModelUnavailability("nonexistent-prov", "nonexistent-model");
assert.equal(removed, false);
});
test("resetAllAvailability: clears all seeded lockouts", () => {
seed("reset-prov-a", "reset-model-a");
seed("reset-prov-b", "reset-model-b");
resetAllAvailability();
const report = getAvailabilityReport();
const a = report.find((e) => e.provider === "reset-prov-a");
const b = report.find((e) => e.provider === "reset-prov-b");
assert.equal(a, undefined);
assert.equal(b, undefined);
});