diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 831be6761c..c2cff0ed8e 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -125,6 +125,19 @@ export const ACCOUNT_DEACTIVATED_SIGNALS = [ "this service has been disabled in this account", ]; +// Custom banned signals — loaded from DB settings at runtime. +// Combined with ACCOUNT_DEACTIVATED_SIGNALS in isAccountDeactivated(). +let _customBannedSignals: string[] = []; + +export function setCustomBannedSignals(signals: string[]): void { + _customBannedSignals = signals; +} + +export function getMergedBannedSignals(): string[] { + if (_customBannedSignals.length === 0) return ACCOUNT_DEACTIVATED_SIGNALS; + return [...ACCOUNT_DEACTIVATED_SIGNALS, ..._customBannedSignals]; +} + // T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted. // Distinct from rate-limit 429 — the account won't recover until credits are added. export const CREDITS_EXHAUSTED_SIGNALS = [ @@ -240,7 +253,7 @@ const MALFORMED_REQUEST_PATTERNS = [ */ export function isAccountDeactivated(errorText: string): boolean { const lower = String(errorText || "").toLowerCase(); - return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig)); + return getMergedBannedSignals().some((sig) => lower.includes(sig)); } /** diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 7aa73b7f5d..511fe49861 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -23,6 +23,7 @@ import { Badge, Input, Modal, + ConfirmModal, CardSkeleton, OAuthModal, KiroOAuthWrapper, @@ -1508,6 +1509,10 @@ export default function ProviderDetailPage() { const [batchDeleting, setBatchDeleting] = useState(false); const [batchUpdating, setBatchUpdating] = useState<"activate" | "deactivate" | null>(null); const [batchRetesting, setBatchRetesting] = useState(false); + const [healthFilter, setHealthFilter] = useState("all"); + const [page, setPage] = useState(0); + const PAGE_SIZE = 50; + const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false); const commandCodeAuthWindowRef = useRef(null); const commandCodeAuthTimerRef = useRef(null); const pendingRiskActionRef = useRef<(() => void) | null>(null); @@ -2077,10 +2082,13 @@ export default function ProviderDetailPage() { }); }, [connections]); - const handleBatchDelete = async () => { + const handleBatchDeleteOpenModal = () => { if (selectedIds.size === 0) return; - if (!confirm(t("batchDeleteConfirm", { count: selectedIds.size }))) return; + setBatchDeleteConfirmOpen(true); + }; + const handleBatchDeleteConfirm = async () => { + setBatchDeleteConfirmOpen(false); setBatchDeleting(true); try { const res = await fetch("/api/providers", { @@ -4778,50 +4786,149 @@ export default function ProviderDetailPage() { icon="delete" loading={batchDeleting} disabled={bulkBusy && !batchDeleting} - onClick={handleBatchDelete} + onClick={handleBatchDeleteOpenModal} > {t("batchDeleteSelected", { count: selectedIds.size })} ); + const isHealthy = (c: ConnectionRowConnection): boolean => { + const s = c.testStatus; + return c.isActive !== false && (!s || s === "active" || s === "success"); + }; + const STATUS_FILTER_OPTIONS = [ + { value: "all", label: t("filterAll", "All") }, + { value: "active", label: t("filterActive", "Active") }, + { value: "error", label: t("filterError", "Error") }, + { value: "banned", label: t("filterBanned", "Banned") }, + { value: "credits_exhausted", label: t("filterCreditsExhausted", "Credits Exhausted") }, + ]; + const filtered = healthFilter === "all" + ? sorted + : sorted.filter((c) => { + if (healthFilter === "active") return isHealthy(c); + if (healthFilter === "error") return !isHealthy(c) && c.testStatus !== "banned" && c.testStatus !== "credits_exhausted"; + return c.testStatus === healthFilter; + }); + + const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const clampedPage = Math.min(page, totalFilteredPages - 1); + const pageStart = clampedPage * PAGE_SIZE; + const pageEnd = pageStart + PAGE_SIZE; + + const filterPills = ( +
+ {STATUS_FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ); + + const paginationBar = totalFilteredPages > 1 ? ( +
+ + {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} + +
+
+
+ ) : null; + if (!hasAnyTag) { + const pageConnections = filtered.slice(pageStart, pageEnd); + const allSelected = pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); + const someSelected = pageConnections.some((c) => selectedIds.has(c.id)); return ( <> -
- +
+
+ + {filterPills} +
{bulkActions}
- {sorted.map((conn, index) => ( + {pageConnections.length === 0 ? ( +
+ {t("noFilteredConnections", "No connections match the current filter.")} +
+ ) : ( + pageConnections.map((conn, index) => ( handleToggleSelectOne(conn.id)} onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} @@ -4927,15 +5034,17 @@ export default function ProviderDetailPage() { perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> - ))} + ))) + }
+ {paginationBar} ); } // Build ordered tag groups: untagged first, then alphabetically const groupMap = new Map(); - for (const conn of sorted) { + for (const conn of filtered) { const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; if (!groupMap.has(tag)) groupMap.set(tag, []); groupMap.get(tag)!.push(conn); @@ -4949,35 +5058,38 @@ export default function ProviderDetailPage() { return ( <> {selectedIds.size > 0 || connections.length > 0 ? ( -
- +
+
+ + {filterPills} +
{/* Distribute Proxies lives in the provider toolbar (top action bar); @@ -5313,6 +5425,16 @@ export default function ProviderDetailPage() { onClose={handleCloseAddApiKeyModal} /> )} + setBatchDeleteConfirmOpen(false)} + onConfirm={handleBatchDeleteConfirm} + title={t("batchDeleteConfirmTitle", "Delete connections")} + message={t("batchDeleteConfirm", { count: selectedIds.size })} + confirmText={t("batchDeleteConfirmButton", "Delete")} + cancelText={t("cancel", "Cancel")} + loading={batchDeleting} + /> {providerId === "codex" && applyCodexModalConnectionId && ( { + const keyword = newBannedKeyword.trim().toLowerCase(); + if (!keyword || customBannedSignals.includes(keyword)) return; + updateSetting("customBannedSignals", [...customBannedSignals, keyword]); + setNewBannedKeyword(""); + }; + + const removeBannedKeyword = (index: number) => { + const updated = customBannedSignals.filter((_, i) => i !== index); + updateSetting("customBannedSignals", updated); + }; + const handlePasswordChange = async (e) => { e.preventDefault(); if (passwords.new !== passwords.confirm) { @@ -368,6 +383,59 @@ export default function SecurityTab() { + + {/* Custom Banned Keywords */} + +
+
+

{t("customBannedSignals", "Banned Keywords")}

+

+ {t("customBannedSignalsDesc", "Additional keywords that trigger permanent account ban detection. Built-in keywords always apply.")} +

+
+
+ ) => setNewBannedKeyword(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") addBannedKeyword(); + }} + /> + +
+ {customBannedSignals.length > 0 ? ( +
+ {customBannedSignals.map((keyword, index) => ( +
+ {keyword} + +
+ ))} +
+ ) : ( +

+ {t("noCustomBannedSignals", "No custom keywords. Only built-in keywords are active.")} +

+ )} +
+
); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bbdc7fd292..4c5970ae5b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4000,6 +4000,14 @@ "batchRetestSelected": "Retest", "batchActivateSuccess": "Activated {count} connection(s)", "batchDeactivateSuccess": "Deactivated {count} connection(s)", + "batchDeleteConfirmTitle": "Delete connections", + "batchDeleteConfirmButton": "Delete", + "filterAll": "All", + "filterActive": "Active", + "filterError": "Error", + "filterBanned": "Banned", + "filterCreditsExhausted": "Credits Exhausted", + "noFilteredConnections": "No connections match the current filter.", "failedSetAlias": "Failed to set alias", "setAliasSuccess": "Alias {alias} set", "deleteAliasSuccess": "Alias {alias} deleted", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 844e0492e1..068cb97577 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3966,6 +3966,14 @@ "batchRetestSelected": "__MISSING__:Retest", "batchActivateSuccess": "__MISSING__:Activated {count} connection(s)", "batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)", + "batchDeleteConfirmTitle": "删除连接", + "batchDeleteConfirmButton": "删除", + "filterAll": "全部", + "filterActive": "健康", + "filterError": "错误", + "filterBanned": "封禁", + "filterCreditsExhausted": "额度耗尽", + "noFilteredConnections": "没有符合当前筛选条件的连接。", "failedSetAlias": "设置别名失败", "failedSaveConnection": "保存连接失败", "failedSaveConnectionRetry": "无法保存连接。请再试一次。", diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index c8ad61ad17..b589167505 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -1,4 +1,5 @@ import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck"; +import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts"; type JsonRecord = Record; @@ -15,7 +16,8 @@ export type RuntimeReloadSection = | "corsOrigins" | "ccBridgeTransforms" | "systemTransforms" - | "authzBypass"; + | "authzBypass" + | "bannedSignals"; export interface RuntimeReloadChange { section: RuntimeReloadSection; @@ -42,6 +44,7 @@ interface RuntimeSettingsSnapshot { ccBridgeTransforms: unknown; systemTransforms: unknown; authzBypass: AuthzBypassSnapshot; + customBannedSignals: string[]; } // Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the @@ -67,6 +70,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { ccBridgeTransforms: null, systemTransforms: null, authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT, + customBannedSignals: [], }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; @@ -246,6 +250,7 @@ export function buildRuntimeSettingsSnapshot( ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"), systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"), authzBypass: normalizeAuthzBypass(settings), + customBannedSignals: normalizeStringArray(settings.customBannedSignals), }; } @@ -526,6 +531,11 @@ export async function applyRuntimeSettings( markChanged("authzBypass"); } + if (force || hasChanged(currentSnapshot.customBannedSignals, previousSnapshot.customBannedSignals)) { + setCustomBannedSignals(currentSnapshot.customBannedSignals); + markChanged("bannedSignals"); + } + lastAppliedSnapshot = currentSnapshot; return changes; } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 6aab3d1bfd..42246413ca 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -123,6 +123,7 @@ export async function getSettings() { // `applyAuthzBypassSection` → `getAuthzBypassSnapshot()`. localOnlyManageScopeBypassEnabled: true, localOnlyManageScopeBypassPrefixes: ["/api/mcp/"], + customBannedSignals: [], proxyEnabled: true, perKeyProxyEnabled: false, }; diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 63aee1fab7..88b7b9a945 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -42,6 +42,7 @@ export const updateSettingsSchema = z.object({ showProviderTopologyOnHome: z.boolean().optional(), localOnlyManageScopeBypassEnabled: z.boolean().optional(), localOnlyManageScopeBypassPrefixes: z.array(z.string().max(200)).optional(), + customBannedSignals: z.array(z.string().max(200)).optional(), debugMode: z.boolean().optional(), hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), sidebarSectionOrder: z diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 0797b7f123..788508c7a8 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1186,3 +1186,64 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui ); clearProviderFailure("9router"); // cleanup }); + +// ── Custom banned signals (PR #3454) ────────────────────────────────────────── +// Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific +// permanent-ban phrasing via Settings → Security. These persist in the +// key_value settings store and are applied at boot + on hot-reload through +// setCustomBannedSignals(). Regression guard for the merge/detection behavior. + +const { + setCustomBannedSignals, + getMergedBannedSignals, + isAccountDeactivated, + ACCOUNT_DEACTIVATED_SIGNALS, +} = accountFallback; + +test("getMergedBannedSignals returns built-in list unchanged when no custom signals", () => { + setCustomBannedSignals([]); + const merged = getMergedBannedSignals(); + assert.deepEqual(merged, ACCOUNT_DEACTIVATED_SIGNALS); +}); + +test("getMergedBannedSignals appends custom signals to the built-in list", () => { + setCustomBannedSignals(["api key revoked", "tenant suspended"]); + const merged = getMergedBannedSignals(); + // Built-ins still present + for (const sig of ACCOUNT_DEACTIVATED_SIGNALS) { + assert.ok(merged.includes(sig), `built-in signal "${sig}" must survive merge`); + } + // Custom appended + assert.ok(merged.includes("api key revoked")); + assert.ok(merged.includes("tenant suspended")); + setCustomBannedSignals([]); // cleanup +}); + +test("isAccountDeactivated still matches built-in signals when custom list is empty", () => { + setCustomBannedSignals([]); + assert.equal(isAccountDeactivated("Your account has been suspended"), true); + assert.equal(isAccountDeactivated("rate limit exceeded, retry later"), false); +}); + +test("isAccountDeactivated matches a custom signal after setCustomBannedSignals", () => { + setCustomBannedSignals([]); + // Before registration the custom phrase is not a ban signal + assert.equal( + isAccountDeactivated("Error: API key revoked by administrator"), + false, + "custom phrase must not match before it is registered" + ); + + setCustomBannedSignals(["api key revoked"]); + // Case-insensitive substring match against the merged list + assert.equal( + isAccountDeactivated("Error: API key revoked by administrator"), + true, + "custom phrase must match once registered (case-insensitive substring)" + ); + + // Built-ins remain matchable alongside custom signals + assert.equal(isAccountDeactivated("account_deactivated"), true); + + setCustomBannedSignals([]); // cleanup — restore module state for other tests +});