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`). -OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 40 documented recurring pool keys covering 455 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 15 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. +OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 39 documented recurring pool keys covering 451 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 14 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /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 @@ -Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless free providers OpenCode Free and Felo are pre-wired into the auto combo, so a fresh install responds out of the box. +Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless provider OpenCode Free is pre-wired into the auto combo, so a fresh install responds out of the box. ```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 GuideCircuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing Auto-Combo Engine15-factor scoring, mode packs, self-healing Proxy Guide3-level proxy system, 1proxy marketplace, registry CRUD - Free TiersConsolidated directory: 40 documented recurring pools / 455 cataloged free-tier entries + Free TiersConsolidated directory: 39 documented recurring pools / 451 cataloged free-tier entries Features GalleryVisual dashboard tour with screenshots Codebase DocumentationBeginner-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 @@ -Funziona subito dopo l'installazione — configurazione zero. Tre passaggi: 1. Installa — npm i -g omniroute, il server parte su localhost:20128. 2. Punta il tuo strumento a http://localhost:20128/v1 — qualunque strumento compatibile con OpenAI (Claude Code, Cursor, Cline). 3. Risponde — usa il modello auto per una risposta immediata, senza API key, registrazione o configurazione. I provider gratuiti senza chiave OpenCode Free e Felo sono già collegati alla combo auto, quindi una nuova installazione risponde immediatamente. +Funziona subito dopo l'installazione — configurazione zero. Tre passaggi: 1. Installa — npm i -g omniroute, il server parte su localhost:20128. 2. Punta il tuo strumento a http://localhost:20128/v1 — qualunque strumento compatibile con OpenAI (Claude Code, Cursor, Cline). 3. Risponde — usa il modello auto per una risposta immediata, senza API key, registrazione o configurazione. OpenCode Free è già collegato alla combo auto, quindi una nuova installazione risponde immediatamente. ```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 @@ -Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless free providers OpenCode Free and Felo are pre-wired into the auto combo, so a fresh install responds out of the box. +Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. OpenCode Free is pre-wired into the auto combo, so a fresh install responds out of the box. ```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(