diff --git a/Ban-Detection.md b/Ban-Detection.md new file mode 100644 index 0000000..e4fd859 --- /dev/null +++ b/Ban-Detection.md @@ -0,0 +1,136 @@ +> 🌍 [View in other languages](Languages) + + +# 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](../architecture/RESILIENCE_GUIDE.md) ("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_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 → also isActive = false + → 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 `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.) +- 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, 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. + +## 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 `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](../architecture/RESILIENCE_GUIDE.md). + +## Source files + +| 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` | diff --git a/Home.md b/Home.md index 791f801..928c16d 100644 --- a/Home.md +++ b/Home.md @@ -1,6 +1,6 @@ # 🚀 OmniRoute — The Free AI Gateway -**Never stop coding. Connect every AI tool to 236 providers — 50+ free — through one endpoint.** +**Never stop coding. Connect every AI tool to 237 providers — 50+ free — through one endpoint.** Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback. @@ -12,7 +12,7 @@ Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / | Feature | Description | |---------|-------------| -| **236 AI Providers** | OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, and many more | +| **237 AI Providers** | OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, and many more | | **50+ Free Tiers** | OAuth + free-tier providers for zero-cost AI access | | **17 Routing Strategies** | Priority, weighted, round-robin, cost-optimized, context-relay, and more | | **Token Compression** | RTK + Caveman engines save 15–95% tokens automatically | @@ -42,7 +42,7 @@ Use the **sidebar** to navigate through all documentation sections: - **[Features](Features)** — All features explained - **[Architecture](Architecture)** — System design and internals - **[API Reference](API-Reference)** — REST API documentation -- **[Providers](Provider-Reference)** — All 236 supported providers +- **[Providers](Provider-Reference)** — All 237 supported providers - **[Combos & Routing](Auto-Combo)** — Routing strategies - **[Compression](Compression-Guide)** — Token compression pipeline - **[MCP Server](MCP-Server)** — MCP tools and transports diff --git a/Maturity-Reeval.md b/Maturity-Reeval.md new file mode 100644 index 0000000..81456dd --- /dev/null +++ b/Maturity-Reeval.md @@ -0,0 +1,107 @@ +> 🌍 [View in other languages](Languages) + + +# Reavaliação de Maturidade — pós-Ondas 0–3 (Quality-Gate v2) + +> **O que é este documento.** Uma re-medição da maturidade do sistema de quality-gates +> **após** as Ondas 0–3 do programa Quality-Gate v2, comparada ao baseline registrado em +> [`QUALITY_GATE_PLAYBOOK.md`](./QUALITY_GATE_PLAYBOOK.md) (2026-06-16). Mede o que mudou, +> contra DSOMM L5 / OpenSSF Scorecard 9 / SLSA L3, separando o que é **CI-mensurável** +> (já entregue / entregável por código) do que é **processo/owner** (settings de organização). +> +> **Data:** 2026-06-30. Gerado do estado real do repositório, não da memória. +> **Régua:** OWASP DSOMM · OpenSSF Scorecard · SLSA · SonarQube "Clean as You Code". + +--- + +## 1. Veredito atualizado + +**Nota geral: A− → A ("Avançado", topo ~5%).** As **duas maiores fraquezas estruturais** +do baseline 06-16 — o *buraco fast-gates* e o *mutation-score-não-catraca* — foram **fechadas**. +Os gaps residuais para "máximo absoluto" são quase todos **owner/infra-gated** (branch-protection, +SLSA L3, CodeQL advanced); o lado-código do programa está essencialmente completo. + +| Framework de referência | Baseline 06-16 | Agora 06-30 | Movimento | Evidência | +| --- | --- | --- | --- | --- | +| **OWASP DSOMM** (5 níveis) | L3→L4 | **L4** em *Test Intensity* e *Static Depth*; L3 sólido nas demais | ▲ | mutation-ratchet bloqueante + suíte determinística no gate-de-merge | +| **OpenSSF Scorecard** | ~7–8/10 | ~7–8/10 (inalterado — gate é o **owner**) | = | falta Branch-Protection na `main` (setting do dono) + pin de actions | +| **SLSA** | L2→L3 | **L2** (encostando em L3) | = | falta builder hermético/reprodutível (infra/owner) | +| **SonarQube "Clean as You Code"** | Alinhado c/ ressalva | Alinhado c/ ressalva | = | ressalva de *sprawl* (~46+ gates) permanece — review de ROI pendente | +| **Quality-Ratchet pattern** | Exemplar | **Exemplar+** | ▲ | novo `dedicatedGate` de `mutationScore` (direction up) | +| **Mutation testing** | "Quase lá" (não-catraca) | **Catraca ativa** | ▲▲ | `check-mutation-ratchet.mjs` + baseline semeado + job nightly bloqueante | + +--- + +## 2. Deltas desde 2026-06-16 (o que as Ondas 0–3 entregaram) + +### 2.1 🔴→✅ Buraco fast-gates FECHADO (era a fraqueza estrutural #1) +O baseline alertava: `quality.yml` (PR→`release/**`) rodava **só gates de filesystem** — sem +typecheck, testes ou build —, então regressões determinísticas só explodiam no PR→`main`. +**Hoje** `.github/workflows/quality.yml` roda, no job *Fast Quality Gates*: `typecheck:core`, +**testes unitários impactados (TIA) bloqueantes com fail-safe para a suíte completa**, o +fast-path do **vitest**, e shards de unit. O gate agora roda **onde o merge acontece** (shift-left), +exatamente o princípio transversal que o playbook prescreve. + +### 2.2 🟠→✅ Mutation score virou CATRACA (era a fraqueza #3 / P0 #1) +O antídoto mais forte contra coverage-gaming estava **advisory**. **Hoje**: +- `scripts/check/check-mutation-ratchet.mjs` (advisory por default, `--ratchet` bloqueante, skip gracioso); +- `config/quality/quality-baseline.json` tem entradas `mutationScore.` semeadas (`direction: up`, `dedicatedGate`); +- `.github/workflows/nightly-mutation.yml` tem o job **"Mutation score ratchet (blocking)"** que unifica os relatórios por-batch e ratcheteia os scores merged por-módulo. + +Resultado: o score de mutação por-módulo **não pode regredir** — cobertura deixou de ser vanity-metric. + +### 2.3 ✅ Quick-wins de gate (Fase 6A/7) entregues +- **a11y axe-core "fake-green" corrigido:** `@axe-core/playwright` em devDeps; `a11y.spec.ts` com skip condicional `REQUIRE_AXE`; job no `nightly-resilience.yml`. +- **complexity varre `bin/`+`electron`:** `check-complexity.mjs` inclui esses diretórios no `ESLINT_ARGS`. +- **tracked-artifacts no pre-commit + pre-push:** `.husky/pre-commit` + `pre-push` bloqueiam artefato rastreado por engano. + +--- + +## 3. As 12 categorias — situação (delta-focada) + +| # | Categoria | Situação 06-30 | +| --- | --- | --- | +| 1 | Estilo & formatação | ✅ inalterado (Prettier+ESLint lint-staged) | +| 2 | Tipos | ✅ **reforçado** — `typecheck:core` agora também no gate PR→release | +| 3 | Testes (intensidade) | ✅ **reforçado** — mutation testing virou catraca; suíte determinística no gate-de-merge | +| 4 | Política de testes (anti-gaming) | ✅ inalterado (pr-test-policy/test-masking/pr-evidence) | +| 5 | Complexidade & saúde | ✅ **reforçado** — complexity varre bin/electron | +| 6 | Segurança estática (SAST+segredos) | 🟡 CodeQL default-setup (advanced = owner); semgrep cloud não-versionado | +| 7 | Supply-chain (deps) | ✅ inalterado (osv/audit/Trivy/Dependabot + allowlist) | +| 8 | Supply-chain (build/release) | 🟡 SLSA L2 (L3 = builder hermético, owner/infra) | +| 9 | Contratos & API | 🟡 oasdiff/osv advisory (candidatos a bloqueante-com-escopo, P1) | +| 10 | Docs & i18n (anti-rot) | ✅ **reforçado** — `fabricated-docs --strict` bloqueante (verificado exit 0) | +| 11 | Anti-alucinação / consistência | ✅ inalterado (known-symbols/fetch-targets/docs-symbols/db-rules) | +| 12 | Resiliência & domínio | ✅ inalterado (chaos/heap/k6/promptfoo/garak nightly) | + +--- + +## 4. Gap residual para "máximo absoluto" + +### 4.1 CI-mensurável / entregável por código (backlog deste programa) +- **P1 — osv/oasdiff → bloqueante com escopo certo:** osv só `CRITICAL`+fixable (two-step como o Trivy); oasdiff bloqueia breaking-change de contrato. +- **P1 — `require-tighten` bloqueante (fim de ciclo):** trava ganhos de métrica (impede afrouxar baseline sem registrar). +- **P1/P2 — review de ROI / sprawl de gates:** consolidar micro-gates de doc-sync; medir timing por-gate no `ci-summary` (combate a fadiga — ressalva do SonarQube/DORA). Os merges ROI deferidos (complexity unificada; `/api` anti-alucinação unificada) entram aqui. +- **P2 — CodeQL config commitado + semgrep versionado:** mais controle/reprodutibilidade. + +### 4.2 Processo / owner (CI não move — settings de organização) +- **Branch-protection na `main`** (sobe Scorecard, fecha o gap DSOMM). Ver [`BRANCH_PROTECTION_MAIN.md`](./BRANCH_PROTECTION_MAIN.md). +- **CodeQL Default → Advanced setup.** +- **SLSA L3** — builder hermético/reprodutível (gerador SLSA do GitHub). Stretch (diminishing returns). + +### 4.3 Explicitamente fora-de-escopo +- **DSOMM L5** é majoritariamente **org-level / processo** (não CI-codificável). +- **SLSA L4** (bit-a-bit reprodutível) é stretch declarado. + +--- + +## 5. Itens deferidos / removidos (housekeeping da cauda) + +- **`semcheck.yaml` (camada LLM de drift semântico docs↔code) — REMOVIDO.** Estava **órfão** + (nenhum workflow/script o invocava) e com contagens stale nas regras. A cobertura determinística + já existe (`check:fabricated-docs --strict` + `check:docs-counts-sync` + `check:docs-symbols`), + e a ressalva de *gate sprawl* desaconselha adicionar um gate LLM advisory de custo recorrente. + Pode ser re-introduzido no futuro como job nightly opt-in se o drift semântico virar problema real. +- **`agent-lsp` scaffold — DEFERIDO / opt-in não-ativado.** Existe como menção em docs + (`docs/architecture/QUALITY_GATES.md`, CHANGELOG) mas **sem wiring** e sem `.mcp.json.example` + no repo. Permanece como scaffold opt-in documentado; não é um gate ativo nem um gap de maturidade. diff --git a/Router-Backends.md b/Router-Backends.md new file mode 100644 index 0000000..d87b17e --- /dev/null +++ b/Router-Backends.md @@ -0,0 +1,146 @@ +> 🌍 [View in other languages](Languages) + + +# Router Backends & Embedded Services — architecture contract (ADR) + +> **Status:** Accepted · **Context:** [#5670](https://github.com/diegosouzapw/OmniRoute/issues/5670), +> [#5603](https://github.com/diegosouzapw/OmniRoute/issues/5603) · **Contract:** `domain/routing/routerBackends.ts` +> (typed registry — code lands with [#5868](https://github.com/diegosouzapw/OmniRoute/pull/5868)) + +This ADR pins down how `ts` (native), `bifrost`, `cliproxy`, `9router`, and +VibeProxy-compatible engines relate to each other, so contributors stop +conflating two things that are architecturally distinct. It documents the typed +registry introduced by the router-backend-registry work as the single source of +truth for that model. + +## The core distinction — two orthogonal axes + +An engine's role is described by **two independent axes**, encoded together in the +registry's `RouterBackendDefinition`: + +1. **Lifecycle** (`RouterBackendLifecycle`) — _how the engine runs_: + - `in-process` — runs inside the OmniRoute Node process (the native TS pipeline). + - `supervised` — a local child process OmniRoute installs/starts/stops/health-checks + via `ServiceSupervisor`, then consumes as a provider connection. + - `external` — an HTTP endpoint OmniRoute dispatches to but does **not** manage + (configured by an env base URL). + - `disabled` — registered but not selectable. +2. **Selection axis** (relay routing backend) — _whether the relay dispatches to it_: + `RelayRoutingBackend = "ts" | "bifrost" | "auto"` in + `src/app/api/v1/relay/chat/completions/routingBackend.ts`. + +The mistake to avoid: treating "embedded service" and "routing backend" as one +list. They are not. A `supervised` engine (9router/cliproxy) is a **provider +connection consumed by the native pipeline**, not an alternate relay dispatch +backend. `bifrost` is the reverse — a relay dispatch backend that (historically) +was `external`-only. + +## The registry — single source of truth + +The `domain/routing/routerBackends.ts` contract (code lands with +[#5868](https://github.com/diegosouzapw/OmniRoute/pull/5868)) declares every engine once, with its +lifecycle, capabilities, service identity, default port, health config, and +telemetry support. Consumers look engines up via `getRouterBackend(id)`, +`listRouterBackends()`, and `listRouterBackendsByCapability(cap)` instead of +special-casing each sidecar. + +| Backend | Lifecycle | Service (axis A) | Relay backend (axis B) | Health | Default port | +| ----------- | ------------ | ---------------- | ---------------------- | ------------- | ------------ | +| `ts` | `in-process` | — | `ts` (native) | — | — | +| `bifrost` | `external`¹ | —¹ | `bifrost` / `auto` | `/health` | — | +| `cliproxy` | `supervised` | `cliproxy` | — (provider) | `/v1/models` | 8317 | +| `9router` | `supervised` | `9router` | — (provider) | `/api/health` | 20130 | +| `vibeproxy` | `external` | — | — (provider adapter) | `/v1/models` | — | + +¹ Bifrost's promotion to a `supervised` embedded service (installable/startable +from `/api/services/bifrost/`) is tracked in +[#5817](https://github.com/diegosouzapw/OmniRoute/pull/5817); until it merges, +Bifrost is `external`-only (reachable solely via `BIFROST_BASE_URL`). + +`capabilities` (`chat`, `responses`, `streaming`, `tools`, `vision`, +`oauth-backed`, `dashboard-embed`, `model-sync`, `native-hot-path`) let callers +filter by what an engine can actually do rather than hard-coding per-id branches. + +## Axis A — embedded services (supervised process side) + +- **Registry of supervised processes:** `src/lib/services/bootstrap.ts` `SERVICES[]` + (today: `9router`, `cliproxy`). +- **Lifecycle owner:** `src/lib/services/ServiceSupervisor.ts` — `start()` spawns the + child, gates on `waitForHealthy()`, taps stdout/stderr into a ring buffer; + `stop()` SIGTERM→SIGKILL; all serialized under a lock. +- **State union** (`src/lib/services/types.ts`): + `not_installed | stopped | starting | running | stopping | error`, plus an + orthogonal `HealthState = healthy | unhealthy | unknown`. +- **Why a separate process (not an in-proc SDK)?** Process isolation is what makes + install/start/stop/health/logs independently controllable per sidecar and lets the + loopback spawn-guard apply. Modeling an in-proc adapter is future work — the + `native-hot-path` capability flag is where that would be expressed. + +### Lifecycle route contract (`/api/services//…`) + +Status codes are **state/verb/path-specific by design** — this is the contract, not +inconsistency: + +| Call | Condition | Status | +| ---------------------------- | ------------------------------- | ------------------------------------ | +| `POST .../start` | service `not_installed` | **409** (precondition) | +| `POST .../stop` | already stopped | **200** (idempotent no-op) | +| `GET .../status` | OK | **200** (`live ?? row ?? "unknown"`) | +| `POST .../start` | spawn failure | **503** (transient) | +| `GET .../status`, `.../stop` | uncaught error | **500** | +| `GET /api/services//logs` | unknown tool `` | **404** `Service '' not found` | +| `GET .../status?reveal=key` | missing `X-Reveal-Confirm: yes` | **403** (9router only) | +| **any** `/api/services/*` | caller not loopback/private-LAN | **403 LOCAL_ONLY** | + +All error bodies are shaped by `createErrorResponse()` → +`{ error: { message, type }, requestId }`, where `type` is derived from the status +(`500→server_error`, `404→not_found`, `409→conflict`, else `invalid_request`) and is +the machine-actionable discriminator. Messages are pre-sanitized +(`sanitizeErrorMessage()`, Hard Rule #12). + +**The loopback guard** is the most common source of a `403`: `/api/services/` is in +`LOCAL_ONLY_API_PREFIXES` (`src/server/authz/routeGuard.ts`) and +`src/server/authz/policies/management.ts` rejects any non-loopback / non-private-LAN +caller **before auth**, because these routes spawn child processes (Hard Rules 15 +and 17). Reaching them through a public tunnel is `403` by design. + +## Axis B — relay routing backend (dispatch side) + +Only the relay proxy path `/api/v1/relay/chat/completions` selects a dispatch +backend; the main `/api/v1/chat/completions` surface never consults +`routingBackend.ts`. + +- **Selection** (`resolveRelayRoutingBackend`): a single global env toggle — + `OMNIROUTE_RELAY_BACKEND` / `RELAY_ROUTING_BACKEND` ∈ {`ts`, `bifrost`, `auto`}. + If unset, `auto` when Bifrost is configured+enabled, else `ts`. +- **Behavior:** + - `bifrost` (forced): Bifrost failure → hard `502`, no fallback. + - `auto`: try Bifrost, on failure/cooldown silently fall through to native. + - `ts` / post-fallback: the native `open-sse` translator/executor pipeline. +- **Cooldown:** per-`baseUrl` failure cooldown in `bifrostCooldown.ts`. + +Selection is **all-or-nothing at the relay level today** — there is no per-provider +or per-request engine swap on `release/v3.8.43`. The per-request gate is being added +by the sidecar-manifest work +([#5869](https://github.com/diegosouzapw/OmniRoute/pull/5869) manifest + +[#5870](https://github.com/diegosouzapw/OmniRoute/pull/5870) `shouldTryBifrostForRequest`), +which lets `auto` route only manifest-eligible providers through Bifrost. + +## Dashboard integration + +The services dashboard polls `GET /api/services//status` every 5s via +`src/app/(dashboard)/dashboard/providers/services/hooks/useServiceStatus.ts`, +returning `{ tool, state, pid, port, health, installedVersion, latestVersion, +updateAvailable, autoStart, … }`. There is no shared availability-context provider — +each component calls the hook per tool. On `!res.ok` the hook currently surfaces a +bare `HTTP `; mapping the `error.type` field to a human explanation is a +tracked UX improvement, not a contract change. + +## Consequences + +- New engines register once in `ROUTER_BACKENDS`; consumers gain them via capability + queries without new per-id branches. +- "Is this a service or a routing backend?" is answered by the `lifecycle` field, not + by which list an id happens to appear in. +- The Bifrost supervision (#5817) and native hot-path migration (#5670) build on this + shared contract instead of special-casing each sidecar.