Rebased onto the current release/v3.8.51 tip as part of a combined provider-retirement/provenance merge batch (Designer Web, Felo Web, Runtime, GPL-derived removal, Qwen Web already landed). Large conflict set (this is the biggest PR in the batch — the common ChatGPT Web provider touches chat, images, count-tokens, session leases, and combos). Conflicts resolved: - `open-sse/config/providers/registry/chatgpt-web/*`, `open-sse/executors/chatgpt-web*`, `open-sse/handlers/imageGeneration/providers/chatgptWeb.ts`, and their tests: kept deleted, matching the PR's stated scope. - `open-sse/config/providers/registry/minimax/web/index.ts`, `open-sse/handlers/imageGeneration/providers/geminiWeb.ts`, `open-sse/executors/gemini-web.ts`'s stale image-mode branch: base-drift collisions against already-merged sibling retirements (#11691, #11708) — kept deleted / dropped the dead code, since this PR's own branch forked before those merged. - `src/shared/constants/reservedProviderPrefixes.ts`, `open-sse/executors/index.ts`, `executorProxy.ts`, `virtualFactory.ts`, `autoStrategy.ts`, `src/lib/db/providers.ts`, `src/sse/handlers/chat.ts`: combined the Designer + Runtime (Felo/Qwen) + common-ChatGPT-Web retirement guard calls at each shared chokepoint — compute-once-then-OR pattern, consistent with prior combinations in this batch. - `src/sse/services/model.ts` / `src/sse/handlers/chatHelpers.ts`: adopted this PR's new `getModelInfoOrRetirementResponse()` central wrapper (a real improvement over ad-hoc try/catch), and extended it to also catch the Designer + Runtime retirement errors it didn't originally cover, so the consolidation doesn't regress the other two mechanisms. - `src/app/api/v1/images/edits/route.ts`: this PR moved the retirement check earlier (before `enforceApiKeyPolicy`) but left the old later call+catch block in place from base drift — removed the now-redundant duplicate `resolveImageRouteModel()` call and merged the Designer catch into the earlier one. - `open-sse/config/imageRegistry.ts`, `tests/snapshots/executors/executor-map.json` (`keyCount` recomputed to 133), `tests/snapshots/provider/translate-path.json`: same "both sides inserted a different retired provider at the same slot" pattern — resolved by dropping both. - `tests/unit/chatcore-executor-proxy.test.ts`, `provider-node-reserved-prefix.test.ts`, `combo-auto-candidate-expansion.test.ts`, `messages-count-tokens-route.test.ts`, `virtual-auto-combo.test.ts`: split into independent per-mechanism test blocks (established pattern); `virtual-auto-combo.test.ts`'s old "includes cookie web-session providers" positive-inclusion test (which used chatgpt-web as its example) was retired along with the provider and replaced by this PR's negative-exclusion test for the same slot. - `docs/architecture/ARCHITECTURE.md`, `CODEBASE_DOCUMENTATION.md` (+ 4 i18n mirrors), `README.md`, `FREE-TIERS-GUIDE.md`, `docs/diagrams/free-tier-budget.svg`, `docs/screenshots/free-tier-budget-card.svg`, `docs/reference/PROVIDER_REFERENCE.md`: recomputed every stale count from the real merged state — 104 executors (`countFiles` gate logic), 351 providers (regenerated via `gen:provider-reference`), 152/351 `hasFree` entries, 445/438/7 free-tier catalog rows, 13 ToS-avoid providers, budget-card regenerated via its real generator script. One doc conflict (`oauth/` module list) needed picking HEAD's side specifically — theirs still listed the already-removed `raycast` module instead of the real `openference`. - `config/quality/test-masking-allowlist.json`: additive merge of the PR's 17 `_deletedWithReplacement` entries alongside the batch's existing ones (one real duplicate-key mistake in my first pass, caught and fixed via a `object_pairs_hook` duplicate-key check before finalizing). Also fixed two real, unrelated-to-my-merge issues surfaced by the focused suite: - `tests/unit/resolve-web-provider-host.test.ts`: the PR's own test had a typo — it asserted `perplexity-web`'s resolved host as `"perplexity.ai"`, but the provider's registered `website` is `"https://www.perplexity.ai"` and the resolver returns the URL's `host` verbatim (no www-stripping), so the correct value is `"www.perplexity.ai"` (consistent with the same test's own `url` assertion). - `tests/unit/hard-session-lease-bypass-inventory.test.ts`: this golden call-site inventory was already stale on the pristine post-#11713 tip (confirmed via a throwaway probe worktree) — `src/lib/db/providers.ts`'s 3 connection-fallback sites and a third `src/app/api/providers/route.ts` site were never added to the golden list by the earlier-merged #11698/#11720 PRs. Updated it to the real current inventory (dated inline comments explain each delta and which PR introduced it), plus this PR's own legitimate deltas (image-edits duplicate-call removal, `ChatGptWebExecutor.execute()` site removed). Focused suite green (433/433 across executor-proxy, reserved-prefix, hard-session-lease-bypass-inventory, resolve-web-provider-host, retirement/runtime-block/source-retirement/management-retirement/image-handler-retirement, migration-168, combo-auto-candidate-expansion, virtual-auto-combo, executor-map-golden and siblings), plus `typecheck:core`, `check-file-size`, and `check-changelog-integrity` clean. Thanks for the thorough provenance-hold retirement work — appreciated.
11 KiB
title
| title |
|---|
| Account-Ban / Banned-Keyword Detection |
Account-Ban / Banned-Keyword Detection
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()).
Built-in keywords
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, …) → terminalcredits_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.
Detection flow
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 and `autoDisableBannedScope`
includes this connection (`all`, or `subscription` for OAuth/cookie/session)
→ also isActive = false. Prepaid API keys stay active when scope is
`subscription`.
→ connection is skipped during account selection (combo QUOTA_BLOCKING statuses)
- The match is a case-insensitive substring search on the response body
(
isAccountDeactivated,accountFallback.ts). - The permanent
bannedterminalization fires on a banned-signal body at any HTTP status (viamarkAccountUnavailable→checkFallbackError). The narrowerdeactivatedlabel (isActive=falsewhen the connection has no spare API keys) is written by the inlinechatCore.tspath on HTTP 401 / 403 (classified viaclassifyProviderError→ACCOUNT_DEACTIVATED). Note themarkAccountUnavailable()path writes a different terminal status —expired— for the sameACCOUNT_DEACTIVATEDsignal (viaresolveTerminalConnectionStatus), so the same ban can surface as eitherdeactivatedorexpireddepending on which path handled the response. (The older code comment says "when a 401 body contains these strings" — that understates the current behavior.) - A
bannedconnection is excluded from selection everywhere terminal statuses are filtered (isTerminalConnectionStatus, comboQUOTA_BLOCKING_CONNECTION_STATUSES).
Scope — which providers are scanned
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 Codex, 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.
autoDisableBannedScope (all | subscription, default all) controls whether
a match also flips isActive=false. subscription means login-style seats
(paid subscriptions and free accounts, including web-cookie sessions). It still
records testStatus=banned for prepaid API keys but leaves them in the routing
pool. The durable design is a per-provider and per-account override; the global
enum is the first cut.
Custom banned keywords
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:
- Bad:
quota,limit,error,denied— appear in many transient errors. - Good: full ban sentences, e.g.
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.
Recovering a flagged connection
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:
- Re-test the connection — the dashboard Test action
(
POST /api/providers/{id}/test); a successful probe resetstestStatustoactiveand clears the error fields. - Re-authenticate / edit credentials — for OAuth providers, re-run the login
/ refresh flow; provider create/import routes set
isActive = true. - Re-enable the connection — if auto-disable set
isActive = false(scopeall, orsubscriptionfor an OAuth/cookie/session connection), 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.
Probe isolation (model test-all)
A probe-origin failure (model test-all / health-check dispatches executed
inside runAsProbe) never removes a connection from the pool (#9817): it is
recorded for visibility (last_error, last_error_type, error_code,
last_error_at) but skips every routing mutation — cooldowns, terminal
status (banned / deactivated / credits_exhausted), per-model lockouts,
the provider circuit breaker, the 5-minute quota cache, OAuth token refresh
and auto-disable. Only a real request-path failure deactivates. The recorded
error is what makes a flagged account visible in the dashboard while it stays
serving traffic.
The single decision point is shouldIsolateProbeFailures()
(src/shared/utils/probeOrigin.ts), consulted by every site that could
mutate routing state from a probe-origin failure:
markAccountUnavailable(auth.ts) — record-only (lastErrorraw text,lastErrorType,errorCode,lastErrorAt; deliberately nobackoffLevel, which would trigger the selection-time auto-decay and wipe the record)maybeAutoDisableBannedAccount— no auto-disablechatCore— FORBIDDEN, ACCOUNT_DEACTIVATED, QUOTA_EXHAUSTED (record-only, no terminalcredits_exhausted), GEO_BLOCKED (no 24h exclusion), MODEL_NOT_FOUND (nolockModel), the codex 429 account-rotation failover (nomarkCodexScopeRateLimited, no persistedrate_limited_until, no session-affinity clear),persistCodexQuotaState(no quota-state write, no cache invalidation),recordKeyHealthStatus(key-health rotator untouched)- OAuth refresh — both the proactive refresh in the executor base
(
base.tsexecute(), no refresh-token rotation consumed) and the reactive 401/403 path inchatCore(noexpireddeactivation) chat.ts— provider circuit breaker and the 5-minute quota cache (markAccountExhaustedFrom429) never degraded
The recorded error is what makes a flagged account visible in the dashboard
while it stays serving traffic. Note: the probe record stores the raw
(unsliced) error text, unlike the real path's slice(0,100) truncation.
Operators who use test-all as a maintenance tool can restore the historical behavior (probe counts as a real generation) via either:
- the
probeCanDisablesetting (POST /api/settingswith{"probeCanDisable": true}, or a directkey_valueDB edit), or - feature flag
PROBE_CAN_DISABLE=true(env or DB override; wins over the setting).
Fail-safe: if the flag or settings lookup throws, isolation stays ON.
Source files
| Concern | File |
|---|---|
| Signal tables + match | open-sse/services/accountFallback.ts |
| Terminalization / persistence | src/sse/services/auth.ts (markAccountUnavailable, resolveTerminalConnectionStatus, clearAccountError) |
| Auto-disable scope | src/shared/utils/autoDisableBanned.ts, src/sse/services/autoDisableBannedAccount.ts |
| 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 |