diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx new file mode 100644 index 0000000000..5ca9122b93 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Card } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; + +type CooldownItem = { + provider: string; + model: string; + reason: string; + remainingMs: number; + unavailableSince: string; +}; + +function formatRemaining(ms: number): string { + const totalSec = Math.max(0, Math.ceil(ms / 1000)); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}m ${sec}s`; +} + +export default function ModelCooldownsCard() { + const notify = useNotificationStore(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [busyKey, setBusyKey] = useState(null); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/resilience/model-cooldowns", { cache: "no-store" }); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); + setItems(Array.isArray(json.items) ? json.items : []); + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to load cooldowns"); + } finally { + setLoading(false); + } + }, [notify]); + + useEffect(() => { + void load(); + const timer = setInterval(() => { + void load(); + }, 5000); + return () => clearInterval(timer); + }, [load]); + + const clearOne = useCallback( + async (provider: string, model: string) => { + const key = `${provider}::${model}`; + setBusyKey(key); + try { + const res = await fetch("/api/resilience/model-cooldowns", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, model }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); + notify.success(`Modelo reativado: ${provider}/${model}`); + await load(); + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to clear cooldown"); + } finally { + setBusyKey(null); + } + }, + [load, notify] + ); + + const clearAll = useCallback(async () => { + setBusyKey("ALL"); + try { + const res = await fetch("/api/resilience/model-cooldowns", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ all: true }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); + notify.success("Todos os modelos em cooldown foram reativados."); + await load(); + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to clear cooldowns"); + } finally { + setBusyKey(null); + } + }, [load, notify]); + + const hasItems = items.length > 0; + const sorted = useMemo(() => [...items].sort((a, b) => b.remainingMs - a.remainingMs), [items]); + + return ( + +
+
+

Modelos em cooldown

+

+ Lista de modelos temporariamente isolados por falha. Quando o cooldown expira, eles + voltam automaticamente. +

+
+
+ + +
+
+ +
+ {loading ? ( +

Carregando...

+ ) : !hasItems ? ( +

Nenhum modelo em cooldown no momento.

+ ) : ( + sorted.map((item) => { + const rowKey = `${item.provider}::${item.model}`; + return ( +
+
+

+ {item.provider}/{item.model} +

+

+ motivo: {item.reason} • restante: {formatRemaining(item.remainingMs)} +

+
+ +
+ ); + }) + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index f3ca6f7105..1639ffbb6d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -1,9 +1,11 @@ "use client"; -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { Button, Card } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; +import AutoDisableCard from "./AutoDisableCard"; +import ModelCooldownsCard from "./ModelCooldownsCard"; type RequestQueueSettings = { autoEnableApiKeyProviders: boolean; @@ -60,13 +62,13 @@ function SectionDescription({ return (
- Scope: {scope} + Escopo: {scope}
- Trigger: {trigger} + Gatilho: {trigger}
- Effect: {effect} + Efeito: {effect}
); @@ -211,12 +213,12 @@ function RequestQueueCard({
speed -

Request Queue & Pacing

+

Fila de Requisições e Ritmo

- 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.

{editing ? ( <> setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders })) } /> setDraft((prev) => ({ ...prev, requestsPerMinute }))} /> @@ -264,7 +267,7 @@ function RequestQueueCard({ } /> @@ -272,7 +275,7 @@ function RequestQueueCard({ } />
-
Auto-enable for API key providers
+
Autoativar para provedores com API key
- {value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"} + {value.autoEnableApiKeyProviders ? "Ativado" : "Desativado"}
-
Requests per minute
+
Requisições por minuto
{value.requestsPerMinute}
-
Min time between requests
+
Tempo mínimo entre requisições
{formatMs(value.minTimeBetweenRequestsMs)}
-
Concurrent requests
+
Requisições concorrentes
{value.concurrentRequests}
-
Max queue wait
+
Tempo máximo de espera em fila
{formatMs(value.maxWaitMs)}
@@ -341,7 +344,7 @@ function ConnectionCooldownCard({ {editing ? ( <> setDraft((prev) => ({ @@ -361,7 +364,7 @@ function ConnectionCooldownCard({ } /> @@ -372,17 +375,17 @@ function ConnectionCooldownCard({ ) : ( <>
- Base cooldown + Cooldown base {formatMs(current.baseCooldownMs)}
- Use upstream retry hints + Usar dicas de retry do upstream - {current.useUpstreamRetryHints ? "Yes" : "No"} + {current.useUpstreamRetryHints ? "Sim" : "Não"}
- Max backoff steps + Máximo de passos de backoff {current.maxBackoffSteps}
@@ -397,12 +400,12 @@ function ConnectionCooldownCard({
timer_off -

Connection Cooldown

+

Cooldown de Conexão

- 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.

- {renderProfile("oauth", "OAuth Providers", "lock")} - {renderProfile("apikey", "API Key Providers", "key")} + {renderProfile("oauth", "Provedores OAuth", "lock")} + {renderProfile("apikey", "Provedores API Key", "key")}
); @@ -456,7 +459,7 @@ function ProviderBreakerCard({ {editing ? ( <> @@ -464,7 +467,7 @@ function ProviderBreakerCard({ } />
- Failure threshold + Limite de falhas {current.failureThreshold}
- Reset timeout + Tempo para reset {formatMs(current.resetTimeoutMs)}
@@ -497,12 +500,12 @@ function ProviderBreakerCard({ electrical_services -

Provider Circuit Breaker

+

Circuit Breaker por Provedor

- 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.

- {renderProfile("oauth", "OAuth Providers", "lock")} - {renderProfile("apikey", "API Key Providers", "key")} + {renderProfile("oauth", "Provedores OAuth", "lock")} + {renderProfile("apikey", "Provedores API Key", "key")}
); @@ -555,12 +558,12 @@ function WaitForCooldownCard({
hourglass_top -

Wait For Cooldown

+

Aguardar Cooldown

- 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.

{editing ? ( <> setDraft((prev) => ({ ...prev, enabled }))} /> setDraft((prev) => ({ ...prev, maxRetries }))} />
-
Enable server-side waiting
+
Ativar espera no servidor
- {value.enabled ? "Enabled" : "Disabled"} + {value.enabled ? "Ativado" : "Desativado"}
-
Max retries
+
Máximo de tentativas
{value.maxRetries}
-
Max retry wait
+
Tempo máximo de espera por tentativa
{value.maxRetryWaitSec}s
@@ -632,9 +635,19 @@ function WaitForCooldownCard({ export default function ResilienceTab() { const notify = useNotificationStore(); + const t = useTranslations("settings"); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [savingSection, setSavingSection] = useState(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; @@ -654,7 +667,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); } @@ -664,7 +681,7 @@ export default function ResilienceTab() { return () => { mounted = false; }; - }, [notify]); + }, [notify, tx]); const savePatch = async (section: string, payload: Record) => { setSavingSection(section); @@ -684,9 +701,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); @@ -698,7 +719,7 @@ export default function ResilienceTab() {
progress_activity - Loading resilience settings... + {tx("loadingResilience", "Loading resilience settings...")}
); @@ -707,21 +728,29 @@ export default function ResilienceTab() { if (!data) { return ( -

Unable to load resilience settings.

+

+ {tx("failedLoadResilience", "Unable to load resilience settings.")} +

); } return (
+ +
info
-

Resilience Structure

+

+ {tx("resilienceStructureTitle", "Resilience Structure")} +

- 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." + )}

diff --git a/src/app/api/resilience/model-cooldowns/route.ts b/src/app/api/resilience/model-cooldowns/route.ts new file mode 100644 index 0000000000..f225614b38 --- /dev/null +++ b/src/app/api/resilience/model-cooldowns/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { + clearModelUnavailability, + getAvailabilityReport, + resetAllAvailability, +} from "@/domain/modelAvailability"; + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +export async function GET() { + try { + const items = getAvailabilityReport().sort((a, b) => b.remainingMs - a.remainingMs); + return NextResponse.json({ items }); + } catch (error: unknown) { + console.error("[API] GET /api/resilience/model-cooldowns error:", error); + return NextResponse.json( + { error: getErrorMessage(error, "Failed to load cooldowns") }, + { status: 500 } + ); + } +} + +export async function DELETE(request: Request) { + try { + const body = (await request.json().catch(() => ({}))) as { + provider?: string; + model?: string; + all?: boolean; + }; + + if (body.all) { + resetAllAvailability(); + return NextResponse.json({ ok: true, clearedAll: true }); + } + + const provider = typeof body.provider === "string" ? body.provider.trim() : ""; + const model = typeof body.model === "string" ? body.model.trim() : ""; + if (!provider || !model) { + return NextResponse.json({ error: "provider and model are required" }, { status: 400 }); + } + + const removed = clearModelUnavailability(provider, model); + return NextResponse.json({ ok: true, removed }); + } catch (error: unknown) { + console.error("[API] DELETE /api/resilience/model-cooldowns error:", error); + return NextResponse.json( + { error: getErrorMessage(error, "Failed to clear cooldown") }, + { status: 500 } + ); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 07b6ef1647..ef8298f9a4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3233,6 +3233,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", @@ -3795,7 +3797,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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 948ecf47c1..4a2fb453a2 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ebe95cadcb..a464b71368 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -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",