diff --git a/README.md b/README.md
index 47b6d94714..99c6c2c22c 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,9 @@
-> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **455 free-tier entries across 40 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
+> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **451 free-tier entries across 39 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
-
+
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -189,7 +189,7 @@
-
+
```bash
# Fresh install, zero credentials — `auto` already works:
@@ -198,7 +198,7 @@ curl http://localhost:20128/v1/chat/completions \
-d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}'
```
-Prefer a specific free backend? Call it directly, e.g. `oc/…` (OpenCode Free) or `felo/…` (Felo). Then graduate to `auto` and let OmniRoute pick.
+Prefer a specific free backend? Call `oc/…` (OpenCode Free) directly. Then graduate to `auto` and let OmniRoute pick.
📦 Copy-paste quickstart scripts for **Python, Node.js, PHP, and cURL** → [`examples/quickstart/`](examples/quickstart/)
@@ -1265,7 +1265,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
Resilience Guide Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing
Auto-Combo Engine 15-factor scoring, mode packs, self-healing
Proxy Guide 3-level proxy system, 1proxy marketplace, registry CRUD
- Free Tiers Consolidated directory: 40 documented recurring pools / 455 cataloged free-tier entries
+ Free Tiers Consolidated directory: 39 documented recurring pools / 451 cataloged free-tier entries
Features Gallery Visual dashboard tour with screenshots
Codebase Documentation Beginner-friendly codebase walkthrough
diff --git a/changelog.d/maintenance/11698-retire-felo-web.md b/changelog.d/maintenance/11698-retire-felo-web.md
new file mode 100644
index 0000000000..a55910feeb
--- /dev/null
+++ b/changelog.d/maintenance/11698-retire-felo-web.md
@@ -0,0 +1 @@
+- **chore(providers):** retire the Felo Web provider while its GPL-derived provenance remains on licensing HOLD ([#11698](https://github.com/diegosouzapw/OmniRoute/pull/11698)) — stale runtime and database references now fail closed while existing connection, lease, and usage history remains preserved for auditability.
diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json
index 032fe4db0d..e3d35f8a6e 100644
--- a/config/quality/test-masking-allowlist.json
+++ b/config/quality/test-masking-allowlist.json
@@ -33,6 +33,10 @@
"replacement": "tests/unit/gemini-web-image-retirement.test.ts",
"reason": "v3.8.50 #11708: parser, image-mode, handler e modelo gemini-web/nano-banana-web foram aposentados em HOLD de procedencia/licenca. O replacement prova a ausencia no catalogo e request path, a remocao dos artefatos dedicados, a neutralizacao da antiga extensao image-mode e a preservacao de Gemini Web chat e imagens Gemini/Nano Banana em provedores legitimos. Superficie extinta, nao mascaramento."
},
+ "tests/unit/felo-web-executor.test.ts": {
+ "replacement": "tests/unit/felo-web-retirement.test.ts",
+ "reason": "v3.8.50 #11698: the Felo Web integration remains on a GPL provenance/licensing HOLD, so its executor and registry no longer ship. The replacement proves runtime/catalog/source absence; generic no-auth and strict-zero-cost behavior remains covered with independent fixtures."
+ },
"open-sse/services/combo/__tests__/targetExhaustion.test.ts": {
"replacement": "tests/unit/combo/combo-target-exhaustion.test.ts",
"reason": "v3.8.44 #5976: os testes de exaustão eram flake-prone (delays Math.random, timeouts 30s, >3min no CI) e foram REESCRITOS como unit determinístico com MAIS cobertura (21 casos/52 asserts vs 13 casos/37 asserts). Documentado no commit 5fe225850. Revisão humana: apresentado ao operador no STOP #1 do release v3.8.44."
@@ -131,7 +135,6 @@
"tests/unit/translator-openai-to-kiro.test.ts": "v3.8.49 #8565: auto-kiro passou a ser REJEITADO com erro (KIRO_REMOVED_AUTO_ALIAS_MESSAGE, \"not a real Kiro upstream model\") em vez de mapeado silenciosamente para \"auto\"; o teste do comportamento antigo foi removido porque hoje ele asseriria o comportamento errado. A rejeição está coberta em kiro-model-aliases.test.ts (assert.throws /not a real Kiro/) e kiro-available-models.test.ts. Net 119→118. Verificado legítimo. Prune após v3.8.49 mergear para main.",
"tests/unit/usage-providers.test.ts": "v3.8.49 #7866: o case \"qwen\" saiu de getUsageForProvider (não há mais case \"qwen\" no switch de open-sse/services/usage.ts); o teste cobria esse ramo extinto (net 20→19). Verificado legítimo. Prune após v3.8.49 mergear para main.",
"tests/unit/usage-service-hardening.test.ts": "v3.8.49 #7866/#8565/#8013: qwen removido (−3 asserts); o Kimi/Kiro builder-id (uso profileless) passou a ter SUCESSO real em vez de erro de ARN — supportsProfilelessKiroUsage(\"builder-id\") retorna true —, trocando 1 assert de regex de erro por 3 asserts de valor; e os ids de bucket de quota do Antigravity foram atualizados para o catálogo atual. Rodado no HEAD: 23/23 passam. Net 210→209. Verificado legítimo. Prune após v3.8.49 mergear para main.",
- "tests/unit/virtual-auto-combo.test.ts": "v3.8.49 #7928/#8183: o pooling de contas passou a agrupar conexões web-session do mesmo provider numa entrada lógica com allowedConnectionIds (campo confirmado em open-sse/services/autoCombo/virtualFactory.ts), e o pool no-auth virou uma allowlist fixa (AUTO_COMBO_NOAUTH_ALLOWLIST = opencode, felo-web) — os testes antigos esperavam duplicatas e a inclusão de duckduckgo-web/theoldllm/chipotle, que hoje são corretamente excluídos. Guard dedicado em noauth-autocombo-allowlist.test.ts. Rodado no HEAD: 10/10 passam. Net 39→31. Verificado legítimo. Prune após v3.8.49 mergear para main.",
"open-sse/services/__tests__/tierResolver.test.ts": "v3.8.49 #7866: refactor(qwen) remove o provider OAuth legado — o teste \"classifies Qwen as free\" e a entrada de qwen na lista do batch saíram junto com o provider, e os índices do batch desceram de 10 para 9 elementos (net 61→59). Superfície extinta, não enfraquecimento. Verificado legítimo. Prune após v3.8.49 mergear para main.",
"tests/unit/plugins-welcome-banner-e2e.test.ts": "v3.8.50 #9126 (commit 8fac6bcd48): o teste único 'BUILTIN_EVENTS has all 14 events' (13 asserts .ok/.equal) foi reestruturado em 3 testes mais específicos — 'contains only emitted/public events' (assert.deepEqual da lista completa), 'does not advertise dead events' (7 asserts .equal(false) para eventos sem emissor real: onModelSelect/onComboResolve/onRateLimit/onQuotaExhaust/onProviderError/onStreamStart/onStreamEnd) e 'lifecycle events remain represented' (4 asserts .ok). Contrato mais forte (agora também nega presença dos eventos mortos), não mais fraco — a contagem líquida cai (73→61) porque o assert.deepEqual único substitui múltiplos assert.ok redundantes com a mesma cobertura. Asserts restruturados, não removidos sem substituição. Verificado legítimo.",
"tests/unit/web-tools-translation-2820.test.ts": "v3.8.50 #9343 (commit d969555417): fix(security) exige envelope explicito — JSON puro NAO deve mais ser promovido a tool_calls. Os 5 testes foram REESCRITOS para o contrato oposto (antes: 'promove e valida name/arguments'; agora: 'toolCalls === null e content preservado'), o que naturalmente usa menos asserts: verificar a NAO-promocao custa 2 asserts, verificar o objeto promovido custava 4. Contrato mais restritivo, nao mais fraco (39->35). Verificado legitimo — a inversao esta explicita nos proprios nomes dos testes ('does NOT promote ... (#9343)').",
diff --git a/docs/diagrams/free-tier-budget.svg b/docs/diagrams/free-tier-budget.svg
index b96da3272d..b0da72855b 100644
--- a/docs/diagrams/free-tier-budget.svg
+++ b/docs/diagrams/free-tier-budget.svg
@@ -1,4 +1,4 @@
-
+
Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately.
@@ -64,7 +64,7 @@
~1.51B
FREE TOKENS / MONTH · STEADY
up to ~2.13B in your first month — signup credits
- documented free tiers · 40 recurring pools · 455 catalog entries · one endpoint
+ documented free tiers · 39 recurring pools · 451 catalog entries · one endpoint
@@ -78,7 +78,7 @@
~1.51B
each shared free pool
counted once ✓
- 15 providers ToS-flagged — we flag it · you decide
+ 14 providers ToS-flagged — we flag it · you decide
WHERE IT COMES FROM · 20 QUANTIFIED RECURRING POOLS
diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg
index 3952ed7fd5..0789cfa323 100644
--- a/docs/diagrams/promise-pillars.svg
+++ b/docs/diagrams/promise-pillars.svg
@@ -73,7 +73,7 @@
$0 to start
- 90+ providers with a free tier, 56 free
+ 90+ providers with a free tier, 55 free
forever — Qoder, Pollinations, Cloudflare,
SiliconFlow… No card needed.
diff --git a/docs/diagrams/works-zero-config.svg b/docs/diagrams/works-zero-config.svg
index 0664cc115d..8ab1b00842 100644
--- a/docs/diagrams/works-zero-config.svg
+++ b/docs/diagrams/works-zero-config.svg
@@ -1,4 +1,4 @@
-
+
Animated flow card: three step tiles (Install, Point your tool, It answers) fade in left to right, a dot travels along the connectors between them in a loop, and the final check pulses.
@@ -112,6 +112,6 @@
- OpenCode Free & Felo are pre-wired into auto — a fresh install responds out of the box.
+ OpenCode Free is pre-wired into auto — a fresh install responds out of the box.
$0 · MIT
diff --git a/docs/getting-started/FREE-TIERS-GUIDE.md b/docs/getting-started/FREE-TIERS-GUIDE.md
index 400343affe..3fc6917453 100644
--- a/docs/getting-started/FREE-TIERS-GUIDE.md
+++ b/docs/getting-started/FREE-TIERS-GUIDE.md
@@ -1,6 +1,6 @@
# Free Tiers Guide: Understand and Combine Free AI Access
-> **TL;DR**: OmniRoute registers 350 provider IDs, with **154 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **40 recurring pool keys / 455 entries** (448 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
+> **TL;DR**: OmniRoute registers 351 provider IDs, with **153 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **39 recurring pool keys / 451 entries** (444 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
---
@@ -163,9 +163,9 @@ The live, pool-deduplicated catalog currently reports:
| ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- |
| Recurring quantified grant | **~1.51B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.13B tokens** | Recurring total plus one-time and recurring credits |
-| Audited free-model inventory | **40 recurring pool keys / 455 catalog entries** | 448 active + 7 discontinued; distinct from the 350-provider catalog |
-| Recurring/keyless free-forever providers represented | **56** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types |
-| Provider catalog entries marked `hasFree` | **154 / 350** | Broader provider metadata; not all have a quantifiable recurring quota |
+| Audited free-model inventory | **39 recurring pool keys / 451 catalog entries** | 444 active + 7 discontinued; distinct from the 351-provider catalog |
+| Recurring/keyless free-forever providers represented | **55** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types |
+| Provider catalog entries marked `hasFree` | **153 / 351** | Broader provider metadata; not all have a quantifiable recurring quota |
These values are computed from `open-sse/config/freeModelCatalog.ts`; see the
[Free Tiers Reference](../reference/FREE_TIERS.md) for pool deduplication, ToS flags,
diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md
index e81ef8a95c..c08476b5f3 100644
--- a/docs/guides/TROUBLESHOOTING.md
+++ b/docs/guides/TROUBLESHOOTING.md
@@ -40,11 +40,11 @@ Common problems and solutions for OmniRoute.
### Rate Limiting on Free Providers (429 / 400 / 401)
-**Symptom**: When using `model: "auto"` with free/no-auth providers (opencode, felo-web, auggie, etc.), you intermittently get `HTTP 429`, `400`, or `401` instead of answers. The requests succeed when retrying the same prompt moments later, but automation (cron jobs, agents, scripts) breaks on the first failure.
+**Symptom**: When using `model: "auto"` with free/no-auth providers (opencode, auggie, etc.), you intermittently get `HTTP 429`, `400`, or `401` instead of answers. The requests succeed when retrying the same prompt moments later, but automation (cron jobs, agents, scripts) breaks on the first failure.
**Root cause**: Three independent failure modes stack up:
-1. **Provider rate-limit (`429`)**: Free tiers (notably `felo/felo-chat`) enforce a per-window quota. A burst of parallel calls exhausts it, so the next request is refused until the window resets.
+1. **Provider rate-limit (`429`)**: Free tiers can enforce a per-window quota. A burst of parallel calls exhausts it, so the next request is refused until the window resets.
2. **Broken model in passthrough (`400`/`401`)**: `auto/*` pools can include passthrough models from `opencode` that are registered in the catalog but have no live credentials (e.g. `oc/north-mini-code-free` → `401`). The auto-router tries one, fails, and the error propagates before fallback kicks in.
3. **Concurrency amplification (`429` under load)**: When multiple agent/cron sessions hit `auto` at once, the aggregate request rate exceeds what free providers tolerate, so legitimate calls get flagged as abusive.
diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md
index 627e5b95c6..233ec04249 100644
--- a/docs/i18n/it/README.md
+++ b/docs/i18n/it/README.md
@@ -199,7 +199,7 @@
-
+
```bash
# Fresh install, zero credentials — `auto` already works:
@@ -208,7 +208,7 @@ curl http://localhost:20128/v1/chat/completions \
-d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}'
```
-Preferisci uno specifico backend gratuito? Chiamalo direttamente, ad esempio `oc/…` (OpenCode Free) o `felo/…` (Felo). Poi passa a `auto` e lascia che sia OmniRoute a scegliere.
+Preferisci uno specifico backend gratuito? Chiama direttamente `oc/…` (OpenCode Free). Poi passa a `auto` e lascia che sia OmniRoute a scegliere.
📦 Script di avvio rapido pronti da copiare per **Python, Node.js, PHP e cURL** → [`examples/quickstart/`](../../../examples/quickstart/)
diff --git a/docs/i18n/tr/README.md b/docs/i18n/tr/README.md
index 0d02c1c25b..2d7e77de26 100644
--- a/docs/i18n/tr/README.md
+++ b/docs/i18n/tr/README.md
@@ -189,7 +189,7 @@
-
+
```bash
# Fresh install, zero credentials — `auto` already works:
@@ -198,7 +198,7 @@ curl http://localhost:20128/v1/chat/completions \
-d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}'
```
-Belirli bir ücretsiz arka uç mu tercih ediyorsunuz? Doğrudan çağırın, örn. `oc/…` (OpenCode Free) veya `felo/…` (Felo). Ardından `auto` modeline geçin ve seçimi OmniRoute'a bırakın.
+Belirli bir ücretsiz arka uç mu tercih ediyorsunuz? Doğrudan `oc/…` (OpenCode Free) çağırın. Ardından `auto` modeline geçin ve seçimi OmniRoute'a bırakın.
📦 **Python, Node.js, PHP ve cURL** için kopyala-yapıştır hızlı başlangıç betikleri → [`examples/quickstart/`](examples/quickstart/)
diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md
index 927dccf720..a7b2aa68d5 100644
--- a/docs/reference/FREE_TIERS.md
+++ b/docs/reference/FREE_TIERS.md
@@ -1,7 +1,7 @@
---
title: "Free Tiers & Free-Token Budget"
-version: 3.8.40
-lastUpdated: 2026-07-31
+version: 3.8.50
+lastUpdated: 2026-08-26
---
# Free Tiers & Free-Token Budget
@@ -21,13 +21,13 @@ lastUpdated: 2026-07-31
| **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. |
| Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. |
-**Honest headline:** _OmniRoute aggregates **~1.51B documented free tokens per month** (up to ~2.13B in your first month with signup credits) across 42 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (15–95% token savings) stretches that further._
+**Honest headline:** _OmniRoute aggregates **~1.51B documented free tokens per month** (up to ~2.13B in your first month with signup credits) across 39 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (15–95% token savings) stretches that further._
> **Why this dropped from the previous ~1.94B.** The 2026-06-17 refresh is an honesty correction, not a loss: `gemini` is now pool-deduped (was inflated by counting each Flash variant separately, 462M → 60M), `cloudflare-ai` corrected to its real 10k-Neurons/day (122M → 30M), `doubao` reclassified as a one-time signup credit (not recurring), and shut-down tiers removed (`chutes`/`phind`/`kluster` discontinued). Partly offset by `llm7` (correct 5M/day → 150M) and new free providers (Kilo, OpenCode Zen, Z.AI GLM-Flash).
>
> **Further corrected to ~1.37B in v3.8.42:** `longcat` was reclassified from a 150M/mo recurring grant to a one-time 10M signup credit after its free preview ended. Same honesty rule — no provider was dropped by mistake.
>
-> **Updated to ~1.51B after removing a retired provider:** the pool count is now 42 after mapping free tiers that were documented upstream but missing from the catalog (`requesty`, `ovhcloud`, `agnes`, `glm`) plus new providers `navy` and `aihorde` (#7840). This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`).
+> **Updated on 2026-08-26 after retiring Felo Web:** the source now reports 39 recurring pool keys. Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD. This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`).
Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `groq` 117M, `gemini` 60M, `cerebras` 30M, `cloudflare-ai` 30M, `sambanova` 30M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md
index 53e25458d5..d8761ed47d 100644
--- a/docs/reference/PROVIDER_REFERENCE.md
+++ b/docs/reference/PROVIDER_REFERENCE.md
@@ -34,7 +34,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
---
-## No-auth Providers (no key required) (13)
+## No-auth Providers (no key required) (12)
| ID | Alias | Name | Tags | Website | Notes | Tool calling |
|----|-------|------|------|---------|-------|--------------|
@@ -45,7 +45,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `codex-app-server` | `cxa` | OpenAI Codex (App-Server) | No-auth | [link](https://developers.openai.com/codex/cli) | No token stored by OmniRoute. The Codex CLI app-server manages its own ChatGPT sign-in (~/.codex/auth.json, auto-refreshed). Use “Sign in with ChatGPT” if the CLI is not yet authenticated. | — |
| `devin-cli-agentic` | `dva` | Devin CLI Agentic Bridge | No-auth | [link](https://docs.devin.ai/work-with-devin/devin-cli) | Authentication is owned by the official Devin CLI in its isolated bridge volume. | emulated |
| `duckduckgo-web` | `ddgw` | DuckDuckGo AI Chat | No-auth | [link](https://duckduckgo.com/duckchat) | No credentials required — DuckDuckGo AI Chat is anonymous and free. | emulated |
-| `felo-web` | `felo` | Felo | No-auth | [link](https://felo.ai) | No credentials required — Felo is a free, no-signup chat/search aggregator. | — |
| `opencode` | `oc` | OpenCode Free | No-auth | [link](https://opencode.ai) | No API key required — uses OpenCode's public free endpoint. | — |
| `theoldllm` | `tllm` | The Old LLM (Free) | No-auth | [link](https://theoldllm.vercel.app) | No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance. | — |
| `uncloseai` | `unc` | UncloseAI | No-auth | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ. | — |
diff --git a/docs/routing/STRICT_ZERO_COST.md b/docs/routing/STRICT_ZERO_COST.md
index 50f15778b6..ea9c4469f8 100644
--- a/docs/routing/STRICT_ZERO_COST.md
+++ b/docs/routing/STRICT_ZERO_COST.md
@@ -119,21 +119,18 @@ contractual reasons, or left in when this guard is off even with `freeAccessPoli
## What passes today
Run `npx tsx scripts/ad-hoc/dry-run-strict-zero-cost.ts` against a live instance's
-`GET /v1/auto-combo/{channel}/candidates` output for a real before/after — the script now reads
-each candidate's real `connectionId`, so it also proves the connection-safety fix live, not just
-in unit tests. As of 2026-08-20, only `freeType: "keyless"` candidates pass in practice (7 of 29
-live candidates on this instance: `opencode/big-pickle`, `opencode/deepseek-v4-flash-free`, and
-5 `felo-web` models — all confirmed arriving with the genuine no-auth `connectionId`, never a
-real connection) — no currently-catalogued `recurring-*` provider both has a usage adapter
-registered in `USAGE_FETCHER_PROVIDERS` **and** `hardStopGuaranteed: true` declared (e.g. `groq`
-has neither the adapter registered here nor is fetched offline in this dry run; `kiro` lacks
-`hardStopGuaranteed`). This is not a bug: it's the honest state of two independently-curated
-metadata sets that happen not to overlap yet, not a limitation of the filter itself.
+`GET /v1/auto-combo/{channel}/candidates` output for a real before/after — the script reads each
+candidate's real `connectionId`, so it also proves the connection-safety fix live, not just in
+unit tests. Keyless candidates must arrive with the synthetic no-auth `connectionId`, never a
+real connection. The current built-in keyless auto path is OpenCode Free; exact candidate counts
+still depend on live model discovery and should be measured on the target deployment instead of
+copied from an older run. A `recurring-*` candidate passes only when it has both a registered
+usage adapter and `hardStopGuaranteed: true`; incomplete metadata remains fail-closed.
-With `excludeTosAvoid: true` added on top of the same live pool, the count drops from 7 to 0 —
-every one of the 7 surviving candidates is curated `tos: "avoid"` today (`felo-web`, `opencode`).
-This is a real, expected trade-off of turning the ToS guard on, not a bug: the guard is
-`false` by default for exactly this reason (see "ToS guard" above).
+With `excludeTosAvoid: true`, every candidate curated as `tos: "avoid"` is removed. OpenCode Free
+currently carries that verdict, so enabling the guard can empty a deployment's remaining keyless
+pool. This is an expected trade-off of turning the ToS guard on, not a bug: the guard is `false`
+by default for exactly this reason (see "ToS guard" above).
## Enabling
diff --git a/docs/screenshots/free-tier-budget-card.svg b/docs/screenshots/free-tier-budget-card.svg
index 42bf89d20c..3477208b90 100644
--- a/docs/screenshots/free-tier-budget-card.svg
+++ b/docs/screenshots/free-tier-budget-card.svg
@@ -1,16 +1,16 @@
-
+
Static dashboard preview of recurring token pools, first-month signup grants, and uncapped but rate-limited free-access providers.
OmniRoute · /dashboard/free-tiers · preview mockup
Monthly free-token budget
-40 recurring pools · 455 catalog entries · one endpoint
+39 recurring pools · 451 catalog entries · one endpoint
Steady / month
~1.51B
First month (+ signup credits)
~2.13B
ToS-flagged (you decide)
-15 providers
+14 providers
@@ -34,7 +34,7 @@
-Each segment = one of 20 quantified recurring pools · 40 pools / 455 entries in the audited catalog.
+Each segment = one of 20 quantified recurring pools · 39 pools / 451 entries in the audited catalog.
Mistral 1.00B
diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md
index 1122865d66..2c355a15cc 100644
--- a/examples/quickstart/README.md
+++ b/examples/quickstart/README.md
@@ -13,26 +13,26 @@ npx omniroute
## Examples
-| File | Language | Dependency |
-|------|----------|------------|
-| [`python_requests.py`](python_requests.py) | Python | `pip install requests` |
-| [`nodejs_axios.js`](nodejs_axios.js) | Node.js | `npm install axios` |
-| [`curl_terminal.sh`](curl_terminal.sh) | Bash / cURL | `curl` (pre-installed on Mac/Linux) |
-| [`php_curl.php`](php_curl.php) | PHP | PHP 7.4+ with cURL |
+| File | Language | Dependency |
+| ------------------------------------------ | ----------- | ----------------------------------- |
+| [`python_requests.py`](python_requests.py) | Python | `pip install requests` |
+| [`nodejs_axios.js`](nodejs_axios.js) | Node.js | `npm install axios` |
+| [`curl_terminal.sh`](curl_terminal.sh) | Bash / cURL | `curl` (pre-installed on Mac/Linux) |
+| [`php_curl.php`](php_curl.php) | PHP | PHP 7.4+ with cURL |
-All examples use **`felo/auto`** — a keyless, zero-configuration model that works immediately with no provider sign-up required.
+All examples use **`auto`** — the zero-configuration router that works immediately with no provider sign-up required.
## Key Settings (same in all examples)
-| Setting | Value | Why |
-|---------|-------|-----|
-| `model` | `felo/auto` | Keyless provider, works out of the box |
-| `stream` | `false` | Returns standard JSON instead of SSE stream |
+| Setting | Value | Why |
+| --------------- | ------------------ | ----------------------------------------------------- |
+| `model` | `auto` | Zero-configuration routing, works out of the box |
+| `stream` | `false` | Returns standard JSON instead of SSE stream |
| `Authorization` | `Bearer dummy-key` | Any non-empty string satisfies the header requirement |
## What to Change
-To use a different model, replace `felo/auto` with any model ID from:
+To use a specific model, replace `auto` with any model ID from:
```bash
curl http://localhost:20128/v1/models
diff --git a/examples/quickstart/curl_terminal.sh b/examples/quickstart/curl_terminal.sh
index 21c3ba8cc8..b6caedbaca 100644
--- a/examples/quickstart/curl_terminal.sh
+++ b/examples/quickstart/curl_terminal.sh
@@ -11,7 +11,7 @@ curl "$API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dummy-key" \
-d '{
- "model": "felo/auto",
+ "model": "auto",
"stream": false,
"messages": [
{ "role": "user", "content": "Hello! What can you do?" }
diff --git a/examples/quickstart/nodejs_axios.js b/examples/quickstart/nodejs_axios.js
index 9ae104ae76..880f2003d3 100644
--- a/examples/quickstart/nodejs_axios.js
+++ b/examples/quickstart/nodejs_axios.js
@@ -5,27 +5,26 @@
* node nodejs_axios.js
*/
-const axios = require('axios');
+const axios = require("axios");
// Your local OmniRoute server — started with: npx omniroute
-const API_URL = 'http://localhost:20128/v1/chat/completions';
+const API_URL = "http://localhost:20128/v1/chat/completions";
const headers = {
- 'Content-Type': 'application/json',
- 'Authorization': 'Bearer dummy-key', // Any string works for free/keyless providers
+ "Content-Type": "application/json",
+ Authorization: "Bearer dummy-key", // Any string works for free/keyless providers
};
const data = {
- model: 'felo/auto', // Keyless, works out of the box — no sign-up needed
+ model: "auto", // Zero-config routing, works out of the box — no sign-up needed
stream: false,
- messages: [
- { role: 'user', content: 'Hello! What can you do?' },
- ],
+ messages: [{ role: "user", content: "Hello! What can you do?" }],
};
-axios.post(API_URL, data, { headers })
- .then(res => console.log(res.data.choices[0].message.content))
- .catch(err => {
- console.error('Error:', err.message);
- if (err.response) console.error('Server replied:', err.response.data);
+axios
+ .post(API_URL, data, { headers })
+ .then((res) => console.log(res.data.choices[0].message.content))
+ .catch((err) => {
+ console.error("Error:", err.message);
+ if (err.response) console.error("Server replied:", err.response.data);
});
diff --git a/examples/quickstart/php_curl.php b/examples/quickstart/php_curl.php
index 0860c35cf7..60a7ecc0a6 100644
--- a/examples/quickstart/php_curl.php
+++ b/examples/quickstart/php_curl.php
@@ -15,7 +15,7 @@ $headers = [
];
$data = [
- "model" => "felo/auto", // Keyless, works out of the box — no sign-up needed
+ "model" => "auto", // Zero-config routing, works out of the box — no sign-up needed
"stream" => false,
"messages" => [
["role" => "user", "content" => "Hello! What can you do?"],
diff --git a/examples/quickstart/python_requests.py b/examples/quickstart/python_requests.py
index a27c7b38b0..03aae72c90 100644
--- a/examples/quickstart/python_requests.py
+++ b/examples/quickstart/python_requests.py
@@ -16,7 +16,7 @@ headers = {
}
data = {
- "model": "felo/auto", # Keyless, works out of the box — no sign-up needed
+ "model": "auto", # Zero-config routing, works out of the box — no sign-up needed
"stream": False,
"messages": [
{"role": "user", "content": "Hello! What can you do?"}
diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts
index 2a0bd5b4c0..e940d3cf28 100644
--- a/open-sse/config/freeModelCatalog.data.ts
+++ b/open-sse/config/freeModelCatalog.data.ts
@@ -159,11 +159,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "duckduckgo-web", modelId: "mistral-small-2603", displayName: "Mistral Small 4", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "tinfoil/gpt-oss-120b", displayName: "gpt-oss 120B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "tinfoil/gemma4-31b", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
- { provider: "felo-web", modelId: "felo-chat", displayName: "Felo Chat", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
- { provider: "felo-web", modelId: "felo-search", displayName: "Felo Search", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
- { provider: "felo-web", modelId: "felo-scholar", displayName: "Felo Scholar", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
- { provider: "felo-web", modelId: "felo-social", displayName: "Felo Social", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
- { provider: "felo-web", modelId: "felo-document", displayName: "Felo Document", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
{ provider: "fireworks", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" },
{ provider: "fireworks", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" },
{ provider: "fireworks", modelId: "glm-5p1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" },
diff --git a/open-sse/config/freeTierCatalog.ts b/open-sse/config/freeTierCatalog.ts
index cfc2836b98..a3617284fe 100644
--- a/open-sse/config/freeTierCatalog.ts
+++ b/open-sse/config/freeTierCatalog.ts
@@ -40,7 +40,6 @@ export const FREE_TIER_BUDGETS: Record = {
export const FREE_TIER_TOS: Record = {
opencode: "avoid",
"duckduckgo-web": "avoid",
- "felo-web": "avoid",
agy: "avoid",
kiro: "avoid",
"amazon-q": "avoid",
diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts
index c54ea48c60..947e9071ac 100644
--- a/open-sse/config/providers/index.ts
+++ b/open-sse/config/providers/index.ts
@@ -149,7 +149,6 @@ import { longcatProvider } from "./registry/longcat/index.ts";
import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts";
import { vertexProvider } from "./registry/vertex/index.ts";
import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts";
-import { felo_webProvider } from "./registry/felo-web/index.ts";
import { xaiProvider, xai_oauthProvider } from "./registry/xai/index.ts";
import { morphProvider } from "./registry/morph/index.ts";
import { siliconflowProvider } from "./registry/siliconflow/index.ts";
@@ -419,7 +418,6 @@ export const REGISTRY: Record = {
"vertex-partner": vertex_partnerProvider,
vertex: vertexProvider,
"duckduckgo-web": duckduckgo_webProvider,
- "felo-web": felo_webProvider,
xai: xaiProvider,
"xai-oauth": xai_oauthProvider,
morph: morphProvider,
diff --git a/open-sse/config/providers/registry/felo-web/index.ts b/open-sse/config/providers/registry/felo-web/index.ts
deleted file mode 100644
index 58f1605d1d..0000000000
--- a/open-sse/config/providers/registry/felo-web/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { RegistryEntry } from "../../shared.ts";
-
-export const felo_webProvider: RegistryEntry = {
- id: "felo-web",
- alias: "felo",
- format: "openai",
- executor: "felo-web",
- baseUrl: "https://felo.ai/api-proxy/main/search/threads",
- authType: "none",
- authHeader: "none",
- models: [
- { id: "felo-chat", name: "Felo Chat", toolCalling: false },
- { id: "felo-search", name: "Felo Search", toolCalling: false },
- { id: "felo-scholar", name: "Felo Scholar", toolCalling: false },
- { id: "felo-social", name: "Felo Social", toolCalling: false },
- { id: "felo-document", name: "Felo Document", toolCalling: false },
- ],
-};
diff --git a/open-sse/executors/felo-web.ts b/open-sse/executors/felo-web.ts
deleted file mode 100644
index 537d46afba..0000000000
--- a/open-sse/executors/felo-web.ts
+++ /dev/null
@@ -1,372 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { BaseExecutor, type ExecuteInput } from "./base.ts";
-import { sanitizeErrorMessage } from "../utils/error.ts";
-
-/**
- * FeloWebExecutor — anonymous, free access to Felo (felo.ai), a chat/search-agent
- * aggregator. No API key or session cookie required (`needs_auth = False` in the
- * g4f reference implementation, `g4f/Provider/Felo.py`, fetched 2026-07-17).
- *
- * Flow:
- * 1. POST /api-proxy/main/search/threads — opens a search thread, returns `stream_key`.
- * 2. GET /api/message/v1/stream/{stream_key}?offset=0 — SSE-shaped stream. Each line is
- * `data:{...}` (no space after the colon, unlike most SSE producers). The JSON payload
- * carries a double-encoded `content` string; parsing that yields `{ data: { type, data } }`
- * where `type` is `"answer"` (incremental/snapshot text) or `"final_contexts"` (sources,
- * dropped here — no OpenAI-compatible slot for citations on this translation path).
- *
- * Felo has no published API; this is a reverse-engineered, scrape-style integration in the
- * same family as `duckduckgo-web.ts` / `blackbox-web.ts` (see #6666 plan). It may break
- * without notice if Felo changes its frontend contract.
- */
-
-export const FELO_BASE = "https://felo.ai";
-export const FELO_THREADS_URL = `${FELO_BASE}/api-proxy/main/search/threads`;
-export const FELO_PROVIDER_PREFIX = "felo-web/";
-
-export function feloStreamUrl(streamKey: string): string {
- return `${FELO_BASE}/api/message/v1/stream/${encodeURIComponent(streamKey)}?offset=0`;
-}
-
-const FELO_USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
- "(KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
-
-export const FELO_HEADERS: Record = {
- Accept: "*/*",
- "Content-Type": "application/json",
- Origin: FELO_BASE,
- Referer: `${FELO_BASE}/search?q=hello`,
- "User-Agent": FELO_USER_AGENT,
-};
-
-const FELO_STREAM_REQUEST_HEADERS: Record = {
- Accept: "*/*",
- Origin: FELO_BASE,
- Referer: FELO_HEADERS.Referer,
- "User-Agent": FELO_USER_AGENT,
-};
-
-// Mirrors g4f's `Felo.model_aliases` — Felo has no published model list; this
-// reverse-engineered mapping is the only reference (category drives which
-// search/answer pipeline Felo routes the query through).
-const FELO_MODEL_CATEGORIES: Record = {
- "felo-chat": "chat",
- "felo-search": "google",
- "felo-scholar": "scholar",
- "felo-social": "social",
- "felo-document": "document",
-};
-
-export const FELO_DEFAULT_MODEL = "felo-chat";
-
-export function normalizeFeloModel(model: string | undefined | null): string {
- if (!model) return FELO_DEFAULT_MODEL;
- const clean = model.startsWith(FELO_PROVIDER_PREFIX)
- ? model.slice(FELO_PROVIDER_PREFIX.length)
- : model;
- return Object.prototype.hasOwnProperty.call(FELO_MODEL_CATEGORIES, clean)
- ? clean
- : FELO_DEFAULT_MODEL;
-}
-
-export function resolveFeloCategory(model: string | undefined | null): string {
- return FELO_MODEL_CATEGORIES[normalizeFeloModel(model)];
-}
-
-export function extractFeloLastUserPrompt(messages: Array>): string {
- const lastUser = [...messages].reverse().find((m) => m.role === "user");
- if (!lastUser) return "";
- const content = lastUser.content;
- if (typeof content === "string") return content;
- if (!Array.isArray(content)) return "";
- return content
- .map((part) => {
- if (part && typeof part === "object" && typeof (part as Record).text === "string") {
- return (part as Record).text as string;
- }
- return "";
- })
- .filter(Boolean)
- .join("\n");
-}
-
-export function buildFeloThreadPayload(
- model: string | undefined | null,
- prompt: string
-): Record {
- const searchUuid = randomUUID();
- return {
- query: prompt,
- search_uuid: searchUuid,
- lang: "",
- agent_lang: "en",
- search_options: { langcode: "en-US" },
- search_video: true,
- query_from: "default",
- category: resolveFeloCategory(model),
- model: "",
- auto_routing: true,
- mode: "concise",
- device_id: randomUUID().replaceAll("-", ""),
- source_message_rid: "",
- documents: [],
- document_action: "",
- slides_source: { type: "ask_question", files: {} },
- slide_template_uid: "",
- selected_resource_ids: [],
- process_id: searchUuid,
- stream_protocol: "message_center_v1",
- enable_task_state: true,
- };
-}
-
-function extractFeloAnswerText(contentJson: unknown): string | null {
- if (!contentJson || typeof contentJson !== "object") return null;
- const data = (contentJson as Record).data;
- if (!data || typeof data !== "object") return null;
- const dataRecord = data as Record;
- if (dataRecord.type !== "answer") return null;
- const inner = dataRecord.data;
- if (!inner || typeof inner !== "object") return null;
- const text = (inner as Record).text;
- return typeof text === "string" ? text : null;
-}
-
-export interface FeloParsedLine {
- /** New text to emit for this line, or null when the line carried nothing new. */
- newText: string | null;
- /** Running "previous text" snapshot to pass into the next call. */
- nextPreviousText: string;
-}
-
-/**
- * Parse a single line of Felo's SSE-shaped stream, diffing against the running
- * snapshot the same way the g4f reference implementation does: each `answer`
- * event carries the full text-so-far, and only the new suffix is new content.
- */
-export function parseFeloStreamLine(line: string, previousText: string): FeloParsedLine {
- const trimmed = line.trim();
- if (!trimmed.startsWith("data:{")) {
- return { newText: null, nextPreviousText: previousText };
- }
-
- let outer: unknown;
- try {
- outer = JSON.parse(trimmed.slice(5));
- } catch {
- return { newText: null, nextPreviousText: previousText };
- }
-
- const content = (outer as Record | null)?.content;
- if (typeof content !== "string") {
- return { newText: null, nextPreviousText: previousText };
- }
-
- let contentJson: unknown;
- try {
- contentJson = JSON.parse(content);
- } catch {
- return { newText: null, nextPreviousText: previousText };
- }
-
- const text = extractFeloAnswerText(contentJson);
- if (text === null) {
- return { newText: null, nextPreviousText: previousText };
- }
-
- if (text.startsWith(previousText)) {
- const newPart = text.slice(previousText.length);
- return newPart
- ? { newText: newPart, nextPreviousText: text }
- : { newText: null, nextPreviousText: previousText };
- }
-
- return { newText: text, nextPreviousText: text };
-}
-
-/** Replay a full raw stream body through `parseFeloStreamLine`, returning the final text. */
-export function accumulateFeloStreamText(rawText: string): string {
- let previousText = "";
- for (const line of rawText.split("\n")) {
- previousText = parseFeloStreamLine(line, previousText).nextPreviousText;
- }
- return previousText;
-}
-
-export class FeloWebExecutor extends BaseExecutor {
- constructor() {
- super("felo-web", { baseUrl: FELO_BASE });
- }
-
- async testConnection(
- _credentials: Record,
- signal?: AbortSignal
- ): Promise {
- const controller = new AbortController();
- const feloTestMs = this.getTimeoutMs();
- const timeout = setTimeout(() => {
- const err = new Error(`felo-web testConnection timeout after ${feloTestMs}ms`);
- err.name = "TimeoutError";
- controller.abort(err);
- }, feloTestMs);
- try {
- const mergedSignal = signal
- ? AbortSignal.any([signal, controller.signal])
- : controller.signal;
-
- const response = await fetch(FELO_THREADS_URL, {
- method: "POST",
- headers: FELO_HEADERS,
- body: JSON.stringify(buildFeloThreadPayload(FELO_DEFAULT_MODEL, "hi")),
- signal: mergedSignal,
- });
- if (!response.ok) return false;
- const data = await response.json().catch(() => null);
- return typeof (data as Record | null)?.stream_key === "string";
- } catch {
- return false;
- } finally {
- clearTimeout(timeout);
- }
- }
-
- async execute(input: ExecuteInput): Promise {
- const { model, body, stream, signal } = input;
- const bodyObj = (body || {}) as Record;
- const messages = Array.isArray(bodyObj.messages)
- ? (bodyObj.messages as Array>)
- : [];
- const isStreaming = stream !== false;
-
- if (messages.length === 0) {
- return feloErrorResponse(400, "No messages provided");
- }
- const prompt = extractFeloLastUserPrompt(messages);
- if (!prompt) {
- return feloErrorResponse(400, "No user message content found");
- }
-
- const controller = new AbortController();
- const feloExecMs = this.getTimeoutMs();
- const timeout = setTimeout(() => {
- const err = new Error(`felo-web execute timeout after ${feloExecMs}ms`);
- err.name = "TimeoutError";
- controller.abort(err);
- }, feloExecMs);
- const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
-
- try {
- const streamKey = await this.createFeloThread(model, prompt, mergedSignal);
- if (streamKey instanceof Response) {
- clearTimeout(timeout);
- return streamKey;
- }
-
- const streamResponse = await fetch(feloStreamUrl(streamKey), {
- method: "GET",
- headers: FELO_STREAM_REQUEST_HEADERS,
- signal: mergedSignal,
- });
- clearTimeout(timeout);
-
- if (!streamResponse.ok || !streamResponse.body) {
- const status = !streamResponse.ok && streamResponse.status >= 500 ? 502 : streamResponse.status || 502;
- return feloErrorResponse(status, `Felo stream request failed with HTTP ${streamResponse.status}`);
- }
-
- return await processFeloResponse(streamResponse, isStreaming);
- } catch (error) {
- clearTimeout(timeout);
- if (error instanceof DOMException && error.name === "AbortError") {
- return feloErrorResponse(499, "Request cancelled");
- }
- return feloErrorResponse(500, error instanceof Error ? error.message : "Unknown error");
- }
- }
-
- /** Returns the resolved `stream_key`, or an error Response to propagate as-is. */
- private async createFeloThread(
- model: string | undefined,
- prompt: string,
- signal: AbortSignal
- ): Promise {
- const threadResponse = await fetch(FELO_THREADS_URL, {
- method: "POST",
- headers: FELO_HEADERS,
- body: JSON.stringify(buildFeloThreadPayload(model, prompt)),
- signal,
- });
-
- if (!threadResponse.ok) {
- const status = threadResponse.status >= 500 ? 502 : threadResponse.status;
- return feloErrorResponse(status, `Felo thread creation failed with HTTP ${threadResponse.status}`);
- }
-
- const threadJson = await threadResponse.json().catch(() => null);
- const streamKey = (threadJson as Record | null)?.stream_key;
- if (typeof streamKey !== "string" || !streamKey) {
- return feloErrorResponse(502, "Felo did not return a stream_key");
- }
- return streamKey;
- }
-}
-
-function feloErrorResponse(status: number, message: string): Response {
- return new Response(JSON.stringify({ error: { message: sanitizeErrorMessage(message) } }), {
- status,
- headers: { "Content-Type": "application/json" },
- });
-}
-
-function buildFeloStreamTransform(): TransformStream {
- let previousText = "";
- let buffer = "";
- const decoder = new TextDecoder();
- const encoder = new TextEncoder();
-
- return new TransformStream({
- transform(chunk, controller) {
- buffer += decoder.decode(chunk, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() ?? "";
- for (const line of lines) {
- const parsed = parseFeloStreamLine(line, previousText);
- previousText = parsed.nextPreviousText;
- if (!parsed.newText) continue;
- const openaiChunk = { choices: [{ delta: { content: parsed.newText }, index: 0 }] };
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(openaiChunk)}\n\n`));
- }
- },
- flush(controller) {
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
- },
- });
-}
-
-async function processFeloResponse(response: Response, streaming: boolean): Promise {
- if (streaming) {
- if (!response.body) {
- return feloErrorResponse(500, "No response body");
- }
- const transformed = response.body.pipeThrough(buildFeloStreamTransform());
- return new Response(transformed, { headers: { "Content-Type": "text/event-stream" } });
- }
-
- const rawText = await response.text();
- const fullText = accumulateFeloStreamText(rawText);
- return new Response(
- JSON.stringify({
- choices: [
- {
- message: { role: "assistant", content: fullText },
- index: 0,
- finish_reason: "stop",
- },
- ],
- }),
- { headers: { "Content-Type": "application/json" } }
- );
-}
-
-export const feloWebExecutor = new FeloWebExecutor();
diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts
index d72b836807..9e762e8a05 100644
--- a/open-sse/executors/index.ts
+++ b/open-sse/executors/index.ts
@@ -1,5 +1,6 @@
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
import { assertMicrosoftDesignerWebProviderAvailable } from "@/shared/constants/designerWebRetirement";
+import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
import {
registerLazyExecutor,
loadRegisteredExecutor,
@@ -132,8 +133,6 @@ const lazyExecutors: Record Promise> = {
"duckduckgo-web": () =>
import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()),
ddgw: () => import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()), // Alias
- "felo-web": () => import("./felo-web.ts").then((m) => new m.FeloWebExecutor()),
- felo: () => import("./felo-web.ts").then((m) => new m.FeloWebExecutor()), // Alias
"t3-web": () => import("./t3-chat-web.ts").then((m) => new m.T3ChatWebExecutor()),
t3chat: () => import("./t3-chat-web.ts").then((m) => new m.T3ChatWebExecutor()), // Alias
"inner-ai": () => import("./inner-ai.ts").then((m) => new m.InnerAiExecutor()),
@@ -240,6 +239,7 @@ const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS))
export async function getExecutor(provider: string): Promise {
assertMicrosoftDesignerWebProviderAvailable(provider);
+ assertRuntimeProviderAvailable(provider);
const loaded = await loadRegisteredExecutor(provider);
if (loaded) return loaded;
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts
index 1f7a746d41..e0a27b20a2 100644
--- a/open-sse/handlers/chatCore/executorProxy.ts
+++ b/open-sse/handlers/chatCore/executorProxy.ts
@@ -16,6 +16,8 @@
* account pool (not a configured bearer key) and has its own server-side model-alias mechanism.
*/
+import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
+
import { getExecutor } from "../../executors/index.ts";
import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts";
import { isDarioDeepModeEnabled } from "../../executors/dario.ts";
@@ -96,6 +98,7 @@ export async function resolveExecutorWithProxy(
providerSpecificData?: Record | null
) {
assertMicrosoftDesignerWebProviderAvailable(prov);
+ assertRuntimeProviderAvailable(prov);
// Per-connection routing override (#6339): the resolved connection can opt itself
// into the CLIProxyAPI passthrough executor via providerSpecificData.cliproxyapiMode
diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts
index ea6989c7de..db828b9540 100644
--- a/open-sse/services/autoCombo/virtualFactory.ts
+++ b/open-sse/services/autoCombo/virtualFactory.ts
@@ -310,8 +310,8 @@ const SYNTHETIC_NOAUTH_CONNECTION_ID = RESILIENCE_NOAUTH_CONNECTION_ID;
// Allowlist of no-auth (keyless) providers permitted to enter the `auto`/`auto-*`
// candidate pool. Narrowed to the backends verified to answer without any
-// configuration on our reference egress (VPS .15): `opencode` and `felo-web`
-// both return 200 there, while duckduckgo-web (429/VQD rate limit), theoldllm
+// configuration on our reference egress (VPS .15): `opencode` returns 200
+// there, while duckduckgo-web (429/VQD rate limit), theoldllm
// (403 Vercel egress block), chipotle (502), aihorde (401, anon key rejected)
// and the others are unreliable. The excluded providers stay fully usable via
// direct `/` calls — they are just kept OUT of auto-routing until
@@ -325,7 +325,7 @@ const SYNTHETIC_NOAUTH_CONNECTION_ID = RESILIENCE_NOAUTH_CONNECTION_ID;
// pool, so it admits any no-auth backend that genuinely serves the family (e.g.
// auggie, a local CLI subprocess with zero HTTP egress, belongs in auto/glm
// regardless of this list). See the `bypassAllowlist` param below.
-const AUTO_COMBO_NOAUTH_ALLOWLIST = new Set(["opencode", "felo-web"]);
+const AUTO_COMBO_NOAUTH_ALLOWLIST = new Set(["opencode"]);
function isChatAutoComboNoAuthProvider(
providerDef: NoAuthProviderDefinition,
diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts
index 8034c5a536..4fafd05b1a 100644
--- a/open-sse/utils/error.ts
+++ b/open-sse/utils/error.ts
@@ -343,13 +343,22 @@ export function errorResponseWithComboDiagnostics(
* @param {string} message - Error message
* @returns {Response} HTTP Response object
*/
-export function errorResponse(statusCode: number, message: string): Response {
- return new Response(JSON.stringify(buildErrorBody(statusCode, sanitizeErrorMessage(message))), {
- status: statusCode,
- headers: {
- "Content-Type": "application/json",
- },
- });
+export function errorResponse(
+ statusCode: number,
+ message: string,
+ classification?: ErrorBodyClassification
+): Response {
+ return new Response(
+ JSON.stringify(
+ buildErrorBody(statusCode, sanitizeErrorMessage(message), undefined, classification)
+ ),
+ {
+ status: statusCode,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
}
/**
diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts
index a7e02842c6..2dc0dfcbce 100644
--- a/src/app/api/v1/chat/completions/route.ts
+++ b/src/app/api/v1/chat/completions/route.ts
@@ -27,6 +27,10 @@ import {
withCompressionHeaderEcho,
} from "@/shared/utils/compressionHeaderEcho";
import { resolveModelAliasWithSeedFallbackOnBody } from "@/lib/modelAliasResolver";
+import {
+ assertRuntimeModelProviderAvailable,
+ isRuntimeProviderRetirementError,
+} from "@/shared/constants/providerRetirement";
let initPromise = null;
@@ -158,6 +162,24 @@ export async function POST(request) {
admission.lease?.release();
return finishAdmission(structuralAdmission.response);
}
+ admission.lease = structuralAdmission.lease;
+
+ // Preserve the caller-supplied provider identity long enough to enforce
+ // retirement. A persisted alias can otherwise rewrite felo-web/... to a
+ // healthy provider before getModelInfo or the executor tombstones see it.
+ try {
+ assertRuntimeModelProviderAvailable(parsedBody.model);
+ } catch (error) {
+ if (isRuntimeProviderRetirementError(error)) {
+ return finishAdmission(
+ errorResponse(error.status, error.message, {
+ type: "provider_error",
+ code: error.code,
+ })
+ );
+ }
+ throw error;
+ }
// Resolve model alias before forwarding to handleChat
if (parsedBody && typeof parsedBody === "object") {
@@ -165,7 +187,6 @@ export async function POST(request) {
/* swallow — fall through with original model */
});
}
- admission.lease = structuralAdmission.lease;
const { blocked, result } = injectionGuard(parsedBody);
if (blocked) {
diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts
index 80bbc5d77f..0be43d5d0a 100644
--- a/src/app/api/v1/messages/count_tokens/route.ts
+++ b/src/app/api/v1/messages/count_tokens/route.ts
@@ -2,7 +2,9 @@ import { CORS_HEADERS } from "@/shared/utils/cors";
import { v1CountTokensSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { countTextTokens, type TokenizerContext } from "@/shared/utils/tiktokenCounter";
+import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
+import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { getModelInfo } from "@/sse/services/model";
import { extractApiKey, getProviderCredentials, isValidApiKey } from "@/sse/services/auth";
@@ -103,6 +105,20 @@ export async function POST(request) {
}
);
} catch (error) {
+ if (isRuntimeProviderRetirementError(error)) {
+ return new Response(
+ JSON.stringify(
+ buildErrorBody(error.status, error.message, null, {
+ type: "provider_error",
+ code: error.code,
+ })
+ ),
+ {
+ status: error.status,
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
+ }
+ );
+ }
log.debug(
"COUNT_TOKENS",
`Falling back to estimate for ${requestedModel}: ${error instanceof Error ? error.message : String(error)}`
diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts
index 214232e7bf..7b55a283fd 100644
--- a/src/app/api/v1/models/catalog.ts
+++ b/src/app/api/v1/models/catalog.ts
@@ -71,7 +71,10 @@ import { getModelsDevPricing, getSyncedCapability } from "@/lib/modelsDevSync";
import { getModelSpec } from "@/shared/constants/modelSpecs";
import { classifyModelSupportedEndpoints } from "@/shared/constants/modelSupportedEndpoints";
import { getModelsCatalogPrefixMode } from "@/shared/utils/featureFlags";
-import { buildReservedPrefixes, selectCompatibleNodeForPrefix } from "@/lib/providerNodePrefixes";
+import {
+ isProviderNodePrefixReserved,
+ selectCompatibleNodeForPrefix,
+} from "@/lib/providerNodePrefixes";
import { applyCatalogPostFilters, finalizeCatalogResponse } from "./catalogResponse";
import {
isNoAuthProviderBlocked,
@@ -373,9 +376,8 @@ async function buildUnifiedModelsResponseCore(
nodeIdToProviderType[node.id] = node.type;
}
}
- const reservedProviderPrefixes = buildReservedPrefixes();
for (const prefix of new Set(Object.values(providerIdToPrefix))) {
- if (reservedProviderPrefixes.has(prefix)) continue;
+ if (isProviderNodePrefixReserved(prefix)) continue;
const winner = selectCompatibleNodeForPrefix(providerNodes, prefix);
if (winner?.id) providerNodeIdByPrefix[prefix] = winner.id;
}
diff --git a/src/app/api/v1/session-leases/route.ts b/src/app/api/v1/session-leases/route.ts
index 53671f5ad3..d2151a8098 100644
--- a/src/app/api/v1/session-leases/route.ts
+++ b/src/app/api/v1/session-leases/route.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import {
@@ -55,8 +56,7 @@ export const OPTIONS = async (): Promise => handleCorsOptions();
export async function POST(request: Request): Promise {
const apiKey = extractApiKey(request);
if (!apiKey) return error(401, "LEASE_AUTHENTICATION_REQUIRED", "Authentication required");
- if (!(await isValidApiKey(apiKey)))
- return error(401, "LEASE_API_KEY_INVALID", "Invalid API key");
+ if (!(await isValidApiKey(apiKey))) return error(401, "LEASE_API_KEY_INVALID", "Invalid API key");
const contentType = request.headers.get("content-type")?.toLowerCase().split(";", 1)[0].trim();
if (contentType !== "application/json") {
return error(415, "LEASE_CONTENT_TYPE_REQUIRED", "Content-Type must be application/json");
@@ -133,6 +133,9 @@ export async function POST(request: Request): Promise {
const result = selection as ExclusiveLeaseSelectionResult;
return json(200, lifecycle(result.exclusiveLease));
} catch (cause) {
+ if (isRuntimeProviderRetirementError(cause)) {
+ return error(cause.status, cause.code, cause.message);
+ }
if (cause instanceof LeaseContextError) return error(cause.status, cause.code, cause.message);
return error(503, "LEASE_SERVICE_UNAVAILABLE", "Lease service unavailable");
}
diff --git a/src/lib/db/migrations/165_retire_felo_web.sql b/src/lib/db/migrations/165_retire_felo_web.sql
new file mode 100644
index 0000000000..40d8732e2e
--- /dev/null
+++ b/src/lib/db/migrations/165_retire_felo_web.sql
@@ -0,0 +1,195 @@
+-- Retire the Felo Web integration while its GPL-derived provenance remains on hold.
+--
+-- Match the complete ECMAScript trim whitespace set so database tombstones and
+-- the TypeScript runtime agree even for restored provider ids wrapped in Unicode
+-- spaces (NBSP, OGHAM, U+2000..U+200A, line/paragraph separators and BOM).
+--
+-- Keep connection rows and historical records for auditability. Disabling the
+-- connections is deliberately fail-closed: API-key allowed_connections entries
+-- continue to reference the same connection ids instead of becoming an empty
+-- allowlist, which would mean unrestricted access in the policy layer.
+
+UPDATE exclusive_connection_leases
+SET state = 'INVALIDATED',
+ ended_at = datetime('now'),
+ end_reason = 'CONNECTION_INELIGIBLE'
+WHERE state = 'ACTIVE'
+ AND (
+ lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ OR connection_id IN (
+ SELECT id
+ FROM provider_connections
+ WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ )
+ );
+
+UPDATE provider_connections
+SET is_active = 0,
+ test_status = 'unavailable',
+ error_code = 'PROVIDER_REMOVED',
+ last_error = 'Provider integration retired from OmniRoute v3.8.50',
+ last_error_type = 'provider_removed',
+ last_error_source = 'migration:retire-felo-web',
+ last_error_at = datetime('now'),
+ updated_at = datetime('now')
+WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ AND (
+ is_active IS NOT 0
+ OR test_status IS NOT 'unavailable'
+ OR error_code IS NOT 'PROVIDER_REMOVED'
+ OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
+ OR last_error_type IS NOT 'provider_removed'
+ OR last_error_source IS NOT 'migration:retire-felo-web'
+ OR last_error_at IS NULL
+ );
+
+-- Migrations run before settings imports. Keep the tombstone durable when an
+-- old db.json snapshot or an admin PATCH later attempts to reactivate either
+-- retired id. The WHEN predicates are null-safe and prevent timestamp churn
+-- when an already-normalized row is written again.
+CREATE TRIGGER IF NOT EXISTS provider_connections_retire_felo_web_insert
+AFTER INSERT ON provider_connections
+WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+BEGIN
+ UPDATE provider_connections
+ SET is_active = 0,
+ test_status = 'unavailable',
+ error_code = 'PROVIDER_REMOVED',
+ last_error = 'Provider integration retired from OmniRoute v3.8.50',
+ last_error_type = 'provider_removed',
+ last_error_source = 'migration:retire-felo-web',
+ last_error_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = NEW.id
+ AND (
+ is_active IS NOT 0
+ OR test_status IS NOT 'unavailable'
+ OR error_code IS NOT 'PROVIDER_REMOVED'
+ OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
+ OR last_error_type IS NOT 'provider_removed'
+ OR last_error_source IS NOT 'migration:retire-felo-web'
+ OR last_error_at IS NULL
+ );
+
+ UPDATE exclusive_connection_leases
+ SET state = 'INVALIDATED',
+ ended_at = datetime('now'),
+ end_reason = 'CONNECTION_INELIGIBLE'
+ WHERE state = 'ACTIVE'
+ AND connection_id = NEW.id;
+END;
+
+CREATE TRIGGER IF NOT EXISTS provider_connections_retire_felo_web_update
+AFTER UPDATE OF provider, is_active, test_status, error_code, last_error,
+ last_error_type, last_error_source, last_error_at ON provider_connections
+WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+BEGIN
+ UPDATE provider_connections
+ SET is_active = 0,
+ test_status = 'unavailable',
+ error_code = 'PROVIDER_REMOVED',
+ last_error = 'Provider integration retired from OmniRoute v3.8.50',
+ last_error_type = 'provider_removed',
+ last_error_source = 'migration:retire-felo-web',
+ last_error_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = NEW.id
+ AND (
+ is_active IS NOT 0
+ OR test_status IS NOT 'unavailable'
+ OR error_code IS NOT 'PROVIDER_REMOVED'
+ OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
+ OR last_error_type IS NOT 'provider_removed'
+ OR last_error_source IS NOT 'migration:retire-felo-web'
+ OR last_error_at IS NULL
+ );
+
+ UPDATE exclusive_connection_leases
+ SET state = 'INVALIDATED',
+ ended_at = datetime('now'),
+ end_reason = 'CONNECTION_INELIGIBLE'
+ WHERE state = 'ACTIVE'
+ AND connection_id = NEW.id;
+END;
+
+-- Once a connection id belongs to a retired provider, imports and internal
+-- writers must not repurpose that same audited identity as another provider.
+-- Retired-to-retired normalization remains allowed and is re-tombstoned by the
+-- AFTER UPDATE trigger above.
+CREATE TRIGGER IF NOT EXISTS provider_connections_preserve_felo_web_identity_insert
+BEFORE INSERT ON provider_connections
+WHEN EXISTS (
+ SELECT 1
+ FROM provider_connections
+ WHERE id = NEW.id
+ AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ )
+ AND lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ NOT IN ('felo-web', 'felo')
+BEGIN
+ SELECT RAISE(ABORT, 'Retired provider connection identity cannot be changed');
+END;
+
+CREATE TRIGGER IF NOT EXISTS provider_connections_preserve_felo_web_identity_update
+BEFORE UPDATE OF provider ON provider_connections
+WHEN lower(trim(OLD.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ AND lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ NOT IN ('felo-web', 'felo')
+BEGIN
+ SELECT RAISE(ABORT, 'Retired provider connection identity cannot be changed');
+END;
+
+-- A restore can also insert lease rows after migrations have completed. Keep
+-- lease state fail-closed independently of request-time auth selection.
+CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_felo_web_insert
+AFTER INSERT ON exclusive_connection_leases
+WHEN NEW.state = 'ACTIVE'
+ AND (
+ lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ OR EXISTS (
+ SELECT 1
+ FROM provider_connections
+ WHERE id = NEW.connection_id
+ AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ )
+ )
+BEGIN
+ UPDATE exclusive_connection_leases
+ SET state = 'INVALIDATED',
+ ended_at = datetime('now'),
+ end_reason = 'CONNECTION_INELIGIBLE'
+ WHERE id = NEW.id
+ AND state = 'ACTIVE';
+END;
+
+CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_felo_web_update
+AFTER UPDATE OF provider, connection_id, state ON exclusive_connection_leases
+WHEN NEW.state = 'ACTIVE'
+ AND (
+ lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ OR EXISTS (
+ SELECT 1
+ FROM provider_connections
+ WHERE id = NEW.connection_id
+ AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
+ IN ('felo-web', 'felo')
+ )
+ )
+BEGIN
+ UPDATE exclusive_connection_leases
+ SET state = 'INVALIDATED',
+ ended_at = datetime('now'),
+ end_reason = 'CONNECTION_INELIGIBLE'
+ WHERE id = NEW.id
+ AND state = 'ACTIVE';
+END;
diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts
index 2f3d0c6aaa..40235833e5 100644
--- a/src/lib/db/providers.ts
+++ b/src/lib/db/providers.ts
@@ -3,6 +3,7 @@
*/
import { v4 as uuidv4 } from "uuid";
+import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
import { getDbInstance, rowToCamel, cleanNulls } from "./core";
import { backupDbFile } from "./backup";
import {
@@ -596,17 +597,23 @@ export async function createProviderConnection(data: JsonRecord) {
_updateConnectionRow(db, existingId, encryptConnectionFields(persistence));
})();
backupDbFile("pre-write");
- if (isMicrosoftDesignerWebRetiredProviderId(merged.provider)) {
- invalidateDbCache("connections");
- return getProviderConnectionById(existingId);
- }
- return withNullableRateLimitOverrides(
+ const returnedConnection = withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(merged), merged),
merged
),
merged
);
+
+ if (
+ isMicrosoftDesignerWebRetiredProviderId(merged.provider) ||
+ isRuntimeRetiredProviderId(merged.provider)
+ ) {
+ invalidateDbCache("connections");
+ return (await getProviderConnectionById(existingId)) ?? returnedConnection;
+ }
+
+ return returnedConnection;
}
// Generate name: prefer explicit name, then email, then a stable short-ID label.
@@ -727,17 +734,22 @@ export async function createProviderConnection(data: JsonRecord) {
backupDbFile("pre-write");
invalidateDbCache("connections"); // Bust connections read cache
- if (isMicrosoftDesignerWebRetiredProviderId(data.provider)) {
- return getProviderConnectionById(String(connection.id));
- }
-
- return withNullableRateLimitOverrides(
+ const returnedConnection = withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(connection), connection),
connection
),
connection
);
+
+ if (
+ isMicrosoftDesignerWebRetiredProviderId(data.provider) ||
+ isRuntimeRetiredProviderId(providerId)
+ ) {
+ return (await getProviderConnectionById(String(connection.id))) ?? returnedConnection;
+ }
+
+ return returnedConnection;
}
function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
@@ -976,17 +988,22 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
reorderConnections(db, providerId);
}
- if (isMicrosoftDesignerWebRetiredProviderId(merged.provider)) {
- return getProviderConnectionById(id);
- }
-
- return withNullableRateLimitOverrides(
+ const returnedConnection = withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(merged), merged),
merged
),
merged
);
+
+ if (
+ isMicrosoftDesignerWebRetiredProviderId(merged.provider) ||
+ isRuntimeRetiredProviderId(merged.provider)
+ ) {
+ return (await getProviderConnectionById(id)) ?? returnedConnection;
+ }
+
+ return returnedConnection;
}
export {
diff --git a/src/lib/providerNodePrefixes.ts b/src/lib/providerNodePrefixes.ts
index 83a4fbb623..2ce26a543b 100644
--- a/src/lib/providerNodePrefixes.ts
+++ b/src/lib/providerNodePrefixes.ts
@@ -35,8 +35,11 @@
* Built-in/no-compatible catalog entries are always eligible.
*/
-import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getProviderNodes } from "@/lib/db/providers/nodes";
+import {
+ getReservedProviderPrefixes,
+ isReservedProviderPrefix,
+} from "@/shared/constants/reservedProviderPrefixes";
export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved";
@@ -66,12 +69,11 @@ export interface ProviderPrefixIndex {
* prefixes can never shadow a built-in provider.
*/
export function buildReservedPrefixes(): Set {
- const reserved = new Set();
- for (const entry of Object.values(REGISTRY)) {
- if (entry?.id) reserved.add(entry.id);
- if (entry?.alias) reserved.add(entry.alias);
- }
- return reserved;
+ return new Set(getReservedProviderPrefixes());
+}
+
+export function isProviderNodePrefixReserved(value: unknown): boolean {
+ return isReservedProviderPrefix(value);
}
export interface CompatibleNodeLike {
@@ -97,7 +99,6 @@ export function selectCompatibleNodeForPrefix(
}
export async function getProviderPrefixIndex(): Promise {
- const reserved = buildReservedPrefixes();
const nodes = (await getProviderNodes()) as CompatibleNodeLike[];
const compatible = nodes.filter(
(n) => n.type === "openai-compatible" || n.type === "anthropic-compatible"
@@ -123,7 +124,7 @@ export async function getProviderPrefixIndex(): Promise {
const eligibleNodeIds = new Set();
for (const [prefix, prefixNodes] of byPrefix) {
- if (reserved.has(prefix)) {
+ if (isProviderNodePrefixReserved(prefix)) {
// Built-in registry id/alias — never a compatible public target.
entries.set(prefix, { prefix, status: "reserved" });
continue;
diff --git a/src/shared/constants/providerRetirement.ts b/src/shared/constants/providerRetirement.ts
new file mode 100644
index 0000000000..975d7a9b88
--- /dev/null
+++ b/src/shared/constants/providerRetirement.ts
@@ -0,0 +1,44 @@
+/**
+ * Provider ids that must remain unavailable even when stale rows are restored
+ * after migrations have already run. Keep canonical ids and legacy aliases
+ * together so neither executor dispatch nor credential selection can fall back.
+ */
+export const RUNTIME_RETIRED_PROVIDER_IDS: ReadonlySet = new Set(["felo-web", "felo"]);
+export const RUNTIME_PROVIDER_RETIRED_ERROR_CODE = "PROVIDER_RETIRED";
+export const RUNTIME_PROVIDER_RETIRED_MESSAGE = "Provider is retired and unavailable.";
+
+type RuntimeProviderRetirementError = Error & {
+ code: typeof RUNTIME_PROVIDER_RETIRED_ERROR_CODE;
+ status: 410;
+};
+
+export function isRuntimeRetiredProviderId(providerId: unknown): providerId is string {
+ return (
+ typeof providerId === "string" &&
+ RUNTIME_RETIRED_PROVIDER_IDS.has(providerId.trim().toLowerCase())
+ );
+}
+
+export function assertRuntimeProviderAvailable(providerId: unknown): void {
+ if (!isRuntimeRetiredProviderId(providerId)) return;
+
+ const error = new Error(RUNTIME_PROVIDER_RETIRED_MESSAGE) as RuntimeProviderRetirementError;
+ error.code = RUNTIME_PROVIDER_RETIRED_ERROR_CODE;
+ error.status = 410;
+ throw error;
+}
+
+export function assertRuntimeModelProviderAvailable(modelId: unknown): void {
+ if (typeof modelId !== "string") return;
+ const slashIndex = modelId.indexOf("/");
+ if (slashIndex <= 0) return;
+ assertRuntimeProviderAvailable(modelId.slice(0, slashIndex));
+}
+
+export function isRuntimeProviderRetirementError(
+ error: unknown
+): error is RuntimeProviderRetirementError {
+ if (!(error instanceof Error)) return false;
+ const typed = error as Error & { code?: unknown; status?: unknown };
+ return typed.code === RUNTIME_PROVIDER_RETIRED_ERROR_CODE && typed.status === 410;
+}
diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts
index dabf42442e..f4a6de066c 100644
--- a/src/shared/constants/providers/noauth.ts
+++ b/src/shared/constants/providers/noauth.ts
@@ -74,23 +74,6 @@ export const NOAUTH_PROVIDERS = {
text: "Cloudflare AI Playground uses a reverse-engineered anonymous WebSocket protocol (no official API). Requires Playwright with a Chromium browser on first request. Rate limits apply per IP (error 3021).",
},
},
- "felo-web": {
- id: "felo-web",
- alias: "felo",
- name: "Felo",
- icon: "travel_explore",
- color: "#5B7FFF",
- textIcon: "FL",
- website: "https://felo.ai",
- noAuth: true,
- hasFree: true,
- serviceKinds: ["llm"],
- freeNote: "Free — anonymous access to Felo's chat/search-agent aggregator. No API key.",
- authHint: "No credentials required — Felo is a free, no-signup chat/search aggregator.",
- notice: {
- text: "Felo uses a reverse-engineered public endpoint (no official API). No signup or API key needed. Behavior may change without notice if Felo updates its frontend.",
- },
- },
theoldllm: {
id: "theoldllm",
alias: "tllm",
diff --git a/src/shared/constants/reservedProviderPrefixes.ts b/src/shared/constants/reservedProviderPrefixes.ts
index 04ad0c753d..e1a4578e6e 100644
--- a/src/shared/constants/reservedProviderPrefixes.ts
+++ b/src/shared/constants/reservedProviderPrefixes.ts
@@ -25,6 +25,7 @@ import {
isMicrosoftDesignerWebRetiredProviderId,
RETIRED_MICROSOFT_DESIGNER_WEB_PROVIDER_IDS,
} from "@/shared/constants/designerWebRetirement";
+import { isRuntimeRetiredProviderId, RUNTIME_RETIRED_PROVIDER_IDS } from "./providerRetirement";
let _reserved: Set | null = null;
@@ -38,13 +39,15 @@ function buildReservedProviderPrefixes(): Set {
for (const providerId of RETIRED_MICROSOFT_DESIGNER_WEB_PROVIDER_IDS) {
reserved.add(providerId);
}
+ for (const providerId of RUNTIME_RETIRED_PROVIDER_IDS) reserved.add(providerId);
_reserved = reserved;
return reserved;
}
/**
- * All canonical reserved provider prefixes (REGISTRY ids + aliases + retired ids).
- * Built lazily so the registry is only walked once per process.
+ * All canonical reserved provider prefixes (REGISTRY ids + aliases + retired
+ * provider tombstones). Built lazily so the registry is only walked once per
+ * process.
*/
export function getReservedProviderPrefixes(): ReadonlySet {
return buildReservedProviderPrefixes();
@@ -69,7 +72,8 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet = getReservedProvid
export function isReservedProviderPrefix(value: unknown): boolean {
return (
(typeof value === "string" && buildReservedProviderPrefixes().has(value)) ||
- isMicrosoftDesignerWebRetiredProviderId(value)
+ isMicrosoftDesignerWebRetiredProviderId(value) ||
+ isRuntimeRetiredProviderId(value)
);
}
@@ -78,5 +82,5 @@ export function isReservedProviderPrefix(value: unknown): boolean {
* prefix and tells the operator what to pick instead.
*/
export function reservedProviderPrefixMessage(value: string): string {
- return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like /model would always route to the built-in provider)`;
+ return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like /model route to a built-in provider or fail closed when retired)`;
}
diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts
index a99ba7c947..696db7de9c 100644
--- a/src/shared/validation/schemas/provider.ts
+++ b/src/shared/validation/schemas/provider.ts
@@ -339,6 +339,17 @@ export const createProviderNodeSchema = z
})
.superRefine((value, ctx) => {
const nodeType = value.type || "openai-compatible";
+ const normalizedPrefix = value.prefix?.trim();
+ if (normalizedPrefix && isReservedProviderPrefix(normalizedPrefix)) {
+ // Validate caller-supplied prefixes before preset handling. Presets may
+ // provide a default, but the route preserves an explicit prefix; an early
+ // return here used to let retired identities create unreachable nodes.
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: reservedProviderPrefixMessage(normalizedPrefix),
+ path: ["prefix"],
+ });
+ }
if (value.preset === "vibeproxy-openai") {
// Preset supplies name/prefix/apiType — but baseUrl is still mandatory
// (a local proxy's host/port is operator-specific, unlike the generic
@@ -365,17 +376,6 @@ export const createProviderNodeSchema = z
message: "Prefix is required",
path: ["prefix"],
});
- } else if (isReservedProviderPrefix(value.prefix.trim())) {
- // Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
- // compatible-node lookup for built-in registry ids/aliases, so a node
- // created with such a prefix could never be reached by it and silently
- // routed requests to the built-in provider instead. Reject at the write
- // path. Case-sensitive to match the runtime guard exactly.
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: reservedProviderPrefixMessage(value.prefix.trim()),
- path: ["prefix"],
- });
}
if (nodeType === "openai-compatible" && !value.apiType) {
ctx.addIssue({
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index f54e94fc44..7fadb8fbe9 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -63,6 +63,7 @@ import * as log from "../utils/logger";
import { checkAndRefreshToken } from "../services/tokenRefresh";
import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry";
import { rejectPeerRequest } from "@/shared/resilience/peerRouting";
+import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
import { getComboByName, updateCombo } from "@/lib/db/combos";
import { isModelAllowedForKey } from "@/lib/db/apiKeys";
@@ -956,6 +957,7 @@ async function handleChatImplementation(
// Persisted explicit combos may still reference the retired provider. Treat
// that target as unavailable so priority/fallback strategies can continue.
if (isMicrosoftDesignerWebProviderRetiredError(error)) return false;
+ if (isRuntimeProviderRetirementError(error)) return false;
throw error;
}
// Apply the same prefix-override guard as handleSingleModelChat:
diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts
index 23bb632df1..a24c1454c4 100644
--- a/src/sse/handlers/chatHelpers.ts
+++ b/src/sse/handlers/chatHelpers.ts
@@ -28,6 +28,7 @@ import {
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
import { hasBlockingProxyAssignment } from "@/lib/db/proxies";
+import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
import {
CircuitBreakerOpenError,
getCircuitBreaker,
@@ -128,6 +129,14 @@ export async function resolveModelOrError(
if (isMicrosoftDesignerWebProviderRetiredError(error)) {
return { error: errorResponse(HTTP_STATUS.GONE, error.message) };
}
+ if (isRuntimeProviderRetirementError(error)) {
+ return {
+ error: errorResponse(error.status, error.message, {
+ type: "provider_error",
+ code: error.code,
+ }),
+ };
+ }
throw error;
}
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts
index cea122f5e7..2ab5474c62 100644
--- a/src/sse/services/auth.ts
+++ b/src/sse/services/auth.ts
@@ -33,6 +33,7 @@ import { buildJinaEnvCredentials } from "@/lib/providers/jina";
import { buildGeminiEnvCredentials } from "@/lib/providers/gemini";
import { toNumber } from "@/shared/utils/numeric";
import { isMicrosoftDesignerWebRetiredProviderId } from "@/shared/constants/designerWebRetirement";
+import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
import {
createLazyConnectionView,
toProviderConnection,
@@ -844,7 +845,7 @@ async function maybeSyntheticNoAuthFallback(
// #9057: a key pinned to specific connections via allowedConnections must
// NOT receive the synthetic "noauth" connection — the synthetic id is
// never in an explicit allowlist, so returning it would let a restricted
- // key reach free providers (felo-chat, etc.) that it should not access.
+ // key reach free providers (OpenCode Free, etc.) that it should not access.
if (Array.isArray(allowedConnections) && allowedConnections.length > 0) return null;
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null;
if (
@@ -1290,6 +1291,12 @@ export async function getProviderCredentials(
return null;
}
+ if (isRuntimeRetiredProviderId(provider)) {
+ invalidateManagedLease(options, "CONNECTION_INELIGIBLE");
+ log.warn("AUTH", "Retired provider rejected before credential selection");
+ return null;
+ }
+
const selectionLock = options._leaseRetryWithLockHeld
? null
: createSelectionLock(getSelectionMutexKey(provider, options));
diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts
index 540e6dd570..7934dae144 100644
--- a/src/sse/services/model.ts
+++ b/src/sse/services/model.ts
@@ -22,6 +22,7 @@ import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
import { assertMicrosoftDesignerWebProviderAvailable } from "@/shared/constants/designerWebRetirement";
+import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
export { parseModel, stripContextWindowSuffix };
@@ -424,6 +425,11 @@ function stripRedundantNodeRoutingSegments(model: string, routingIds: unknown[])
*/
export async function getModelInfo(modelStr) {
const parsed = parseModel(modelStr);
+ // Fail before compatible-node lookup and stripModelPrefix can erase or remap
+ // a retired provider identity. Executor/auth tombstones are later defenses;
+ // they cannot see the original prefix after either remapping path.
+ assertRuntimeProviderAvailable(parsed.providerAlias);
+ assertRuntimeProviderAvailable(parsed.provider);
const { extendedContext } = parsed;
// Fail closed before a custom compatible node or stripModelPrefix can reinterpret
diff --git a/tests/integration/combo-matrix/auto.test.ts b/tests/integration/combo-matrix/auto.test.ts
index 6a9b80918a..dc2d10c701 100644
--- a/tests/integration/combo-matrix/auto.test.ts
+++ b/tests/integration/combo-matrix/auto.test.ts
@@ -47,7 +47,6 @@ function body(model: string) {
const NO_AUTH_PROVIDER_IDS = [
"opencode",
"duckduckgo-web",
- "felo-web",
"theoldllm",
"chipotle",
"veoaifree-web",
diff --git a/tests/integration/freeModelBenchmarkShared.ts b/tests/integration/freeModelBenchmarkShared.ts
index 2621d194bb..e7a38a4844 100644
--- a/tests/integration/freeModelBenchmarkShared.ts
+++ b/tests/integration/freeModelBenchmarkShared.ts
@@ -36,7 +36,7 @@ export interface FreeModelSpec {
// resolution path). getActiveProviders() only sees configured *connections*,
// so these have to be unioned in separately or every no-auth model gets
// filtered out as "not active" even though they work with zero setup.
-export const NO_AUTH_PROVIDER_IDS = new Set(["felo-web", "aihorde", "opencode", "duckduckgo-web"]);
+export const NO_AUTH_PROVIDER_IDS = new Set(["aihorde", "opencode", "duckduckgo-web"]);
// Curated from open-sse/config/freeModelCatalog.data.ts: the original 5
// providers configured+active on this deployment (checked via GET
@@ -82,7 +82,6 @@ export const FREE_MODELS: FreeModelSpec[] = [
model: "openrouter/auto",
displayName: "Auto — Best Available (OpenRouter free pool)",
},
- { provider: "felo-web", model: "felo-web/felo-chat", displayName: "Felo Chat (no-auth)" },
{
provider: "aihorde",
model: "aihorde/google/gemma-4-31b",
diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json
index ec115fada2..30256b6525 100644
--- a/tests/snapshots/executors/executor-map.json
+++ b/tests/snapshots/executors/executor-map.json
@@ -285,16 +285,6 @@
"configSource": "freebuff",
"provider": "freebuff"
},
- "felo": {
- "className": "FeloWebExecutor",
- "configSource": "",
- "provider": "felo-web"
- },
- "felo-web": {
- "className": "FeloWebExecutor",
- "configSource": "",
- "provider": "felo-web"
- },
"firefly": {
"className": "AdobeFireflyExecutor",
"configSource": "",
diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json
index f9e96d0909..f037b44e93 100644
--- a/tests/snapshots/provider/translate-path.json
+++ b/tests/snapshots/provider/translate-path.json
@@ -2069,29 +2069,6 @@
"stream": "https://api.featherless.ai/v1/chat/completions"
}
},
- "felo-web": {
- "format": "openai",
- "headers": {
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json"
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json"
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json"
- }
- },
- "url": {
- "nonStream": "https://felo.ai/api-proxy/main/search/threads",
- "stream": "https://felo.ai/api-proxy/main/search/threads"
- }
- },
"fireworks": {
"format": "openai",
"headers": {
diff --git a/tests/unit/api-key-policy-noauth-allowed-connections.test.ts b/tests/unit/api-key-policy-noauth-allowed-connections.test.ts
index 73040c648f..985c341c37 100644
--- a/tests/unit/api-key-policy-noauth-allowed-connections.test.ts
+++ b/tests/unit/api-key-policy-noauth-allowed-connections.test.ts
@@ -3,7 +3,7 @@
*
* TDD regression test: an API key pinned via `allowedConnections` to a specific
* connection must NOT receive synthetic no-auth credentials for free providers
- * (e.g. felo-chat).
+ * (e.g. OpenCode Free).
*/
import test from "node:test";
@@ -25,31 +25,36 @@ const RESTRICTED_CONNECTION_UUID = "00000000-0000-4000-8000-000000000001";
test.after(() => {
coreDb.resetDbInstance();
- try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
+ try {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ } catch {}
});
-test("#9057 LAYER1: restricted key gets NO synthetic credentials for noauth provider felo", async () => {
+test("#9057 LAYER1: restricted key gets NO synthetic credentials for OpenCode Free", async () => {
// LAYER1: getProviderCredentials() with explicit allowedConnections
// must NOT return synthetic noauth credentials because the synthetic
// "noauth" connection is never in an explicit allowed-connections list.
const creds = await getProviderCredentials(
- "felo",
+ "opencode",
null,
[RESTRICTED_CONNECTION_UUID], // allowedConnections restricts to a real UUID
- "felo-chat"
+ "big-pickle"
+ );
+ assert.equal(
+ creds,
+ null,
+ "OpenCode Free must not leak synthetic credentials for a connection-restricted key"
);
- assert.equal(creds, null,
- "noauth provider felo must not leak synthetic credentials for a connection-restricted key");
});
-test("#9057 LAYER1: unrestricted key still gets synthetic credentials for felo", async () => {
+test("#9057 LAYER1: unrestricted key still gets synthetic credentials for OpenCode Free", async () => {
const creds = await getProviderCredentials(
- "felo",
+ "opencode",
null,
- null, // allowedConnections=null means unrestricted
- "felo-chat"
+ null, // allowedConnections=null means unrestricted
+ "big-pickle"
);
- assert(creds, "unrestricted key must receive synthetic credentials for felo");
+ assert(creds, "unrestricted key must receive synthetic credentials for OpenCode Free");
assert.equal(
(creds as Record)?.connectionId,
"noauth",
@@ -57,7 +62,7 @@ test("#9057 LAYER1: unrestricted key still gets synthetic credentials for felo",
);
});
-test("#9057 LAYER2: isModelAllowedForKey rejects felo-chat for disableNonPublicModels key", async () => {
+test("#9057 LAYER2: isModelAllowedForKey rejects keyless model for disableNonPublicModels key", async () => {
// Create a key with disableNonPublicModels=true
const created = await apiKeysDb.createApiKey("dnp-9057", "machine-dnp");
assert(created, "key must be created");
@@ -66,6 +71,6 @@ test("#9057 LAYER2: isModelAllowedForKey rejects felo-chat for disableNonPublicM
disableNonPublicModels: true,
});
- const allowed = await isModelAllowedForKey(key, "felo-chat");
- assert.equal(allowed, false, "disableNonPublicModels key must reject felo-chat");
+ const allowed = await isModelAllowedForKey(key, "big-pickle");
+ assert.equal(allowed, false, "disableNonPublicModels key must reject keyless models");
});
diff --git a/tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts b/tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts
index bb93e3601b..bf11ab3b73 100644
--- a/tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts
+++ b/tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts
@@ -35,7 +35,7 @@ function safeState(): FreeAccessState {
// `findBudgetEntry(candidate, catalog)`. These provider ids are otherwise
// arbitrary — the fixtures below prove the behavior is driven entirely by
// catalog membership, not by any hardcoded provider/model name.
-const KEYLESS_PROVIDER = "felo-web";
+const KEYLESS_PROVIDER = "synthetic-keyless-provider";
const QUOTA_PROVIDER = "groq";
const REAL_CONN = "conn-1";
diff --git a/tests/unit/autoCombo/strict-zero-cost-filter.test.ts b/tests/unit/autoCombo/strict-zero-cost-filter.test.ts
index dc293a6dda..59ab8c226c 100644
--- a/tests/unit/autoCombo/strict-zero-cost-filter.test.ts
+++ b/tests/unit/autoCombo/strict-zero-cost-filter.test.ts
@@ -41,11 +41,11 @@ const BASE_OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000, now: no
const REAL_CONN = "conn-real-1";
-// A real keyless entry from the catalog (felo-web, all models keyless/tos=avoid),
+// A real keyless entry from the catalog (OpenCode Free, all models keyless/tos=avoid),
// as a genuine no-auth candidate (the only shape that legitimately gets the shortcut).
const KEYLESS = {
- provider: "felo-web",
- model: "felo-chat",
+ provider: "opencode",
+ model: "big-pickle",
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
};
// A real quota-based entry with hardStopGuaranteed: true (added by this feature),
@@ -80,17 +80,21 @@ test("sanity: fixtures exist in the real catalog with the metadata these tests a
true,
"agentrouter must NOT carry hardStopGuaranteed: true (no documented hard-stop guarantee)"
);
- const feloEntry = FREE_MODEL_BUDGETS.find(
- (m) => m.provider === "felo-web" && m.modelId === "felo-chat"
+ const keylessEntry = FREE_MODEL_BUDGETS.find(
+ (m) => m.provider === "opencode" && m.modelId === "big-pickle"
+ );
+ assert.equal(keylessEntry?.freeType, "keyless");
+ assert.equal(
+ keylessEntry?.tos,
+ "avoid",
+ "opencode must be tos=avoid for the ToS-guard tests below"
);
- assert.equal(feloEntry?.freeType, "keyless");
- assert.equal(feloEntry?.tos, "avoid", "felo-web must be tos=avoid for the ToS-guard tests below");
});
// 1. keyless SAFE (genuine no-auth candidate) → PASS
test("keyless candidate from the genuine no-auth path passes with no state at all", () => {
const entry = FREE_MODEL_BUDGETS.find(
- (m) => m.provider === "felo-web" && m.modelId === "felo-chat"
+ (m) => m.provider === "opencode" && m.modelId === "big-pickle"
);
assert.deepEqual(
evaluateCandidateConnections(KEYLESS, entry, () => undefined, BASE_OPTIONS),
@@ -210,7 +214,7 @@ test("hardStopGuaranteed explicitly false excludes", () => {
// 12 & 13. ToS guard, independent of economic evaluation
test("tos=avoid + excludeTosAvoid=true excludes a keyless-safe candidate", () => {
const result = filterTosAvoidCandidates([KEYLESS], true);
- assert.deepEqual(result, [], "felo-web (tos=avoid) must be dropped when the guard is on");
+ assert.deepEqual(result, [], "opencode (tos=avoid) must be dropped when the guard is on");
});
test("tos=avoid + excludeTosAvoid=false leaves normal economic evaluation untouched", () => {
diff --git a/tests/unit/chatcore-executor-proxy.test.ts b/tests/unit/chatcore-executor-proxy.test.ts
index 1596cd3ecc..2d794c8696 100644
--- a/tests/unit/chatcore-executor-proxy.test.ts
+++ b/tests/unit/chatcore-executor-proxy.test.ts
@@ -154,3 +154,25 @@ test("connection proxy overrides cannot bypass Microsoft Designer retirement", a
}
}
});
+
+test("retired Felo ids cannot bypass the tombstone through a connection proxy", async () => {
+ for (const providerId of ["felo-web", "felo", " FeLo-Web ", "\tFELO\n"]) {
+ await assert.rejects(
+ resolveExecutorWithProxy(providerId, undefined, {
+ cliproxyapiMode: "claude-native",
+ }),
+ (error: unknown) => {
+ const typed = error as Error & { code?: string; status?: number };
+ assert.equal(typed.code, "PROVIDER_RETIRED");
+ assert.equal(typed.status, 410);
+ assert.match(typed.message, /retired/i);
+ return true;
+ }
+ );
+ }
+
+ const openAi = await resolveExecutorWithProxy("openai", undefined, {
+ cliproxyapiMode: "claude-native",
+ });
+ assert.equal(openAi, await getExecutor("cliproxyapi"));
+});
diff --git a/tests/unit/felo-web-executor.test.ts b/tests/unit/felo-web-executor.test.ts
deleted file mode 100644
index 232ca72b55..0000000000
--- a/tests/unit/felo-web-executor.test.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-import { describe, it, beforeEach, afterEach } from "node:test";
-import assert from "node:assert/strict";
-import type { ExecuteInput } from "../../open-sse/executors/base.ts";
-
-const mod = await import("../../open-sse/executors/felo-web.ts");
-const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
-const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
-
-const {
- FeloWebExecutor,
- FELO_THREADS_URL,
- feloStreamUrl,
- normalizeFeloModel,
- resolveFeloCategory,
- extractFeloLastUserPrompt,
- buildFeloThreadPayload,
- parseFeloStreamLine,
- accumulateFeloStreamText,
-} = mod;
-
-type FetchCall = { url: string; init: RequestInit };
-
-const realFetch = globalThis.fetch;
-let calls: FetchCall[] = [];
-
-function threadsResponse(streamKey = "sk-123", status = 200): Response {
- return new Response(JSON.stringify({ stream_key: streamKey }), {
- status,
- headers: { "content-type": "application/json" },
- });
-}
-
-/** Build a Felo-shaped `data:{...}` stream body from a list of answer snapshots. */
-function feloStreamResponse(answerSnapshots: string[], includeSourcesEvent = false): Response {
- const encoder = new TextEncoder();
- const lines: string[] = [];
- for (const text of answerSnapshots) {
- const contentJson = { data: { type: "answer", data: { text } } };
- lines.push(`data:${JSON.stringify({ content: JSON.stringify(contentJson) })}`);
- }
- if (includeSourcesEvent) {
- const contentJson = {
- data: {
- type: "final_contexts",
- data: { sources: [{ link: "https://example.com", title: "Example" }] },
- },
- };
- lines.push(`data:${JSON.stringify({ content: JSON.stringify(contentJson) })}`);
- }
- const stream = new ReadableStream({
- start(controller) {
- controller.enqueue(encoder.encode(lines.join("\n") + "\n"));
- controller.close();
- },
- });
- return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } });
-}
-
-function mockFetch(handler: (url: string, init: RequestInit) => Response | Promise): void {
- globalThis.fetch = (async (input: RequestInfo | URL, init: RequestInit = {}) => {
- const url = String(input);
- calls.push({ url, init });
- return handler(url, init);
- }) as typeof fetch;
-}
-
-function jsonBody(init: RequestInit): Record {
- return JSON.parse(String(init.body)) as Record;
-}
-
-function baseExecuteInput(overrides: Partial = {}): ExecuteInput {
- return {
- model: "felo-chat",
- body: { messages: [{ role: "user", content: "hi" }] },
- stream: false,
- credentials: {},
- signal: null,
- ...overrides,
- };
-}
-
-beforeEach(() => {
- calls = [];
-});
-
-afterEach(() => {
- globalThis.fetch = realFetch;
-});
-
-describe("FeloWebExecutor — registry wiring", () => {
- it("is registered as a canonical noAuth provider (providers.ts)", () => {
- const provider = AI_PROVIDERS["felo-web"];
- assert.ok(provider, "felo-web should be a canonical provider");
- assert.equal(provider.noAuth, true);
- });
-
- it("is registered in the provider REGISTRY with the felo-web executor", () => {
- const entry = REGISTRY["felo-web"];
- assert.ok(entry, "felo-web should have a REGISTRY entry");
- assert.equal(entry.executor, "felo-web");
- assert.equal(entry.authType, "none");
- assert.ok(entry.models.some((m) => m.id === "felo-chat"));
- });
-});
-
-describe("FeloWebExecutor — pure helpers", () => {
- it("normalizeFeloModel: strips the felo-web/ prefix and falls back to felo-chat", () => {
- assert.equal(normalizeFeloModel("felo-web/felo-search"), "felo-search");
- assert.equal(normalizeFeloModel("felo-scholar"), "felo-scholar");
- assert.equal(normalizeFeloModel("not-a-real-model"), "felo-chat");
- assert.equal(normalizeFeloModel(undefined), "felo-chat");
- });
-
- it("resolveFeloCategory: maps each model alias to its g4f category", () => {
- assert.equal(resolveFeloCategory("felo-chat"), "chat");
- assert.equal(resolveFeloCategory("felo-search"), "google");
- assert.equal(resolveFeloCategory("felo-scholar"), "scholar");
- assert.equal(resolveFeloCategory("felo-social"), "social");
- assert.equal(resolveFeloCategory("felo-document"), "document");
- });
-
- it("extractFeloLastUserPrompt: picks the last user message, string content", () => {
- const prompt = extractFeloLastUserPrompt([
- { role: "system", content: "be nice" },
- { role: "user", content: "first" },
- { role: "assistant", content: "reply" },
- { role: "user", content: "second" },
- ]);
- assert.equal(prompt, "second");
- });
-
- it("extractFeloLastUserPrompt: joins array-of-parts content", () => {
- const prompt = extractFeloLastUserPrompt([
- {
- role: "user",
- content: [
- { type: "text", text: "part one" },
- { type: "text", text: "part two" },
- ],
- },
- ]);
- assert.equal(prompt, "part one\npart two");
- });
-
- it("buildFeloThreadPayload: carries the query and resolved category", () => {
- const payload = buildFeloThreadPayload("felo-search", "hello world");
- assert.equal(payload.query, "hello world");
- assert.equal(payload.category, "google");
- assert.equal(payload.stream_protocol, "message_center_v1");
- assert.equal(typeof payload.search_uuid, "string");
- assert.ok((payload.search_uuid as string).length > 0);
- });
-
- it("parseFeloStreamLine: ignores non-data lines and malformed JSON", () => {
- assert.deepEqual(parseFeloStreamLine("", "prev"), { newText: null, nextPreviousText: "prev" });
- assert.deepEqual(parseFeloStreamLine("not-a-data-line", "prev"), {
- newText: null,
- nextPreviousText: "prev",
- });
- assert.deepEqual(parseFeloStreamLine("data:{not json", "prev"), {
- newText: null,
- nextPreviousText: "prev",
- });
- });
-
- it("parseFeloStreamLine: diffs incremental answer snapshots against the running text", () => {
- const line1 = `data:${JSON.stringify({
- content: JSON.stringify({ data: { type: "answer", data: { text: "Hel" } } }),
- })}`;
- const line2 = `data:${JSON.stringify({
- content: JSON.stringify({ data: { type: "answer", data: { text: "Hello" } } }),
- })}`;
-
- const first = parseFeloStreamLine(line1, "");
- assert.equal(first.newText, "Hel");
- assert.equal(first.nextPreviousText, "Hel");
-
- const second = parseFeloStreamLine(line2, first.nextPreviousText);
- assert.equal(second.newText, "lo");
- assert.equal(second.nextPreviousText, "Hello");
- });
-
- it("parseFeloStreamLine: ignores final_contexts events (no OpenAI-compatible slot)", () => {
- const line = `data:${JSON.stringify({
- content: JSON.stringify({
- data: { type: "final_contexts", data: { sources: [{ link: "https://x", title: "X" }] } },
- }),
- })}`;
- assert.deepEqual(parseFeloStreamLine(line, "prev"), { newText: null, nextPreviousText: "prev" });
- });
-
- it("accumulateFeloStreamText: replays a full stream body into the final text", () => {
- const raw = [
- `data:${JSON.stringify({ content: JSON.stringify({ data: { type: "answer", data: { text: "Hi" } } }) })}`,
- `data:${JSON.stringify({
- content: JSON.stringify({ data: { type: "answer", data: { text: "Hi there" } } }),
- })}`,
- ].join("\n");
- assert.equal(accumulateFeloStreamText(raw), "Hi there");
- });
-});
-
-describe("FeloWebExecutor — execute() input validation", () => {
- it("rejects an empty messages array with 400", async () => {
- const executor = new FeloWebExecutor();
- const response = await executor.execute(baseExecuteInput({ body: { messages: [] } }));
-
- assert.equal(response.status, 400);
- const responseBody = (await response.json()) as { error?: { message?: string } };
- assert.ok(responseBody.error?.message);
- });
-
- it("rejects messages with no extractable user prompt with 400", async () => {
- const executor = new FeloWebExecutor();
- const response = await executor.execute(
- baseExecuteInput({ body: { messages: [{ role: "system", content: "no user turn" }] } })
- );
-
- assert.equal(response.status, 400);
- });
-});
-
-describe("FeloWebExecutor — execute() happy path (mocked fetch)", () => {
- it("POSTs the thread payload, GETs the stream, and returns non-streaming OpenAI JSON", async () => {
- mockFetch((url) => {
- if (url === FELO_THREADS_URL) return threadsResponse("sk-abc");
- if (url === feloStreamUrl("sk-abc")) return feloStreamResponse(["Hel", "Hello", "Hello there"], true);
- throw new Error(`unexpected fetch: ${url}`);
- });
-
- const executor = new FeloWebExecutor();
- const response = await executor.execute(baseExecuteInput());
-
- assert.equal(calls.length, 2, "should call threads then stream exactly once each");
- assert.equal(calls[0].init.method, "POST");
- const threadPayload = jsonBody(calls[0].init);
- assert.equal(threadPayload.query, "hi");
- assert.equal(threadPayload.category, "chat");
-
- assert.equal(response.status, 200);
- const json = (await response.json()) as {
- choices: Array<{ message: { role: string; content: string }; finish_reason: string }>;
- };
- assert.equal(json.choices[0].message.content, "Hello there");
- assert.equal(json.choices[0].message.role, "assistant");
- assert.equal(json.choices[0].finish_reason, "stop");
- });
-
- it("streams OpenAI-compatible SSE chunks ending with [DONE]", async () => {
- mockFetch((url) => {
- if (url === FELO_THREADS_URL) return threadsResponse("sk-stream");
- if (url === feloStreamUrl("sk-stream")) return feloStreamResponse(["A", "AB", "ABC"]);
- throw new Error(`unexpected fetch: ${url}`);
- });
-
- const executor = new FeloWebExecutor();
- const response = await executor.execute(baseExecuteInput({ stream: true }));
-
- assert.equal(response.status, 200);
- assert.ok(response.body);
- const text = await response.text();
- assert.match(text, /"content":"A"/);
- assert.match(text, /"content":"B"/);
- assert.match(text, /"content":"C"/);
- assert.match(text, /data: \[DONE\]/);
- });
-});
-
-describe("FeloWebExecutor — error paths", () => {
- it("propagates a 5xx from thread creation as a sanitized 502", async () => {
- mockFetch((url) => {
- if (url === FELO_THREADS_URL) return new Response("upstream on fire", { status: 503 });
- throw new Error(`unexpected fetch: ${url}`);
- });
-
- const executor = new FeloWebExecutor();
- const response = await executor.execute(baseExecuteInput());
-
- assert.equal(response.status, 502);
- const responseBody = (await response.json()) as { error: { message: string } };
- assert.ok(responseBody.error.message.includes("HTTP 503"));
- assert.ok(!responseBody.error.message.includes("at /"), "must not leak a stack trace");
- });
-
- it("returns 502 when the threads response omits stream_key", async () => {
- mockFetch((url) => {
- if (url === FELO_THREADS_URL) {
- return new Response(JSON.stringify({}), {
- status: 200,
- headers: { "content-type": "application/json" },
- });
- }
- throw new Error(`unexpected fetch: ${url}`);
- });
-
- const executor = new FeloWebExecutor();
- const response = await executor.execute(baseExecuteInput());
-
- assert.equal(response.status, 502);
- const responseBody = (await response.json()) as { error: { message: string } };
- assert.match(responseBody.error.message, /stream_key/);
- });
-});
-
-describe("FeloWebExecutor — testConnection", () => {
- it("returns true when threads endpoint responds with a stream_key", async () => {
- mockFetch(() => threadsResponse("sk-health"));
- const executor = new FeloWebExecutor();
- assert.equal(await executor.testConnection({}), true);
- });
-
- it("returns false on a non-ok response", async () => {
- mockFetch(() => new Response("nope", { status: 500 }));
- const executor = new FeloWebExecutor();
- assert.equal(await executor.testConnection({}), false);
- });
-
- it("returns false on a network error", async () => {
- globalThis.fetch = (async () => {
- throw new Error("network down");
- }) as typeof fetch;
- const executor = new FeloWebExecutor();
- assert.equal(await executor.testConnection({}), false);
- });
-});
diff --git a/tests/unit/felo-web-retirement.test.ts b/tests/unit/felo-web-retirement.test.ts
new file mode 100644
index 0000000000..648492282a
--- /dev/null
+++ b/tests/unit/felo-web-retirement.test.ts
@@ -0,0 +1,56 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import test from "node:test";
+
+import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.ts";
+import { REGISTRY, getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
+import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
+import { AI_PROVIDERS } from "../../src/shared/constants/providers.ts";
+
+test("Felo Web provenance-hold integration is absent from runtime dispatch", () => {
+ assert.equal(REGISTRY["felo-web"], undefined);
+ assert.equal(getRegistryEntry("felo-web"), null);
+ assert.equal(getRegistryEntry("felo"), null);
+ assert.equal(AI_PROVIDERS["felo-web"], undefined);
+ assert.equal(hasSpecializedExecutor("felo-web"), false);
+ assert.equal(hasSpecializedExecutor("felo"), false);
+});
+
+test("retired Felo ids fail closed instead of falling through to OpenAI", async () => {
+ for (const providerId of ["felo-web", "felo", " FeLo-Web ", "\tFELO\n"]) {
+ await assert.rejects(
+ () => getExecutor(providerId),
+ (error: unknown) => {
+ const typed = error as Error & { status?: number };
+ assert.equal(typed.status, 410);
+ assert.match(typed.message, /retired/i);
+ return true;
+ },
+ `${providerId} must never receive DefaultExecutor fallback`
+ );
+ }
+});
+
+test("Felo Web models are absent from the executable free-model catalog", () => {
+ const feloModels = FREE_MODEL_BUDGETS.filter(
+ ({ provider, modelId }) => provider === "felo-web" || modelId.startsWith("felo-")
+ );
+
+ assert.deepEqual(feloModels, []);
+});
+
+test("Felo Web implementation files are absent from the shipped tree", () => {
+ const removedPaths = [
+ "open-sse/config/providers/registry/felo-web/index.ts",
+ "open-sse/executors/felo-web.ts",
+ ];
+
+ for (const relativePath of removedPaths) {
+ assert.equal(
+ fs.existsSync(path.join(process.cwd(), relativePath)),
+ false,
+ `${relativePath} must not ship`
+ );
+ }
+});
diff --git a/tests/unit/felo-web-runtime-block.test.ts b/tests/unit/felo-web-runtime-block.test.ts
new file mode 100644
index 0000000000..82eea583f2
--- /dev/null
+++ b/tests/unit/felo-web-runtime-block.test.ts
@@ -0,0 +1,405 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-felo-runtime-block-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts");
+const modelAliasesDb = await import("../../src/lib/db/models/aliases.ts");
+const settingsDb = await import("../../src/lib/db/settings.ts");
+const modelAliasResolver = await import("../../src/lib/modelAliasResolver.ts");
+const providerPrefixIndex = await import("../../src/lib/providerNodePrefixes.ts");
+const { hashLeaseOwnerId } = await import("../../src/lib/db/exclusiveConnectionLeases.ts");
+const { getProviderCredentials } = await import("../../src/sse/services/auth.ts");
+const { getModelInfo } = await import("../../src/sse/services/model.ts");
+const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts");
+const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
+
+const originalFetch = globalThis.fetch;
+
+const RETIRED_PROVIDER_VARIANTS = [
+ "felo-web",
+ "felo",
+ " FeLo-Web ",
+ "\tFELO\n",
+ "\u00a0FELO-WEB\uFEFF",
+ "\u2003felo\u2029",
+ "\u3000FELO-WEB\u3000",
+] as const;
+
+async function resetStorage() {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+ core.getDbInstance();
+ modelAliasResolver.invalidateAliasCache();
+}
+
+test.beforeEach(async () => {
+ globalThis.fetch = originalFetch;
+ await resetStorage();
+});
+
+test.afterEach(async () => {
+ globalThis.fetch = originalFetch;
+ await new Promise((resolve) => setImmediate(resolve));
+});
+
+test.after(() => {
+ globalThis.fetch = originalFetch;
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+function isRetiredError(error: unknown): boolean {
+ const typed = error as Error & { code?: string; status?: number };
+ assert.equal(typed.code, "PROVIDER_RETIRED");
+ assert.equal(typed.status, 410);
+ assert.match(typed.message, /retired/i);
+ return true;
+}
+
+test("retired Felo model prefixes cannot shadow custom compatible nodes", async () => {
+ const nodeIdsByPrefix = new Map();
+ for (const [index, providerId] of ["felo-web", "felo", "FeLo-WeB", "FELO"].entries()) {
+ const nodeId = `openai-compatible-chat-retired-felo-prefix-${index}`;
+ await providerNodesDb.createProviderNode({
+ id: nodeId,
+ type: "openai-compatible",
+ name: `Retired prefix ${providerId}`,
+ prefix: providerId,
+ apiType: "chat",
+ baseUrl: "https://retired.example.invalid/v1",
+ });
+ nodeIdsByPrefix.set(providerId, nodeId);
+
+ await assert.rejects(() => getModelInfo(`${providerId}/gpt-4o`), isRetiredError);
+ }
+
+ const prefixIndex = await providerPrefixIndex.getProviderPrefixIndex();
+ for (const [providerId, nodeId] of nodeIdsByPrefix) {
+ assert.equal(
+ prefixIndex.entries.get(providerId)?.status,
+ "reserved",
+ `${providerId} must remain reserved in pricing and override indexes`
+ );
+ assert.equal(prefixIndex.eligibleNodeIds.has(nodeId), false);
+ assert.equal(prefixIndex.prefixToNode.has(providerId), false);
+ }
+});
+
+test("stripModelPrefix cannot erase retired Felo identities before dispatch", async () => {
+ await settingsDb.updateSettings({ stripModelPrefix: true });
+ try {
+ for (const providerId of ["felo-web", "felo", "FeLo-WeB", "FELO"]) {
+ await assert.rejects(() => getModelInfo(`${providerId}/gpt-4o`), isRetiredError);
+ }
+ } finally {
+ await settingsDb.updateSettings({ stripModelPrefix: false });
+ }
+});
+
+test("direct chat resolution converts retired Felo failures into sanitized HTTP 410", async () => {
+ for (const providerId of ["felo-web", "felo"]) {
+ const result = await resolveModelOrError(
+ `${providerId}/gpt-4o`,
+ { model: `${providerId}/gpt-4o`, messages: [{ role: "user", content: "hello" }] },
+ "/v1/chat/completions"
+ );
+ assert.ok(result.error instanceof Response);
+ assert.equal(result.error.status, 410);
+ const body = (await result.error.json()) as {
+ error?: { code?: string; message?: string };
+ };
+ assert.equal(body.error?.code, "PROVIDER_RETIRED");
+ assert.equal(body.error?.message, "Provider is retired and unavailable.");
+ assert.equal(JSON.stringify(body).includes(providerId), false);
+ }
+});
+
+test("persisted aliases cannot rewrite retired Felo models before the route tombstone", async () => {
+ await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ name: "Retired Felo alias bypass control",
+ apiKey: "sk-retired-felo-alias-bypass",
+ isActive: true,
+ testStatus: "active",
+ });
+ await modelAliasesDb.setModelAlias("felo-web/gpt-4o", "openai/gpt-4o");
+ modelAliasResolver.invalidateAliasCache();
+
+ const fetchCalls: string[] = [];
+ globalThis.fetch = async (input: string | URL | Request) => {
+ fetchCalls.push(String(input));
+ return Response.json({
+ id: "chatcmpl-retired-felo-alias-bypass",
+ choices: [{ message: { role: "assistant", content: "alias bypassed retirement" } }],
+ });
+ };
+
+ const response = await chatRoute.POST(
+ new Request("http://localhost/v1/chat/completions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "felo-web/gpt-4o",
+ messages: [{ role: "user", content: "hello" }],
+ stream: false,
+ }),
+ })
+ );
+
+ assert.equal(response.status, 410);
+ assert.equal(fetchCalls.length, 0, "a retired alias must be rejected before upstream fetch");
+ const body = (await response.json()) as {
+ error?: { code?: string; message?: string };
+ };
+ assert.equal(body.error?.code, "PROVIDER_RETIRED");
+ assert.equal(body.error?.message, "Provider is retired and unavailable.");
+
+ // Bare model names are aliases/combos, not provider prefixes. An operator is
+ // still allowed to own an unrelated alias named "felo"; only the slashful
+ // retired provider identity must be rejected before alias resolution.
+ await modelAliasesDb.setModelAlias("felo", "openai/gpt-4o");
+ modelAliasResolver.invalidateAliasCache();
+ const bareAliasResponse = await chatRoute.POST(
+ new Request("http://localhost/v1/chat/completions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "felo",
+ messages: [{ role: "user", content: "hello" }],
+ stream: false,
+ }),
+ })
+ );
+ assert.equal(bareAliasResponse.status, 200);
+ assert.equal(fetchCalls.length, 1, "a bare alias named felo must remain routable");
+});
+
+test("priority combo skips retired Felo target and falls back to a healthy target", async () => {
+ await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ name: "Healthy Felo combo fallback",
+ apiKey: "sk-healthy-felo-combo-fallback",
+ isActive: true,
+ testStatus: "active",
+ });
+ await combosDb.createCombo({
+ name: "retired-felo-fallback",
+ strategy: "priority",
+ models: [
+ { provider: "felo-web", model: "gpt-4o" },
+ { provider: "openai", model: "gpt-4o" },
+ ],
+ });
+
+ const fetchCalls: string[] = [];
+ globalThis.fetch = async (input: string | URL | Request) => {
+ fetchCalls.push(String(input));
+ return Response.json({
+ id: "chatcmpl-retired-felo-fallback",
+ choices: [{ message: { role: "assistant", content: "healthy fallback" } }],
+ });
+ };
+
+ const response = await chatRoute.POST(
+ new Request("http://localhost/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-OmniRoute-No-Cache": "true",
+ },
+ body: JSON.stringify({
+ model: "retired-felo-fallback",
+ messages: [{ role: "user", content: "hello" }],
+ stream: false,
+ }),
+ })
+ );
+
+ assert.equal(response.status, 200);
+ assert.equal(fetchCalls.length, 1);
+ const body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
+ assert.equal(body.choices?.[0]?.message?.content, "healthy fallback");
+});
+
+test("retired Felo ids stay ineligible after imports, even if DB triggers are bypassed", async () => {
+ const db = core.getDbInstance();
+
+ const created = await providersDb.createProviderConnection({
+ provider: "felo-web",
+ authType: "apikey",
+ name: "Retired Felo create response",
+ apiKey: "retired-felo-create-key",
+ isActive: true,
+ });
+ assert.equal(created.isActive, false, "create must report the tombstoned persisted state");
+
+ const updated = await providersDb.updateProviderConnection(created.id, {
+ isActive: true,
+ testStatus: "active",
+ errorCode: null,
+ lastError: null,
+ lastErrorType: null,
+ lastErrorSource: null,
+ lastErrorAt: null,
+ });
+ assert.equal(updated?.isActive, false, "update must report the tombstoned persisted state");
+ assert.equal(updated?.errorCode, "PROVIDER_REMOVED");
+
+ const deduplicated = await providersDb.createProviderConnection({
+ provider: "felo-web",
+ authType: "apikey",
+ name: "Retired Felo create response",
+ apiKey: "retired-felo-create-key",
+ isActive: true,
+ testStatus: "active",
+ });
+ assert.equal(deduplicated.id, created.id, "the second create must use the dedup path");
+ assert.equal(
+ deduplicated.isActive,
+ false,
+ "a deduplicated create must report the tombstoned persisted state"
+ );
+ assert.equal(deduplicated.errorCode, "PROVIDER_REMOVED");
+
+ await assert.rejects(
+ providersDb.updateProviderConnection(created.id, {
+ provider: "openai",
+ isActive: true,
+ testStatus: "active",
+ errorCode: null,
+ lastError: null,
+ lastErrorType: null,
+ lastErrorSource: null,
+ lastErrorAt: null,
+ }),
+ /retired provider connection identity cannot be changed/i
+ );
+ const identityPreserved = await providersDb.getProviderConnectionById(created.id);
+ assert.equal(identityPreserved?.provider, "felo-web");
+ assert.equal(identityPreserved?.isActive, false);
+ assert.equal(identityPreserved?.errorCode, "PROVIDER_REMOVED");
+
+ for (const [index, providerId] of RETIRED_PROVIDER_VARIANTS.entries()) {
+ const connectionId = `trigger-normalized-${index}`;
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
+ "VALUES (?, ?, 'apikey', ?, 1, 'active', datetime('now'), datetime('now'))"
+ ).run(connectionId, providerId, `${providerId}-post-migration-import`);
+
+ const persistedState = db
+ .prepare(
+ "SELECT is_active, test_status, error_code, last_error_type, last_error_source " +
+ "FROM provider_connections WHERE id = ?"
+ )
+ .get(connectionId) as {
+ is_active: number;
+ test_status: string;
+ error_code: string;
+ last_error_type: string;
+ last_error_source: string;
+ };
+ assert.deepEqual(persistedState, {
+ is_active: 0,
+ test_status: "unavailable",
+ error_code: "PROVIDER_REMOVED",
+ last_error_type: "provider_removed",
+ last_error_source: "migration:retire-felo-web",
+ });
+
+ const credentials = await getProviderCredentials(
+ providerId,
+ null,
+ [connectionId],
+ "felo-chat",
+ { allowSuppressedConnections: true }
+ );
+ assert.equal(
+ credentials,
+ null,
+ `${providerId} must remain blocked after trigger normalization`
+ );
+ }
+
+ db.exec(`
+ DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_insert;
+ DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_update;
+ DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_insert;
+ DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_update;
+ DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_insert;
+ DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_update;
+ `);
+
+ for (const [index, providerId] of RETIRED_PROVIDER_VARIANTS.entries()) {
+ const connectionId = `truly-active-${index}`;
+ const leaseOwnerId = `vlo_${String.fromCharCode(65 + index).repeat(43)}`;
+ const apiKeyId = `retired-key-${index}`;
+ const generation = index + 1;
+ const now = new Date().toISOString();
+ const expiresAt = new Date(Date.now() + 60_000).toISOString();
+
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
+ "VALUES (?, ?, 'apikey', ?, 1, 'active', ?, ?)"
+ ).run(connectionId, providerId, `${providerId}-trigger-bypass`, now, now);
+ db.prepare(
+ "INSERT INTO exclusive_connection_leases " +
+ "(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
+ "acquired_at, renewed_at, expires_at) VALUES (?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?)"
+ ).run(
+ hashLeaseOwnerId(leaseOwnerId),
+ apiKeyId,
+ providerId,
+ connectionId,
+ generation,
+ now,
+ now,
+ expiresAt
+ );
+
+ const activeBeforeSelection = db
+ .prepare("SELECT is_active, test_status FROM provider_connections WHERE id = ?")
+ .get(connectionId) as { is_active: number; test_status: string };
+ assert.deepEqual(
+ activeBeforeSelection,
+ { is_active: 1, test_status: "active" },
+ "fixture must bypass the migration triggers so the auth tombstone is tested independently"
+ );
+
+ const credentials = await getProviderCredentials(
+ providerId,
+ null,
+ [connectionId],
+ "felo-chat",
+ {
+ allowSuppressedConnections: true,
+ lease: {
+ apiKeyId,
+ context: { leaseOwnerId, generation },
+ mode: "request",
+ },
+ }
+ );
+ assert.equal(credentials, null, `${providerId} must be blocked even with a truly active row`);
+
+ const lease = db
+ .prepare("SELECT state, end_reason FROM exclusive_connection_leases WHERE connection_id = ?")
+ .get(connectionId) as { state: string; end_reason: string | null };
+ assert.deepEqual(lease, {
+ state: "INVALIDATED",
+ end_reason: "CONNECTION_INELIGIBLE",
+ });
+ }
+});
diff --git a/tests/unit/free-provider-onboarding-selector.test.ts b/tests/unit/free-provider-onboarding-selector.test.ts
index 22bdcc72e3..4263f64495 100644
--- a/tests/unit/free-provider-onboarding-selector.test.ts
+++ b/tests/unit/free-provider-onboarding-selector.test.ts
@@ -16,7 +16,7 @@ test("free onboarding candidates come from the no-auth registry and exclude loca
);
assert.ok(ids.includes("opencode"));
assert.ok(ids.includes("duckduckgo-web"));
- assert.ok(ids.includes("felo-web"));
+ assert.ok(!ids.includes("felo-web"));
assert.ok(ids.includes("theoldllm"));
assert.ok(ids.includes("chipotle"));
assert.ok(ids.includes("theoldllm"));
diff --git a/tests/unit/messages-count-tokens-route.test.ts b/tests/unit/messages-count-tokens-route.test.ts
index f3a345a433..f056fa339a 100644
--- a/tests/unit/messages-count-tokens-route.test.ts
+++ b/tests/unit/messages-count-tokens-route.test.ts
@@ -18,6 +18,13 @@ type CountTokensResponse = {
model?: string;
};
+type ErrorResponse = {
+ error?: {
+ code?: string;
+ message?: string;
+ };
+};
+
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -108,6 +115,25 @@ test("messages/count_tokens falls back to estimate when model is missing", async
assert.equal(body.source, "local");
});
+test("messages/count_tokens rejects retired Felo models instead of estimating locally", async () => {
+ const response = await POST(
+ new Request("http://localhost/api/v1/messages/count_tokens", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "felo-web/gpt-4o",
+ messages: [{ role: "user", content: "Count these tokens" }],
+ }),
+ })
+ );
+
+ assert.equal(response.status, 410);
+ const body = (await response.json()) as ErrorResponse;
+ assert.equal(body.error?.code, "PROVIDER_RETIRED");
+ assert.equal(body.error?.message, "Provider is retired and unavailable.");
+ assert.equal(JSON.stringify(body).includes("felo-web"), false);
+});
+
test("count_tokens fallback uses exact tiktoken count with source=local", async () => {
const req = new Request("http://localhost/v1/messages/count_tokens", {
method: "POST",
diff --git a/tests/unit/migration-165-retire-felo-web.test.ts b/tests/unit/migration-165-retire-felo-web.test.ts
new file mode 100644
index 0000000000..94fe43f928
--- /dev/null
+++ b/tests/unit/migration-165-retire-felo-web.test.ts
@@ -0,0 +1,525 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-felo-retirement-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
+
+const RETIRED_PROVIDER_IDS = ["felo-web", "felo"] as const;
+const CONTROL_PROVIDER = "opencode";
+
+type ConnectionState = {
+ id: string;
+ is_active: number;
+ test_status: string;
+ error_code: string;
+ last_error: string;
+ last_error_type: string;
+ last_error_source: string;
+ last_error_at: string;
+ updated_at: string;
+};
+
+type LeaseState = {
+ id: number;
+ generation: number;
+ state: string;
+ ended_at: string | null;
+ end_reason: string | null;
+};
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+test("migration 165 retires every Felo id fail-closed and preserves audit history", async () => {
+ const db = core.getDbInstance();
+
+ const applied = db
+ .prepare("SELECT version FROM _omniroute_migrations WHERE version = 165")
+ .get() as { version: number } | undefined;
+ assert.ok(applied, "migration 165 must be recorded as applied");
+
+ // Recreate a pre-migration fixture even though a fresh test database already
+ // applied migration 165 during startup.
+ db.exec(`
+ DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_insert;
+ DROP TRIGGER IF EXISTS provider_connections_retire_felo_web_update;
+ DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_insert;
+ DROP TRIGGER IF EXISTS provider_connections_preserve_felo_web_identity_update;
+ DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_insert;
+ DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_felo_web_update;
+ `);
+
+ // The domain module reconciles API-key policy columns on a fresh database.
+ // Production upgrades already carry these columns from normal API-key use.
+ await apiKeysDb.getApiKeys();
+
+ for (const provider of [...RETIRED_PROVIDER_IDS, CONTROL_PROVIDER]) {
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, created_at, updated_at) " +
+ "VALUES (?, ?, 'apikey', ?, 1, datetime('now'), datetime('now'))"
+ ).run(`${provider}-connection`, provider, `${provider}-fixture`);
+ }
+
+ for (const provider of RETIRED_PROVIDER_IDS) {
+ db.prepare(
+ "UPDATE provider_connections SET test_status = 'active', last_error = 'legacy error', " +
+ "last_error_type = 'legacy', last_error_source = 'legacy:test', " +
+ "last_error_at = '2000-01-01T00:00:00.000Z', updated_at = '2000-01-01T00:00:00.000Z' " +
+ "WHERE provider = ?"
+ ).run(provider);
+ }
+
+ const normalizedProviderVariants = [
+ { id: "mixed-case-felo-web-connection", provider: " FeLo-Web " },
+ { id: "mixed-case-felo-alias-connection", provider: "\tFELO\n" },
+ { id: "nbsp-felo-web-connection", provider: "\u00a0FELO-WEB\uFEFF" },
+ { id: "em-space-felo-alias-connection", provider: "\u2003felo\u2029" },
+ { id: "ideographic-felo-web-connection", provider: "\u3000FELO-WEB\u3000" },
+ ] as const;
+ for (const { id, provider } of normalizedProviderVariants) {
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, last_error, " +
+ "last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
+ "VALUES (?, ?, 'apikey', ?, 1, 'active', 'legacy error', 'legacy', " +
+ "'legacy:test', '2000-01-01T00:00:00.000Z', datetime('now'), " +
+ "'2000-01-01T00:00:00.000Z')"
+ ).run(id, provider, `${id}-fixture`);
+ }
+
+ const retiredConnectionIds = RETIRED_PROVIDER_IDS.map((provider) => `${provider}-connection`);
+ db.prepare(
+ "INSERT INTO api_keys " +
+ "(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
+ "VALUES ('restricted-key', 'restricted-key', 'restricted-secret', " +
+ "'restricted-hash', 'restrict', ?, 1, datetime('now'))"
+ ).run(JSON.stringify(retiredConnectionIds));
+
+ const mixedConnectionIds = [...retiredConnectionIds, `${CONTROL_PROVIDER}-connection`];
+ const mixedAllowedConnectionsRaw =
+ ' [ "felo-web-connection" , "felo-connection" , "opencode-connection" ] ';
+ db.prepare(
+ "INSERT INTO api_keys " +
+ "(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
+ "VALUES ('mixed-key', 'mixed-key', 'mixed-secret', " +
+ "'mixed-hash', 'mixed', ?, 1, datetime('now'))"
+ ).run(mixedAllowedConnectionsRaw);
+
+ const leaseIds = new Map();
+ const staleLeaseEndedAt = "2000-01-01T00:00:00.000Z";
+ for (const provider of RETIRED_PROVIDER_IDS) {
+ const connectionId = `${provider}-connection`;
+ const leaseProvider = provider === "felo-web" ? "legacy-imported-provider" : provider;
+ const insertedLease = db
+ .prepare(
+ "INSERT INTO exclusive_connection_leases " +
+ "(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
+ "acquired_at, renewed_at, expires_at) VALUES (?, 'restricted-key', ?, ?, 7, " +
+ "'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
+ )
+ .run(provider.padEnd(64, "0"), leaseProvider, connectionId);
+ leaseIds.set(provider, Number(insertedLease.lastInsertRowid));
+ if (provider === "felo-web") {
+ db.prepare("UPDATE exclusive_connection_leases SET ended_at = ? WHERE id = ?").run(
+ staleLeaseEndedAt,
+ Number(insertedLease.lastInsertRowid)
+ );
+ }
+
+ db.prepare(
+ "INSERT INTO usage_history (provider, model, timestamp) " +
+ "VALUES (?, 'felo-chat', datetime('now'))"
+ ).run(provider);
+ db.prepare(
+ "INSERT INTO call_logs (id, timestamp, provider, model, status) " +
+ "VALUES (?, datetime('now'), ?, 'felo-chat', 200)"
+ ).run(`${provider}-call`, provider);
+ db.prepare(
+ "INSERT INTO quota_snapshots " +
+ "(provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) " +
+ "VALUES (?, ?, 'monthly', 50, 0, ?)"
+ ).run(provider, connectionId, new Date().toISOString());
+ }
+
+ const controlLeaseId = Number(
+ db
+ .prepare(
+ "INSERT INTO exclusive_connection_leases " +
+ "(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
+ "acquired_at, renewed_at, expires_at) VALUES (?, 'mixed-key', ?, ?, 11, " +
+ "'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
+ )
+ .run("opencode".padEnd(64, "0"), CONTROL_PROVIDER, `${CONTROL_PROVIDER}-connection`)
+ .lastInsertRowid
+ );
+
+ const readConnection = (provider: string) =>
+ db
+ .prepare(
+ "SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
+ "last_error_source, last_error_at, updated_at FROM provider_connections " +
+ "WHERE provider = ?"
+ )
+ .get(provider) as ConnectionState;
+ const readConnectionById = (id: string) =>
+ db
+ .prepare(
+ "SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
+ "last_error_source, last_error_at, updated_at FROM provider_connections " +
+ "WHERE id = ?"
+ )
+ .get(id) as ConnectionState;
+ const readLease = (id: number) =>
+ db
+ .prepare(
+ "SELECT id, generation, state, ended_at, end_reason FROM exclusive_connection_leases " +
+ "WHERE id = ?"
+ )
+ .get(id) as LeaseState;
+ const readTotalChanges = () =>
+ (db.prepare("SELECT total_changes() AS changes").get() as { changes: number }).changes;
+
+ const sql = fs.readFileSync(
+ path.join(process.cwd(), "src/lib/db/migrations/165_retire_felo_web.sql"),
+ "utf8"
+ );
+ db.exec(sql);
+ const firstConnections = new Map(
+ RETIRED_PROVIDER_IDS.map((provider) => [provider, readConnection(provider)])
+ );
+ const firstLeases = new Map(
+ RETIRED_PROVIDER_IDS.map((provider) => [provider, readLease(leaseIds.get(provider)!)])
+ );
+
+ const changesBeforeSecondExecution = readTotalChanges();
+ db.exec(sql);
+ assert.equal(
+ readTotalChanges() - changesBeforeSecondExecution,
+ 0,
+ "a second execution must not rewrite any retired connection or lease row"
+ );
+
+ for (const provider of RETIRED_PROVIDER_IDS) {
+ const connection = firstConnections.get(provider)!;
+ const lease = firstLeases.get(provider)!;
+
+ assert.deepEqual(readConnection(provider), connection, "timestamps must remain stable");
+ assert.deepEqual(
+ readLease(leaseIds.get(provider)!),
+ lease,
+ "the invalidated lease must remain stable"
+ );
+
+ assert.equal(connection.id, `${provider}-connection`);
+ assert.equal(connection.is_active, 0);
+ assert.equal(connection.test_status, "unavailable");
+ assert.equal(connection.error_code, "PROVIDER_REMOVED");
+ assert.equal(connection.last_error, "Provider integration retired from OmniRoute v3.8.50");
+ assert.equal(connection.last_error_type, "provider_removed");
+ assert.equal(connection.last_error_source, "migration:retire-felo-web");
+ assert.notEqual(connection.last_error_at, "2000-01-01T00:00:00.000Z");
+ assert.notEqual(connection.updated_at, "2000-01-01T00:00:00.000Z");
+
+ assert.equal(lease.id, leaseIds.get(provider));
+ assert.equal(lease.generation, 7);
+ assert.equal(lease.state, "INVALIDATED");
+ assert.ok(lease.ended_at);
+ if (provider === "felo-web") {
+ assert.notEqual(
+ lease.ended_at,
+ staleLeaseEndedAt,
+ "the retirement event must replace a stale restored end timestamp"
+ );
+ }
+ assert.equal(lease.end_reason, "CONNECTION_INELIGIBLE");
+
+ assert.ok(db.prepare("SELECT id FROM usage_history WHERE provider = ?").get(provider));
+ assert.ok(db.prepare("SELECT id FROM call_logs WHERE provider = ?").get(provider));
+ assert.ok(db.prepare("SELECT id FROM quota_snapshots WHERE provider = ?").get(provider));
+ }
+
+ for (const { id } of normalizedProviderVariants) {
+ const connection = db
+ .prepare(
+ "SELECT is_active, test_status, error_code, last_error_type, last_error_source " +
+ "FROM provider_connections WHERE id = ?"
+ )
+ .get(id) as {
+ is_active: number;
+ test_status: string;
+ error_code: string;
+ last_error_type: string;
+ last_error_source: string;
+ };
+ assert.deepEqual(connection, {
+ is_active: 0,
+ test_status: "unavailable",
+ error_code: "PROVIDER_REMOVED",
+ last_error_type: "provider_removed",
+ last_error_source: "migration:retire-felo-web",
+ });
+ }
+
+ const control = db
+ .prepare("SELECT is_active FROM provider_connections WHERE id = 'opencode-connection'")
+ .get() as { is_active: number };
+ assert.equal(control.is_active, 1, "the independent OpenCode provider must remain active");
+
+ assert.deepEqual(
+ readLease(controlLeaseId),
+ {
+ id: controlLeaseId,
+ generation: 11,
+ state: "ACTIVE",
+ ended_at: null,
+ end_reason: null,
+ },
+ "an unrelated active lease must not be invalidated"
+ );
+
+ const apiKey = db
+ .prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'restricted-key'")
+ .get() as { is_active: number; allowed_connections: string };
+ assert.equal(apiKey.is_active, 1);
+ assert.deepEqual(
+ JSON.parse(apiKey.allowed_connections),
+ retiredConnectionIds,
+ "an allowlist containing only Felo ids must remain non-empty and fail closed"
+ );
+
+ const mixedApiKey = db
+ .prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'mixed-key'")
+ .get() as { is_active: number; allowed_connections: string };
+ assert.equal(mixedApiKey.is_active, 1);
+ assert.equal(
+ mixedApiKey.allowed_connections,
+ mixedAllowedConnectionsRaw,
+ "the migration must preserve a mixed allowlist byte-for-byte"
+ );
+ assert.deepEqual(
+ JSON.parse(mixedApiKey.allowed_connections),
+ mixedConnectionIds,
+ "a mixed allowlist must preserve both retired ids and its unrelated connection"
+ );
+
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
+ "VALUES ('post-migration-felo', 'felo-web', 'apikey', 'post migration import', " +
+ "1, 'active', datetime('now'), datetime('now'))"
+ ).run();
+ const postMigrationConnection = db
+ .prepare(
+ "SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
+ "last_error_source, last_error_at, updated_at FROM provider_connections " +
+ "WHERE id = 'post-migration-felo'"
+ )
+ .get() as ConnectionState;
+ assert.equal(postMigrationConnection.is_active, 0);
+ assert.equal(postMigrationConnection.test_status, "unavailable");
+ assert.equal(postMigrationConnection.error_code, "PROVIDER_REMOVED");
+ assert.equal(postMigrationConnection.last_error_type, "provider_removed");
+ assert.equal(postMigrationConnection.last_error_source, "migration:retire-felo-web");
+
+ db.prepare(
+ "INSERT OR REPLACE INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
+ "VALUES ('post-migration-replace-felo', '\fFELO\r', 'apikey', 'replace import', " +
+ "1, 'active', datetime('now'), datetime('now'))"
+ ).run();
+ const postMigrationReplace = readConnectionById("post-migration-replace-felo");
+ assert.equal(postMigrationReplace.is_active, 0);
+ assert.equal(postMigrationReplace.test_status, "unavailable");
+ assert.equal(postMigrationReplace.error_code, "PROVIDER_REMOVED");
+ assert.equal(postMigrationReplace.last_error_source, "migration:retire-felo-web");
+
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
+ "VALUES ('post-migration-felo-alias', ' FELO ', 'apikey', 'post migration alias', " +
+ "1, 'active', datetime('now'), datetime('now'))"
+ ).run();
+ const postMigrationAlias = db
+ .prepare(
+ "SELECT is_active, test_status, error_code, last_error_source " +
+ "FROM provider_connections WHERE id = 'post-migration-felo-alias'"
+ )
+ .get() as {
+ is_active: number;
+ test_status: string;
+ error_code: string;
+ last_error_source: string;
+ };
+ assert.deepEqual(postMigrationAlias, {
+ is_active: 0,
+ test_status: "unavailable",
+ error_code: "PROVIDER_REMOVED",
+ last_error_source: "migration:retire-felo-web",
+ });
+
+ const insertActiveLease = (owner: string, provider: string, connectionId: string) =>
+ Number(
+ db
+ .prepare(
+ "INSERT INTO exclusive_connection_leases " +
+ "(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
+ "acquired_at, renewed_at, expires_at) VALUES (?, ?, ?, ?, 1, 'ACTIVE', " +
+ "datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
+ )
+ .run(owner.padEnd(64, "0"), `${owner}-key`, provider, connectionId).lastInsertRowid
+ );
+
+ const alreadyTombstonedInsertLeaseId = insertActiveLease(
+ "already-tombstoned-felo-insert",
+ "legacy-imported-provider",
+ "already-tombstoned-felo-insert-connection"
+ );
+ assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "ACTIVE");
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, error_code, last_error, " +
+ "last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
+ "VALUES ('already-tombstoned-felo-insert-connection', '\u00a0felo-web\uFEFF', " +
+ "'apikey', 'already tombstoned restore', 0, 'unavailable', 'PROVIDER_REMOVED', " +
+ "'Provider integration retired from OmniRoute v3.8.50', 'provider_removed', " +
+ "'migration:retire-felo-web', '2001-01-01T00:00:00.000Z', datetime('now'), datetime('now'))"
+ ).run();
+ assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "INVALIDATED");
+
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, created_at, updated_at) " +
+ "VALUES ('already-tombstoned-felo-update-connection', 'legacy-provider', 'apikey', " +
+ "'update to retired', 1, datetime('now'), datetime('now'))"
+ ).run();
+ const alreadyTombstonedUpdateLeaseId = insertActiveLease(
+ "already-tombstoned-felo-update",
+ "legacy-imported-provider",
+ "already-tombstoned-felo-update-connection"
+ );
+ assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "ACTIVE");
+ db.prepare(
+ "UPDATE provider_connections SET provider = '\u2003FELO\u2029', is_active = 0, " +
+ "test_status = 'unavailable', error_code = 'PROVIDER_REMOVED', " +
+ "last_error = 'Provider integration retired from OmniRoute v3.8.50', " +
+ "last_error_type = 'provider_removed', last_error_source = 'migration:retire-felo-web', " +
+ "last_error_at = '2001-01-01T00:00:00.000Z' " +
+ "WHERE id = 'already-tombstoned-felo-update-connection'"
+ ).run();
+ assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "INVALIDATED");
+
+ const directRetiredLeaseId = insertActiveLease(
+ "post-direct-felo",
+ " FeLo-Web ",
+ "direct-retired-felo-provider-connection"
+ );
+ assert.equal(readLease(directRetiredLeaseId).state, "INVALIDATED");
+
+ const retiredConnectionLeaseId = insertActiveLease(
+ "post-retired-felo-connection",
+ "legacy-imported-provider",
+ "post-migration-felo"
+ );
+ assert.equal(readLease(retiredConnectionLeaseId).state, "INVALIDATED");
+
+ const restoredBeforeConnectionLeaseId = insertActiveLease(
+ "restored-before-felo-connection",
+ "legacy-imported-provider",
+ "restored-felo-web-connection"
+ );
+ assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "ACTIVE");
+ db.prepare(
+ "INSERT INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, created_at, updated_at) " +
+ "VALUES ('restored-felo-web-connection', 'felo-web', 'apikey', " +
+ "'restored after lease', 1, datetime('now'), datetime('now'))"
+ ).run();
+ assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "INVALIDATED");
+
+ const openCodeLeaseId = insertActiveLease(
+ "post-opencode-control",
+ "opencode",
+ "post-opencode-control-connection"
+ );
+ assert.deepEqual(readLease(openCodeLeaseId), {
+ id: openCodeLeaseId,
+ generation: 1,
+ state: "ACTIVE",
+ ended_at: null,
+ end_reason: null,
+ });
+
+ db.prepare(
+ "UPDATE provider_connections SET provider = ' FeLo ', is_active = 1, test_status = 'active', " +
+ "error_code = NULL, last_error = NULL, last_error_type = NULL, " +
+ "last_error_source = NULL, last_error_at = NULL WHERE provider = 'felo'"
+ ).run();
+ const updateProtectedConnection = readConnectionById("felo-connection");
+ assert.equal(updateProtectedConnection.is_active, 0);
+ assert.equal(updateProtectedConnection.test_status, "unavailable");
+ assert.equal(updateProtectedConnection.error_code, "PROVIDER_REMOVED");
+ assert.equal(updateProtectedConnection.last_error_type, "provider_removed");
+ assert.equal(updateProtectedConnection.last_error_source, "migration:retire-felo-web");
+
+ assert.throws(
+ () =>
+ db
+ .prepare(
+ "UPDATE provider_connections SET provider = 'openai', is_active = 1, " +
+ "test_status = 'active', error_code = NULL WHERE id = 'felo-web-connection'"
+ )
+ .run(),
+ /retired provider connection identity cannot be changed/i
+ );
+ const updateIdentityControl = db
+ .prepare("SELECT provider, is_active, error_code FROM provider_connections WHERE id = ?")
+ .get("felo-web-connection") as {
+ provider: string;
+ is_active: number;
+ error_code: string;
+ };
+ assert.deepEqual(updateIdentityControl, {
+ provider: "felo-web",
+ is_active: 0,
+ error_code: "PROVIDER_REMOVED",
+ });
+
+ assert.throws(
+ () =>
+ db
+ .prepare(
+ "INSERT OR REPLACE INTO provider_connections " +
+ "(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
+ "VALUES ('felo-connection', 'openai', 'apikey', 'identity replacement', 1, " +
+ "'active', datetime('now'), datetime('now'))"
+ )
+ .run(),
+ /retired provider connection identity cannot be changed/i
+ );
+ const replaceIdentityControl = db
+ .prepare("SELECT provider, is_active, error_code FROM provider_connections WHERE id = ?")
+ .get("felo-connection") as {
+ provider: string;
+ is_active: number;
+ error_code: string;
+ };
+ assert.deepEqual(replaceIdentityControl, {
+ provider: " FeLo ",
+ is_active: 0,
+ error_code: "PROVIDER_REMOVED",
+ });
+
+ db.prepare("UPDATE provider_connections SET name = 'renamed' WHERE id = 'felo-connection'").run();
+ const unrelatedUpdate = readConnectionById("felo-connection");
+ assert.equal(unrelatedUpdate.last_error_at, updateProtectedConnection.last_error_at);
+ assert.equal(unrelatedUpdate.updated_at, updateProtectedConnection.updated_at);
+});
diff --git a/tests/unit/noauth-autocombo-allowlist.test.ts b/tests/unit/noauth-autocombo-allowlist.test.ts
index 21b3ad98af..228193c8af 100644
--- a/tests/unit/noauth-autocombo-allowlist.test.ts
+++ b/tests/unit/noauth-autocombo-allowlist.test.ts
@@ -2,8 +2,8 @@
* Auto-combo no-auth allowlist — the `auto`/`auto-*` candidate pool must only
* pull in no-auth (keyless) providers verified to work without any credential on
* our reference egress. As of this change that allowlist is narrowed to
- * `opencode` and `felo-web`: on the reference VPS (.15) they are the only no-auth
- * backends that answer 200 with zero configuration. The other no-auth providers
+ * `opencode`: on the reference VPS (.15) it answers 200 with zero configuration.
+ * The other no-auth providers
* (duckduckgo-web, theoldllm, chipotle, aihorde) stay OUT of every auto/* pool
* until re-verified — they remain usable via direct `/` calls, they
* are just not auto-routed to.
@@ -46,7 +46,7 @@ test.after(async () => {
}
});
-const ALLOWED_NOAUTH_PROVIDERS = ["opencode", "felo-web"];
+const ALLOWED_NOAUTH_PROVIDERS = ["opencode"];
const EXCLUDED_NOAUTH_PROVIDERS = ["duckduckgo-web", "theoldllm", "chipotle", "aihorde"];
test("fresh install: the allowlisted no-auth providers are present in the auto-combo pool", async () => {
diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts
index b93af3d7b1..b1429f100f 100644
--- a/tests/unit/provider-node-reserved-prefix.test.ts
+++ b/tests/unit/provider-node-reserved-prefix.test.ts
@@ -91,6 +91,13 @@ test("shared set contains REGISTRY ids and aliases (tokenrouter + trk)", () => {
assert.equal(RESERVED_PROVIDER_PREFIXES.has("trk"), true);
});
+test("shared guard keeps retired Felo ids reserved after registry removal", () => {
+ assert.equal(RESERVED_PROVIDER_PREFIXES.has("felo-web"), true);
+ assert.equal(RESERVED_PROVIDER_PREFIXES.has("felo"), true);
+ assert.equal(isReservedProviderPrefix(" FeLo-Web "), true);
+ assert.equal(isReservedProviderPrefix("\u00a0FELO\uFEFF"), true);
+});
+
test("shared set is case-sensitive like the runtime guard", () => {
assert.equal(isReservedProviderPrefix("TokenRouter"), false);
assert.equal(isReservedProviderPrefix("TOKENROUTER"), false);
@@ -107,14 +114,10 @@ test("shared set excludes manual aliases that never intercept nodes at runtime",
assert.equal(RESERVED_PROVIDER_PREFIXES.has("aq"), false);
});
-test("shared set size includes live REGISTRY and 2 retired Designer prefixes", () => {
- // Count measured against release/v3.8.51 tip (398 pinned post-#11333,
- // #11629, #11631 — a deduplicated Set, not a raw id+alias sum) after the
- // Microsoft Designer Web retirement drops its 2 ids/aliases from the live
- // REGISTRY walk and re-adds them explicitly via
- // RETIRED_MICROSOFT_DESIGNER_WEB_PROVIDER_IDS so they remain unavailable
- // for compatible-node shadowing. The assertion pins the actual computed
- // set size, not a hand-derived sum.
+test("shared set size includes live REGISTRY and retired Designer + Felo prefixes", () => {
+ // Computed (not hand-derived) after combining Designer's 2 retired
+ // ids/aliases with Felo's 2 retired ids/aliases on top of the live
+ // REGISTRY walk.
assert.equal(RESERVED_PREFIX_COUNT, 400);
});
@@ -151,6 +154,31 @@ test("createProviderNodeSchema rejects reserved alias 'trk'", () => {
assert.equal(result.success, false);
});
+test("provider node schemas reject retired Felo prefixes and normalized variants", () => {
+ for (const prefix of ["felo-web", "felo", " FeLo-Web ", "\u00a0FELO\uFEFF"]) {
+ const created = createProviderNodeSchema.safeParse({
+ name: "Retired prefix",
+ prefix,
+ apiType: "chat",
+ });
+ assert.equal(created.success, false, `create must reject ${JSON.stringify(prefix)}`);
+
+ const updated = updateProviderNodeSchema.safeParse({
+ name: "Retired prefix",
+ prefix,
+ baseUrl: "https://retired.example.invalid/v1",
+ });
+ assert.equal(updated.success, false, `update must reject ${JSON.stringify(prefix)}`);
+
+ const preset = createProviderNodeSchema.safeParse({
+ preset: "vibeproxy-openai",
+ prefix,
+ baseUrl: "http://localhost:8317",
+ });
+ assert.equal(preset.success, false, `preset create must reject ${JSON.stringify(prefix)}`);
+ }
+});
+
test("createProviderNodeSchema accepts mixed-case 'TokenRouter' (no runtime collision)", () => {
const result = createProviderNodeSchema.safeParse({
name: "Case Test",
diff --git a/tests/unit/session-leases-route.test.ts b/tests/unit/session-leases-route.test.ts
index f2c7492f3b..b2f4f23c94 100644
--- a/tests/unit/session-leases-route.test.ts
+++ b/tests/unit/session-leases-route.test.ts
@@ -138,6 +138,33 @@ test("requires JSON mutation input after authenticating and exposes generic CORS
assert.equal(attemptedExternalCalls, 0);
});
+test("acquire rejects retired Felo models with the sanitized retirement response", async () => {
+ const retiredConnection = (await providersDb.createProviderConnection({
+ provider: "felo-web",
+ authType: "apikey",
+ name: "retired-felo-lease-route",
+ apiKey: "sk-retired-felo-lease-route",
+ isActive: true,
+ testStatus: "active",
+ providerSpecificData: {},
+ })) as { id: string };
+ const managed = await seedKey([retiredConnection.id]);
+
+ const response = await route.POST(
+ request(managed.key, { action: "acquire", model: "felo-web/gpt-4o" }, OWNER_A)
+ );
+
+ assert.equal(response.status, 410);
+ const body = await json(response);
+ assert.equal((body.error as { code?: string }).code, "PROVIDER_RETIRED");
+ assert.equal(
+ (body.error as { message?: string }).message,
+ "Provider is retired and unavailable."
+ );
+ assert.equal(JSON.stringify(body).includes("felo-web"), false);
+ assert.equal(attemptedExternalCalls, 0);
+});
+
test("acquires, reuses, renews, releases, and fences a stale lifecycle", async () => {
const connection = await seedConnection(1);
const managed = await seedKey([connection.id]);
diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts
index f6fd111030..43398d3915 100644
--- a/tests/unit/virtual-auto-combo.test.ts
+++ b/tests/unit/virtual-auto-combo.test.ts
@@ -205,14 +205,14 @@ test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_con
test("createVirtualAutoCombo restricts the no-auth pool to the allowlist", async () => {
// Policy: the no-auth (keyless) auto-combo allowlist is narrowed to `opencode`
- // and `felo-web` (open-sse/services/autoCombo/virtualFactory.ts::AUTO_COMBO_NOAUTH_ALLOWLIST) —
- // the keyless backends verified to work without configuration on our reference
+ // (open-sse/services/autoCombo/virtualFactory.ts::AUTO_COMBO_NOAUTH_ALLOWLIST) —
+ // the keyless backend verified to work without configuration on our reference
// egress. The others stay usable via direct `/` calls but must
// NOT be auto-routed to. Dedicated guard:
// tests/unit/noauth-autocombo-allowlist.test.ts.
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast");
- for (const allowed of ["opencode", "felo-web"]) {
+ for (const allowed of ["opencode"]) {
const models = combo.models.filter((m) => m.providerId === allowed);
assert.ok(models.length >= 1, `${allowed} should have at least one model`);
assert.ok(