Files
OmniRoute/docs/security/BAN_DETECTION.md
Diego Rodrigues de Sa e Souza 7d57d9f4a1 fix(providers): retire common ChatGPT Web provider (#11754)
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.
2026-08-28 06:52:46 -03:00

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_SIGNALS in open-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_SIGNALSnon-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 banned terminalization fires on a banned-signal body at any HTTP status (via markAccountUnavailablecheckFallbackError). 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 classifyProviderErrorACCOUNT_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.)
  • A banned connection is excluded from selection everywhere terminal statuses are filtered (isTerminalConnectionStatus, combo QUOTA_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:

  1. Re-test the connection — the dashboard Test action (POST /api/providers/{id}/test); a successful probe resets testStatus to active and clears the error fields.
  2. Re-authenticate / edit credentials — for OAuth providers, re-run the login / refresh flow; provider create/import routes set isActive = true.
  3. Re-enable the connection — if auto-disable set isActive = false (scope all, or subscription for 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 (lastError raw text, lastErrorType, errorCode, lastErrorAt; deliberately no backoffLevel, which would trigger the selection-time auto-decay and wipe the record)
  • maybeAutoDisableBannedAccount — no auto-disable
  • chatCore — FORBIDDEN, ACCOUNT_DEACTIVATED, QUOTA_EXHAUSTED (record-only, no terminal credits_exhausted), GEO_BLOCKED (no 24h exclusion), MODEL_NOT_FOUND (no lockModel), the codex 429 account-rotation failover (no markCodexScopeRateLimited, no persisted rate_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.ts execute(), no refresh-token rotation consumed) and the reactive 401/403 path in chatCore (no expired deactivation)
  • 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 probeCanDisable setting (POST /api/settings with {"probeCanDisable": true}, or a direct key_value DB 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