docs/security/BAN_DETECTION.md
OmniRoute scans upstream error responses for signals that indicate a provider
account is permanently dead (suspended / deactivated / ToS-banned) and, when
matched, moves that connection into a terminal banned state so it is no
longer selected for requests. This is what the Security → Banned Keywords
settings card configures ("Additional keywords that trigger permanent account
ban detection. Built-in keywords always apply.").
This page documents the built-in list, the detection flow, its scope, how to add custom keywords safely, and how to recover a flagged connection. The terminal state itself is part of the resilience model — see RESILIENCE_GUIDE ("Terminal states").
Source of truth: open-sse/services/accountFallback.ts
(ACCOUNT_DEACTIVATED_SIGNALS, getMergedBannedSignals(), isAccountDeactivated()).
These 8 substrings always apply (case-insensitive), regardless of any custom list:
account_deactivated
account has been deactivated
account has been disabled
your account has been suspended
this account is deactivated
verify your account to continue (Antigravity / Google Cloud Code)
this service has been disabled in this account for violation (Antigravity)
this service has been disabled in this account (Antigravity)
This list evolves as providers change their ban wording. The authoritative copy is
ACCOUNT_DEACTIVATED_SIGNALSinopen-sse/services/accountFallback.ts; treat the block above as a snapshot.
Two adjacent, separate signal tables live in the same file and are not part of banned-keyword detection:
CREDITS_EXHAUSTED_SIGNALS — billing/quota depleted (insufficient_quota,
credit_balance_too_low, payment required, …) → terminal credits_exhausted.OAUTH_INVALID_TOKEN_SIGNALS — non-terminal; a token refresh can recover.Note: common transient phrases like rate limit / 429 are handled by the
rate-limit / connection-cooldown path and are not ban signals.
upstream error response
→ body stringified + lowercased
→ isAccountDeactivated(body): getMergedBannedSignals().some(sig => body.includes(sig)) [substring match]
→ match?
→ connection testStatus = "banned" (permanent — 1-year cooldown, never auto-recovers)
→ if setting `autoDisableBannedAccounts` is on → also isActive = false
→ connection is skipped during account selection (combo QUOTA_BLOCKING statuses)
isAccountDeactivated, accountFallback.ts).banned terminalization fires on a banned-signal body at any
HTTP status (via markAccountUnavailable → checkFallbackError). The
narrower deactivated label (isActive=false when the connection has no
spare API keys) is written by the inline chatCore.ts path on HTTP 401 / 403
(classified via classifyProviderError → ACCOUNT_DEACTIVATED). Note the
markAccountUnavailable() path writes a different terminal status —
expired — for the same ACCOUNT_DEACTIVATED signal (via
resolveTerminalConnectionStatus), so the same ban can surface as either
deactivated or expired depending on which path handled the response. (The
older code comment says "when a 401 body contains these strings" — that
understates the current behavior.)banned connection is excluded from selection everywhere terminal statuses
are filtered (isTerminalConnectionStatus, combo QUOTA_BLOCKING_CONNECTION_STATUSES).All providers. The check runs in the generic error-handling pipeline that every failed upstream request flows through — it is not gated to OAuth/subscription scrapers. The resulting terminal state is per connection, not per provider.
That said, the built-in strings are oriented toward subscription/OAuth providers with real ban risk (ChatGPT Web, Claude Web, Codex, Muse Spark, Antigravity). An API-key provider will only trip the detector if its error body literally contains one of the substrings.
Add or remove keywords in Security → Banned Keywords (persisted as the global
customBannedSignals setting via PATCH /api/settings). They are added to
the built-in list — never a replacement — and hot-reload on save (and at startup)
via setCustomBannedSignals(). Each keyword is capped at 200 characters; there is
no array-length limit.
⚠ False-positive risk — choose specific phrases. Detection is a raw substring match on the whole response body, and a match is permanent (1-year cooldown, manual recovery). A broad keyword can ban a perfectly healthy connection:
quota, limit, error, denied — appear in many transient errors.your account has been suspended for,
account permanently banned, violation of our terms.Prefer the longest unambiguous phrase the provider returns on a real ban. When in
doubt, watch the connection's lastError first, then add the exact wording.
Terminal banned / deactivated states never auto-recover (they are excluded
from the proactive-recovery tick — only unavailable cooldowns recover on their
own). An operator must clear them explicitly:
POST /api/providers/{id}/test); a successful probe resets testStatus to
active and clears the error fields.isActive = true.autoDisableBannedAccounts set
isActive = false, toggle it back on after fixing the account.There is no separate "clear ban flag" button — recovery is re-test, re-auth, or re-enable, matching the general terminal-state rule in RESILIENCE_GUIDE.
| Concern | File |
|---|---|
| Signal tables + match | open-sse/services/accountFallback.ts |
| Terminalization / persistence | src/sse/services/auth.ts (markAccountUnavailable, resolveTerminalConnectionStatus, clearAccountError) |
| Inline classification | open-sse/handlers/chatCore.ts, open-sse/services/errorClassifier.ts |
| Terminal-state recovery exclusion | src/lib/quota/connectionRecovery.ts |
| Custom-keyword runtime load | src/lib/config/runtimeSettings.ts (setCustomBannedSignals) |
| Settings UI | src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx |