merge #11698
10
README.md
@@ -17,9 +17,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
> 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`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="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."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="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 @@
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="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."/>
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="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!"}]}'
|
||||
```
|
||||
|
||||
<sub>Prefer a specific free backend? Call it directly, e.g. `oc/…` (OpenCode Free) or `felo/…` (Felo). Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
<sub>Prefer a specific free backend? Call `oc/…` (OpenCode Free) directly. Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
|
||||
<sub>📦 Copy-paste quickstart scripts for **Python, Node.js, PHP, and cURL** → [`examples/quickstart/`](examples/quickstart/)</sub>
|
||||
|
||||
@@ -1265,7 +1265,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>15-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 40 documented recurring pools / 455 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 39 documented recurring pools / 451 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
|
||||
1
changelog.d/maintenance/11698-retire-felo-web.md
Normal file
@@ -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.
|
||||
@@ -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 <tool> 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)').",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in the first month with signup credits. The catalog contains 455 rows, 448 active and 7 discontinued, grouped into 40 recurring pool keys; 20 pools have a published positive monthly token budget and 20 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 15 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, Gemini 60 million, Cerebras 30 million, Cloudflare AI 30 million, API Airforce 24 million, Ollama Cloud 20 million, Groq 15 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. Live usage remains available at /dashboard/free-tiers.">
|
||||
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in the first month with signup credits. The catalog contains 451 rows, 444 active and 7 discontinued, grouped into 39 recurring pool keys; 20 pools have a published positive monthly token budget and 19 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 14 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, Gemini 60 million, Cerebras 30 million, Cloudflare AI 30 million, API Airforce 24 million, Ollama Cloud 20 million, Groq 15 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. Live usage remains available at /dashboard/free-tiers.">
|
||||
<desc>Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately.</desc>
|
||||
<defs>
|
||||
<pattern id="gridPaperF" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
@@ -64,7 +64,7 @@
|
||||
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.51B</text>
|
||||
<text x="62" y="266" font-family="Consolas, 'Courier New', monospace" font-size="15" letter-spacing="3" font-weight="700" fill="#a1a1aa">FREE TOKENS / MONTH · <tspan fill="#22c55e">STEADY</tspan></text>
|
||||
<text x="62" y="298" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16" fill="#F7F6FC">up to <tspan font-weight="800" fill="#22c55e">~2.13B</tspan> in your first month — signup credits</text>
|
||||
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers · <tspan fill="#8b5cf6">40 recurring pools</tspan> · <tspan fill="#8b5cf6">455 catalog entries</tspan> · one endpoint</text>
|
||||
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers · <tspan fill="#8b5cf6">39 recurring pools</tspan> · <tspan fill="#8b5cf6">451 catalog entries</tspan> · one endpoint</text>
|
||||
|
||||
<!-- ═══ Panel · The honest math ═══ -->
|
||||
<rect x="680" y="84" width="460" height="216" rx="14" fill="#161b22" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
|
||||
@@ -78,7 +78,7 @@
|
||||
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.51B</text>
|
||||
<text x="836" y="224" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">each shared free pool</text>
|
||||
<text x="836" y="244" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#22c55e">counted once ✓</text>
|
||||
<text x="704" y="280" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#f59e0b"><tspan font-weight="800">15 providers</tspan> ToS-flagged <tspan fill="#71717a">— we flag it · you decide</tspan></text>
|
||||
<text x="704" y="280" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#f59e0b"><tspan font-weight="800">14 providers</tspan> ToS-flagged <tspan fill="#71717a">— we flag it · you decide</tspan></text>
|
||||
|
||||
<!-- ═══ Budget bar · 20 quantified recurring pools ═══ -->
|
||||
<text x="60" y="356" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">WHERE IT COMES FROM · <tspan fill="#F7F6FC">20 QUANTIFIED RECURRING POOLS</tspan></text>
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -73,7 +73,7 @@
|
||||
<circle cx="6.6" cy="6.6" r="1.4" fill="#fdcb6e" stroke="none"/>
|
||||
</g>
|
||||
<text x="862" y="170" font-size="18" font-weight="800" fill="#fdcb6e">$0 to start</text>
|
||||
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">90+ providers with a free tier, 56 free</text>
|
||||
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">90+ providers with a free tier, 55 free</text>
|
||||
<text x="826" y="226" font-size="13.5" fill="#a1a1aa">forever — Qoder, Pollinations, Cloudflare,</text>
|
||||
<text x="826" y="248" font-size="13.5" fill="#a1a1aa">SiliconFlow… No card needed.</text>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 430" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Works the second you install it — zero config. Three steps: 1. Install — npm install -g omniroute, one command, server on localhost port 20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool. 3. It answers — call model auto and get 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.">
|
||||
<svg viewBox="0 0 1200 430" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Works the second you install it — zero config. Three steps: 1. Install — npm install -g omniroute, one command, server on localhost port 20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool. 3. It answers — call model auto and get 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.">
|
||||
<desc>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.</desc>
|
||||
<defs>
|
||||
<pattern id="gridW" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
@@ -112,6 +112,6 @@
|
||||
</g>
|
||||
|
||||
<!-- footer -->
|
||||
<text x="40" y="406" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">OpenCode Free & Felo are pre-wired into <tspan fill="#7ee787">auto</tspan> — a fresh install responds out of the box.</text>
|
||||
<text x="40" y="406" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">OpenCode Free is pre-wired into <tspan fill="#7ee787">auto</tspan> — a fresh install responds out of the box.</text>
|
||||
<text x="1160" y="406" text-anchor="end" font-family="Consolas, 'Courier New', monospace" font-size="12" letter-spacing="1.5" fill="#71717a">$0 · MIT</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<img src="../../diagrams/works-zero-config.svg" width="100%" alt="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."/>
|
||||
<img src="../../diagrams/works-zero-config.svg" width="100%" alt="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!"}]}'
|
||||
```
|
||||
|
||||
<sub>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.</sub>
|
||||
<sub>Preferisci uno specifico backend gratuito? Chiama direttamente `oc/…` (OpenCode Free). Poi passa a `auto` e lascia che sia OmniRoute a scegliere.</sub>
|
||||
|
||||
<sub>📦 Script di avvio rapido pronti da copiare per **Python, Node.js, PHP e cURL** → [`examples/quickstart/`](../../../examples/quickstart/)</sub>
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="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."/>
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="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!"}]}'
|
||||
```
|
||||
|
||||
<sub>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.</sub>
|
||||
<sub>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.</sub>
|
||||
|
||||
<sub>📦 **Python, Node.js, PHP ve cURL** için kopyala-yapıştır hızlı başlangıç betikleri → [`examples/quickstart/`](examples/quickstart/)</sub>
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
|
||||
@@ -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. | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 566" role="img" aria-label="OmniRoute free-tier dashboard preview: about 1.51 billion documented recurring tokens per month and about 2.13 billion in the first month. The audited catalog has 40 recurring pool keys and 455 entries, 448 active and 7 discontinued; the chart represents the 20 pools with a published positive monthly token budget. One-time signup credits total about 626 million and include a 10 million LongCat grant that requires KYC. Uncapped providers remain subject to rate, concurrency, account, regional, and policy limits." font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 566" role="img" aria-label="OmniRoute free-tier dashboard preview: about 1.51 billion documented recurring tokens per month and about 2.13 billion in the first month. The audited catalog has 39 recurring pool keys and 451 entries, 444 active and 7 discontinued; the chart represents the 20 pools with a published positive monthly token budget. One-time signup credits total about 626 million and include a 10 million LongCat grant that requires KYC. Uncapped providers remain subject to rate, concurrency, account, regional, and policy limits." font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<desc>Static dashboard preview of recurring token pools, first-month signup grants, and uncapped but rate-limited free-access providers.</desc>
|
||||
<rect width="900" height="566" rx="16" fill="#0d1117"/>
|
||||
<rect x="16" y="16" width="868" height="550" rx="13" fill="#161b22" stroke="#30363d"/>
|
||||
<text x="868" y="558" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>
|
||||
<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>
|
||||
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">40 recurring pools · 455 catalog entries · one endpoint</text>
|
||||
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">39 recurring pools · 451 catalog entries · one endpoint</text>
|
||||
<text x="32" y="84" fill="#7d8590" font-size="11.5">Steady / month</text>
|
||||
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.51B</text>
|
||||
<text x="330" y="84" fill="#7d8590" font-size="11.5">First month (+ signup credits)</text>
|
||||
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.13B</text>
|
||||
<text x="700" y="84" fill="#7d8590" font-size="11.5">ToS-flagged (you decide)</text>
|
||||
<text x="700" y="114" fill="#d29922" font-size="27" font-weight="800">15 providers</text>
|
||||
<text x="700" y="114" fill="#d29922" font-size="27" font-weight="800">14 providers</text>
|
||||
<clipPath id="bar"><rect x="32" y="132" width="836" height="16" rx="8"/></clipPath>
|
||||
<g clip-path="url(#bar)"><rect x="32" y="132" width="836" height="16" fill="#21262d"/>
|
||||
<rect x="32.0" y="132" width="510.4" height="16" fill="#6c5ce7"/>
|
||||
@@ -34,7 +34,7 @@
|
||||
<rect x="855.7" y="132" width="5.7" height="16" fill="#fdcb6e"/>
|
||||
<rect x="862.3" y="132" width="5.7" height="16" fill="#e84393"/>
|
||||
</g>
|
||||
<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one of 20 quantified recurring pools · 40 pools / 455 entries in the audited catalog.</text>
|
||||
<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one of 20 quantified recurring pools · 39 pools / 451 entries in the audited catalog.</text>
|
||||
<circle cx="37" cy="196" r="5" fill="#6c5ce7"/>
|
||||
<text x="48" y="200" fill="#c9d1d9" font-size="12.5">Mistral <tspan fill="#7d8590">1.00B</tspan></text>
|
||||
<circle cx="250" cy="196" r="5" fill="#00b894"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -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
|
||||
|
||||
@@ -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?" }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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?"],
|
||||
|
||||
@@ -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?"}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -40,7 +40,6 @@ export const FREE_TIER_BUDGETS: Record<string, number> = {
|
||||
export const FREE_TIER_TOS: Record<string, TosVerdict> = {
|
||||
opencode: "avoid",
|
||||
"duckduckgo-web": "avoid",
|
||||
"felo-web": "avoid",
|
||||
agy: "avoid",
|
||||
kiro: "avoid",
|
||||
"amazon-q": "avoid",
|
||||
|
||||
@@ -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<string, RegistryEntry> = {
|
||||
"vertex-partner": vertex_partnerProvider,
|
||||
vertex: vertexProvider,
|
||||
"duckduckgo-web": duckduckgo_webProvider,
|
||||
"felo-web": felo_webProvider,
|
||||
xai: xaiProvider,
|
||||
"xai-oauth": xai_oauthProvider,
|
||||
morph: morphProvider,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
"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<Record<string, unknown>>): 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<string, unknown>).text === "string") {
|
||||
return (part as Record<string, unknown>).text as string;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildFeloThreadPayload(
|
||||
model: string | undefined | null,
|
||||
prompt: string
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const dataRecord = data as Record<string, unknown>;
|
||||
if (dataRecord.type !== "answer") return null;
|
||||
const inner = dataRecord.data;
|
||||
if (!inner || typeof inner !== "object") return null;
|
||||
const text = (inner as Record<string, unknown>).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<string, unknown> | 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<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
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<string, unknown> | null)?.stream_key === "string";
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<Response> {
|
||||
const { model, body, stream, signal } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const messages = Array.isArray(bodyObj.messages)
|
||||
? (bodyObj.messages as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
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<string | Response> {
|
||||
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<string, unknown> | 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<Uint8Array, Uint8Array> {
|
||||
let previousText = "";
|
||||
let buffer = "";
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new TransformStream<Uint8Array, Uint8Array>({
|
||||
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<Response> {
|
||||
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();
|
||||
@@ -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<string, () => Promise<BaseExecutor>> = {
|
||||
"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<BaseExecutor> {
|
||||
assertMicrosoftDesignerWebProviderAvailable(provider);
|
||||
assertRuntimeProviderAvailable(provider);
|
||||
const loaded = await loadRegisteredExecutor(provider);
|
||||
if (loaded) return loaded;
|
||||
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
|
||||
|
||||
@@ -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<string, unknown> | null
|
||||
) {
|
||||
assertMicrosoftDesignerWebProviderAvailable(prov);
|
||||
assertRuntimeProviderAvailable(prov);
|
||||
|
||||
// Per-connection routing override (#6339): the resolved connection can opt itself
|
||||
// into the CLIProxyAPI passthrough executor via providerSpecificData.cliproxyapiMode
|
||||
|
||||
@@ -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 `<alias>/<model>` 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<string>(["opencode", "felo-web"]);
|
||||
const AUTO_COMBO_NOAUTH_ALLOWLIST = new Set<string>(["opencode"]);
|
||||
|
||||
function isChatAutoComboNoAuthProvider(
|
||||
providerDef: NoAuthProviderDefinition,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Response> => handleCorsOptions();
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
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");
|
||||
}
|
||||
|
||||
195
src/lib/db/migrations/165_retire_felo_web.sql
Normal file
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string> {
|
||||
const reserved = new Set<string>();
|
||||
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<ProviderPrefixIndex> {
|
||||
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<ProviderPrefixIndex> {
|
||||
const eligibleNodeIds = new Set<string>();
|
||||
|
||||
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;
|
||||
|
||||
44
src/shared/constants/providerRetirement.ts
Normal file
@@ -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<string> = 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string> | null = null;
|
||||
|
||||
@@ -38,13 +39,15 @@ function buildReservedProviderPrefixes(): Set<string> {
|
||||
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<string> {
|
||||
return buildReservedProviderPrefixes();
|
||||
@@ -69,7 +72,8 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet<string> = 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 <prefix>/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 <prefix>/model route to a built-in provider or fail closed when retired)`;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,6 @@ function body(model: string) {
|
||||
const NO_AUTH_PROVIDER_IDS = [
|
||||
"opencode",
|
||||
"duckduckgo-web",
|
||||
"felo-web",
|
||||
"theoldllm",
|
||||
"chipotle",
|
||||
"veoaifree-web",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -285,16 +285,6 @@
|
||||
"configSource": "freebuff",
|
||||
"provider": "freebuff"
|
||||
},
|
||||
"felo": {
|
||||
"className": "FeloWebExecutor",
|
||||
"configSource": "<custom-config>",
|
||||
"provider": "felo-web"
|
||||
},
|
||||
"felo-web": {
|
||||
"className": "FeloWebExecutor",
|
||||
"configSource": "<custom-config>",
|
||||
"provider": "felo-web"
|
||||
},
|
||||
"firefly": {
|
||||
"className": "AdobeFireflyExecutor",
|
||||
"configSource": "<custom-config>",
|
||||
|
||||
@@ -2069,29 +2069,6 @@
|
||||
"stream": "https://api.featherless.ai/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"felo-web": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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": {
|
||||
|
||||
@@ -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<string, unknown>)?.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");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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<Response>): 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<string, unknown> {
|
||||
return JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function baseExecuteInput(overrides: Partial<ExecuteInput> = {}): 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);
|
||||
});
|
||||
});
|
||||
56
tests/unit/felo-web-retirement.test.ts
Normal file
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
405
tests/unit/felo-web-runtime-block.test.ts
Normal file
@@ -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<string, string>();
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
525
tests/unit/migration-165-retire-felo-web.test.ts
Normal file
@@ -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<string, number>();
|
||||
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);
|
||||
});
|
||||
@@ -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 `<alias>/<model>` 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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 `<alias>/<model>` 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(
|
||||
|
||||