Merge remote-tracking branch 'origin/release/v3.8.51' into feat/provider-xkiro

# Conflicts:
#	README.md
#	docs/diagrams/README.md
#	docs/diagrams/free-tier-budget.svg
#	docs/diagrams/promise-pillars.svg
#	docs/diagrams/readme-hero.svg
#	docs/reference/FREE_TIERS.md
This commit is contained in:
diegosouzapw
2026-09-03 21:44:58 -03:00
185 changed files with 16801 additions and 2041 deletions

View File

@@ -55,10 +55,11 @@ INITIAL_PASSWORD=CHANGEME
# loader (bin/cli/plugins.mjs) at a package tree — this one drives the server-side scanner.
# OMNIROUTE_PLUGINS_DIR=/opt/omniroute/plugins
# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never
# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the
# operator's real database. Set to 1 only for a deliberate run against the real
# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts
# Escape hatch for the test/eval DATA_DIR guard (#10428). A test or node eval/print
# probe (-e/--eval/-p/--print, including --eval=/--print=) that never chose a DATA_DIR
# is redirected to a throwaway temp dir so it cannot open the operator's real database.
# Set to 1 only for a deliberate run against the real DATA_DIR — never for CI.
# Used by: src/lib/dataPaths.ts
# OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1
# Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git
@@ -96,9 +97,11 @@ STORAGE_ENCRYPTION_KEY=
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
STORAGE_ENCRYPTION_KEY_VERSION=v1
# Automatic SQLite backup on startup.
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
# Default: false (backups enabled) | Set true to skip backup on every restart.
# Routine/pre-write SQLite backups.
# Used by: src/lib/db/backup.ts. Set true only when those backups are managed externally.
# This never disables the migration runner's mandatory, content-addressed safety snapshot
# or its mass-migration guard for an existing persistent database.
# Default: false (routine backups enabled).
DISABLE_SQLITE_AUTO_BACKUP=false
# ── Redis (Rate Limiting) ──

View File

@@ -98,6 +98,10 @@ _Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). B
### 🐛 Bug Fixes
- **security(streaming):** sanitize generic mid-stream error messages before emitting OpenAI,
Responses, or Claude SSE failure frames and before diagnostic logging, while preserving raw
failures for internal classification and keeping client disconnects out of provider failure state.
### 📝 Maintenance
---

View File

@@ -7,19 +7,19 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 357 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 357 AI providers · 150+ free tiers · ~1.66B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 357 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 357 AI providers · 150+ free tiers · ~1.63B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
<div align="center">
## 💰 ~1.66B Free Tokens / Month
## 💰 ~1.63B Free Tokens / Month
</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 **476 free-tier entries across 39 recurring pool keys** and computes the token headline from the **21 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 **476 free-tier entries across 38 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.66B free tokens per month steady, up to ~2.28B in the first month with signup credits, from 39 documented recurring pool keys covering 476 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 21 recurring pools with a published positive monthly token budget; 13 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.63B free tokens per month steady, up to ~2.25B in the first month with signup credits, from 38 documented recurring pool keys covering 476 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; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, xKiro 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)**.
>
@@ -209,7 +209,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 357 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 357 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 54 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 357 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 357 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<br/>
<br/>
@@ -518,9 +518,9 @@ Pix copia-e-cola:
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.66B tokens/month** from the documented,
The main free-tier headline remains **~1.63B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.28B**. Radar is an optional, signed catalog overlay for people who want fresher
month to **~2.25B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
@@ -648,7 +648,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
</div>
> **357 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **476 per-model rows**, **39 recurring pools** and **54 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
> **357 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **476 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
<div align="center">
@@ -1307,7 +1307,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>16-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: 39 documented recurring pools / 476 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: 38 documented recurring pools / 476 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>

View File

@@ -0,0 +1 @@
- Sanitize HuggingChat conversation-creation and message-send transport failures before they reach client error bodies or provider logs.

View File

@@ -0,0 +1 @@
- **fix(security):** Sanitize provider and runtime failures before public API, SSE and MCP responses and before persistent request, proxy and usage logs, preventing credentials, stack traces and host filesystem paths from crossing those boundaries while preserving stable error codes and useful diagnostics.

View File

@@ -0,0 +1 @@
- **fix(providers):** reclassify Cerebras as a one-time $5 signup credit (payment method required, 30-day validity), not a recurring no-card 1M tokens/day trial ([#11773](https://github.com/diegosouzapw/OmniRoute/issues/11773))

View File

@@ -0,0 +1 @@
- **fix(settings):** `PUT /api/settings/cache-config` now persists `alwaysPreserveClientCache` to the flat general settings the runtime cache-control policy actually reads; previously the value landed in the databaseSettings "cache" section and was silently ignored, so the endpoint had no effect on `cache_control` passthrough ([#12304](https://github.com/diegosouzapw/OmniRoute/pull/12304)) — thanks @davidebaraldo

View File

@@ -0,0 +1 @@
- **fix(providers):** Perplexity Web no longer turns upstream stream failures into successful assistant text; pre-content failures remain eligible for fallback, partial output ends with a structured sanitized error, and failed sessions are not persisted

View File

@@ -0,0 +1 @@
- **Z.ai Web:** HTTP 200 streams carrying an upstream error now terminate with a structured failure instead of assistant text plus a normal stop, preserving partial output while allowing pre-content combo fallback.

View File

@@ -0,0 +1 @@
- **fix(sse):** Treat Adapta Web `type:error` SSE events as sanitized non-stream failures instead of empty HTTP 200 completions.

View File

@@ -0,0 +1 @@
- **fix(security):** sanitize `request.failed` diagnostics before publishing them to live dashboard listeners and replay history, while keeping status, model, provider, latency, and internal call-log diagnostics intact.

View File

@@ -0,0 +1 @@
- Harden SQLite upgrades around the historical migration-074 version collision: missing discovery and inspector tables are replayed atomically, pre-existing databases (including setup-created skeletons) receive reusable content-addressed safety snapshots, and Node test/eval probes without `DATA_DIR` are isolated from the operator database.

View File

@@ -0,0 +1,4 @@
- **fix(grok-web):** treat upstream streaming failures as failures instead of successful
assistant text: error-only streams now fail readiness with HTTP 502, while failures after
legitimate content preserve that partial output and terminate through the sanitized stream
failure path without a normal `stop` completion.

View File

@@ -0,0 +1 @@
- HuggingChat now turns HTTP 200 JSONL generation failures into a sanitized 502 before content, or a fixed public stream failure after partial output, so fallback and request persistence no longer record a false successful stop.

View File

@@ -0,0 +1 @@
- **fix(providers):** keep 1min.ai HTTP 200 stream errors out of assistant content, preserve partial output, and expose sanitized terminal errors so pre-content failures can fall back.

View File

@@ -0,0 +1 @@
- **fix(providers):** Zed Hosted streaming failures now trigger fallback before content and end partial streams with a sanitized structured error instead of fake assistant text and a normal-success stop.

View File

@@ -0,0 +1 @@
- **chore(quality):** rebaseline the file-size caps the error-boundary campaign grew past (`open-sse/executors/codex.ts`, `open-sse/vendor/codex-chatgpt-web/bridge.ts`, both via [#12444](https://github.com/diegosouzapw/OmniRoute/pull/12444))

View File

@@ -412,7 +412,7 @@
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1751,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1499,
"open-sse/executors/codex.ts": 1505,
"open-sse/executors/cursor.ts": 1759,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 5984,
@@ -428,7 +428,7 @@
"open-sse/utils/proxyFetch.ts": 1271,
"open-sse/utils/stream.ts": 3072,
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5018,
@@ -636,5 +636,6 @@
"_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves.",
"_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).",
"_rebaseline_2026_09_03_12604_claude_code_2_1_258": "PR #12604 (bump da wire identity do Claude Code 2.1.220->2.1.258, commits do @ggiak vindos do #12402) crescimento proprio: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1606->1607 (+1, a linha do seletor que acompanha a nova versao de identidade). Uma linha num painel de settings ja existente; nao ha o que extrair. Coberto por client-identity-profiles e claude-codex-identity-version-sync (138/138 focados).",
"_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base)."
"_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base).",
"_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente."
}

View File

@@ -34,7 +34,7 @@ inside GitHub's `<img>` sandbox:
| [combo-always-on.svg](./combo-always-on.svg) | style reference | Animated priority-combo fallback (4 layers, 16s loop). Edit the SVG directly — there is no `.mmd` source. |
| [cli-terminal.svg](./cli-terminal.svg) | README.md (root) | Compact half-height animated terminal (1200×350): 3 real CLI commands cycling with typewriter + scrolling subcommand ticker; first frame = completed providers screen. Edit the SVG directly — there is no `.mmd` source. |
| [compression-pipeline.svg](./compression-pipeline.svg) | README.md (root) | Animated 12-engine compression funnel (8s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.66B/mo quantified headline, 21-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.63B/mo quantified headline, 21-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [readme-hero.svg](./readme-hero.svg) | README.md (root) | Animated hero card (tagline, live provider/free-access headline, full-width compression bar demo, 6 stat chips). Edit the SVG directly — there is no `.mmd` source. |
| [promise-pillars.svg](./promise-pillars.svg) | README.md (root) | Animated "The Promise" 6-pillar card (12s border-highlight sweep). Edit the SVG directly — there is no `.mmd` source. |
| [why-pain-fix.svg](./why-pain-fix.svg) | README.md (root) | Animated "Why OmniRoute" 10-row pain-vs-fix ledger (15s green row sweep). Edit the SVG directly — there is no `.mmd` source. |

View File

@@ -1,5 +1,5 @@
<svg viewBox="0 0 1200 872" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.66 billion free tokens per month steady, up to about 2.28 billion in the first month with signup credits. The catalog contains 485 rows, 478 active and 7 discontinued, grouped into 39 recurring pool keys; 21 pools have a published positive monthly token budget and 18 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 13 providers carry a terms-of-service avoid flag. The 21 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, xKiro 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 21 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately.</desc>
<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 476 rows, 469 active and 7 discontinued, grouped into 38 recurring pool keys; 20 pools have a published positive monthly token budget and 18 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 13 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, xKiro 150 million, Gemini 60 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">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="1"/>
@@ -28,9 +28,9 @@
</clipPath>
</defs>
<rect width="1200" height="872" fill="#0b0e14"/>
<rect width="1200" height="872" fill="url(#gridPaperF)"/>
<rect width="1200" height="872" fill="url(#bgGlowF)"/>
<rect width="1200" height="842" fill="#0b0e14"/>
<rect width="1200" height="842" fill="url(#gridPaperF)"/>
<rect width="1200" height="842" fill="url(#bgGlowF)"/>
<!-- ═══ Header ═══ -->
<text x="60" y="52" font-family="Consolas, 'Courier New', monospace" font-size="11" letter-spacing="2.5" font-weight="700" fill="#71717a">FREE-TIER BUDGET &#183; LIVE ON <tspan fill="#8b5cf6">/dashboard/free-tiers</tspan></text>
@@ -61,10 +61,10 @@
<animate attributeName="opacity" values="0;1;1;0" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
</circle>
</g>
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.66B</text>
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.63B</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 &#183; <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.28B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">39 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">476 catalog entries</tspan> &#183; one endpoint</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.25B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">38 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">476 catalog entries</tspan> &#183; 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"/>
@@ -75,37 +75,36 @@
</line>
<text x="836" y="156" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">every rate limit &#183; 24/7</text>
<text x="836" y="176" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#ef4444" opacity="0.85">we don't publish that</text>
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.66B</text>
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.63B</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 &#10003;</text>
<text x="704" y="280" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#f59e0b"><tspan font-weight="800">13 providers</tspan> ToS-flagged <tspan fill="#71717a">&#8212; we flag it &#183; you decide</tspan></text>
<!-- ═══ Budget bar · 21 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 &#183; <tspan fill="#F7F6FC">21 QUANTIFIED RECURRING POOLS</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 &#183; <tspan fill="#F7F6FC">20 QUANTIFIED RECURRING POOLS</tspan></text>
<g clip-path="url(#barShapeF)">
<rect x="60" y="372" width="1080" height="18" fill="#1c2230"/>
<g clip-path="url(#barRevF)">
<rect x="60.0" y="372" width="600.1" height="18" fill="#6c5ce7"/>
<rect x="661.1" y="372" width="90.0" height="18" fill="#00b894"/>
<rect x="752.1" y="372" width="90.0" height="18" fill="#0984e3"/>
<rect x="843.1" y="372" width="90.0" height="18" fill="#e17055"/>
<rect x="934.1" y="372" width="36.0" height="18" fill="#fdcb6e"/>
<rect x="971.1" y="372" width="18.0" height="18" fill="#e84393"/>
<rect x="990.1" y="372" width="18.0" height="18" fill="#00cec9"/>
<rect x="1009.1" y="372" width="14.4" height="18" fill="#d63031"/>
<rect x="1024.5" y="372" width="12.0" height="18" fill="#a29bfe"/>
<rect x="1037.5" y="372" width="9.0" height="18" fill="#55efc4"/>
<rect x="1047.5" y="372" width="7.5" height="18" fill="#74b9ff"/>
<rect x="1056.0" y="372" width="7.5" height="18" fill="#ffeaa7"/>
<rect x="1064.5" y="372" width="7.5" height="18" fill="#fab1a0"/>
<rect x="1073.0" y="372" width="7.5" height="18" fill="#81ecec"/>
<rect x="1081.5" y="372" width="7.5" height="18" fill="#6c5ce7"/>
<rect x="1090.0" y="372" width="7.5" height="18" fill="#00b894"/>
<rect x="1098.5" y="372" width="7.5" height="18" fill="#0984e3"/>
<rect x="1107.0" y="372" width="7.5" height="18" fill="#e17055"/>
<rect x="1115.5" y="372" width="7.5" height="18" fill="#fdcb6e"/>
<rect x="1124.0" y="372" width="7.5" height="18" fill="#e84393"/>
<rect x="1132.5" y="372" width="7.5" height="18" fill="#00cec9"/>
<rect x="60.0" y="372" width="611.9" height="18" fill="#6c5ce7"/>
<rect x="672.9" y="372" width="91.8" height="18" fill="#00b894"/>
<rect x="765.7" y="372" width="91.8" height="18" fill="#0984e3"/>
<rect x="858.5" y="372" width="91.8" height="18" fill="#e17055"/>
<rect x="951.3" y="372" width="36.7" height="18" fill="#fdcb6e"/>
<rect x="989.0" y="372" width="18.4" height="18" fill="#e84393"/>
<rect x="1008.4" y="372" width="14.7" height="18" fill="#00cec9"/>
<rect x="1024.1" y="372" width="12.2" height="18" fill="#d63031"/>
<rect x="1037.3" y="372" width="9.2" height="18" fill="#a29bfe"/>
<rect x="1047.5" y="372" width="7.5" height="18" fill="#55efc4"/>
<rect x="1056.0" y="372" width="7.5" height="18" fill="#74b9ff"/>
<rect x="1064.5" y="372" width="7.5" height="18" fill="#ffeaa7"/>
<rect x="1073.0" y="372" width="7.5" height="18" fill="#fab1a0"/>
<rect x="1081.5" y="372" width="7.5" height="18" fill="#81ecec"/>
<rect x="1090.0" y="372" width="7.5" height="18" fill="#6c5ce7"/>
<rect x="1098.5" y="372" width="7.5" height="18" fill="#00b894"/>
<rect x="1107.0" y="372" width="7.5" height="18" fill="#0984e3"/>
<rect x="1115.5" y="372" width="7.5" height="18" fill="#e17055"/>
<rect x="1124.0" y="372" width="7.5" height="18" fill="#fdcb6e"/>
<rect x="1132.5" y="372" width="7.5" height="18" fill="#e84393"/>
</g>
</g>
<circle r="3.2" fill="#F7F6FC">
@@ -114,90 +113,89 @@
</circle>
<text x="60" y="416" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#71717a">each segment = one recurring pool &#183; widths floored so every pool shows &#183; audited pool budgets below</text>
<!-- ═══ Per-pool grid (21 quantified recurring pools) ═══ -->
<!-- ═══ Per-pool grid (20 quantified recurring pools) ═══ -->
<g font-family="Consolas, 'Courier New', monospace" font-size="12.5">
<circle cx="66" cy="452" r="5" fill="#6c5ce7"/><text x="78" y="456" fill="#c9d1d9">Mistral <tspan fill="#71717a">1.00B</tspan></text>
<circle cx="346" cy="452" r="5" fill="#00b894"/><text x="358" y="456" fill="#c9d1d9">LLM7 <tspan fill="#71717a">150M</tspan></text>
<circle cx="626" cy="452" r="5" fill="#0984e3"/><text x="638" y="456" fill="#c9d1d9">Nara <tspan fill="#71717a">150M</tspan></text>
<circle cx="906" cy="452" r="5" fill="#e17055"/><text x="918" y="456" fill="#c9d1d9">xKiro <tspan fill="#71717a">150M</tspan></text>
<circle cx="66" cy="482" r="5" fill="#fdcb6e"/><text x="78" y="486" fill="#c9d1d9">Gemini <tspan fill="#71717a">60M</tspan></text>
<circle cx="346" cy="482" r="5" fill="#e84393"/><text x="358" y="486" fill="#c9d1d9">Cerebras <tspan fill="#71717a">30M</tspan></text>
<circle cx="626" cy="482" r="5" fill="#00cec9"/><text x="638" y="486" fill="#c9d1d9">Cloudflare AI <tspan fill="#71717a">30M</tspan></text>
<circle cx="906" cy="482" r="5" fill="#d63031"/><text x="918" y="486" fill="#c9d1d9">API Airforce <tspan fill="#71717a">24M</tspan></text>
<circle cx="66" cy="512" r="5" fill="#a29bfe"/><text x="78" y="516" fill="#c9d1d9">Ollama Cloud <tspan fill="#71717a">20M</tspan></text>
<circle cx="346" cy="512" r="5" fill="#55efc4"/><text x="358" y="516" fill="#c9d1d9">Groq <tspan fill="#71717a">15M</tspan></text>
<circle cx="626" cy="512" r="5" fill="#74b9ff"/><text x="638" y="516" fill="#c9d1d9">Bluesminds <tspan fill="#71717a">7.2M</tspan></text>
<circle cx="906" cy="512" r="5" fill="#ffeaa7"/><text x="918" y="516" fill="#c9d1d9">SambaNova <tspan fill="#71717a">6M</tspan></text>
<circle cx="66" cy="542" r="5" fill="#fab1a0"/><text x="78" y="546" fill="#c9d1d9">Arcee <tspan fill="#71717a">4.8M</tspan></text>
<circle cx="346" cy="542" r="5" fill="#81ecec"/><text x="358" y="546" fill="#c9d1d9">Navy <tspan fill="#71717a">4.5M</tspan></text>
<circle cx="626" cy="542" r="5" fill="#6c5ce7"/><text x="638" y="546" fill="#c9d1d9">BazaarLink <tspan fill="#71717a">3.6M</tspan></text>
<circle cx="906" cy="542" r="5" fill="#00b894"/><text x="918" y="546" fill="#c9d1d9">OpenRouter <tspan fill="#71717a">1.2M</tspan></text>
<circle cx="66" cy="572" r="5" fill="#0984e3"/><text x="78" y="576" fill="#c9d1d9">Cohere <tspan fill="#71717a">800K</tspan></text>
<circle cx="346" cy="572" r="5" fill="#e17055"/><text x="358" y="576" fill="#c9d1d9">HuggingChat <tspan fill="#71717a">500K</tspan></text>
<circle cx="626" cy="572" r="5" fill="#fdcb6e"/><text x="638" y="576" fill="#c9d1d9">Morph <tspan fill="#71717a">400K</tspan></text>
<circle cx="906" cy="572" r="5" fill="#e84393"/><text x="918" y="576" fill="#c9d1d9">Hugging Face <tspan fill="#71717a">200K</tspan></text>
<circle cx="66" cy="602" r="5" fill="#00cec9"/><text x="78" y="606" fill="#c9d1d9">Kiro <tspan fill="#71717a">25K</tspan></text>
<circle cx="346" cy="482" r="5" fill="#e84393"/><text x="358" y="486" fill="#c9d1d9">Cloudflare AI <tspan fill="#71717a">30M</tspan></text>
<circle cx="626" cy="482" r="5" fill="#00cec9"/><text x="638" y="486" fill="#c9d1d9">API Airforce <tspan fill="#71717a">24M</tspan></text>
<circle cx="906" cy="482" r="5" fill="#d63031"/><text x="918" y="486" fill="#c9d1d9">Ollama Cloud <tspan fill="#71717a">20M</tspan></text>
<circle cx="66" cy="512" r="5" fill="#a29bfe"/><text x="78" y="516" fill="#c9d1d9">Groq <tspan fill="#71717a">15M</tspan></text>
<circle cx="346" cy="512" r="5" fill="#55efc4"/><text x="358" y="516" fill="#c9d1d9">Bluesminds <tspan fill="#71717a">7.2M</tspan></text>
<circle cx="626" cy="512" r="5" fill="#74b9ff"/><text x="638" y="516" fill="#c9d1d9">SambaNova <tspan fill="#71717a">6M</tspan></text>
<circle cx="906" cy="512" r="5" fill="#ffeaa7"/><text x="918" y="516" fill="#c9d1d9">Arcee <tspan fill="#71717a">4.8M</tspan></text>
<circle cx="66" cy="542" r="5" fill="#fab1a0"/><text x="78" y="546" fill="#c9d1d9">Navy <tspan fill="#71717a">4.5M</tspan></text>
<circle cx="346" cy="542" r="5" fill="#81ecec"/><text x="358" y="546" fill="#c9d1d9">BazaarLink <tspan fill="#71717a">3.6M</tspan></text>
<circle cx="626" cy="542" r="5" fill="#6c5ce7"/><text x="638" y="546" fill="#c9d1d9">OpenRouter <tspan fill="#71717a">1.2M</tspan></text>
<circle cx="906" cy="542" r="5" fill="#00b894"/><text x="918" y="546" fill="#c9d1d9">Cohere <tspan fill="#71717a">800K</tspan></text>
<circle cx="66" cy="572" r="5" fill="#0984e3"/><text x="78" y="576" fill="#c9d1d9">HuggingChat <tspan fill="#71717a">500K</tspan></text>
<circle cx="346" cy="572" r="5" fill="#e17055"/><text x="358" y="576" fill="#c9d1d9">Morph <tspan fill="#71717a">400K</tspan></text>
<circle cx="626" cy="572" r="5" fill="#fdcb6e"/><text x="638" y="576" fill="#c9d1d9">Hugging Face <tspan fill="#71717a">200K</tspan></text>
<circle cx="906" cy="572" r="5" fill="#e84393"/><text x="918" y="576" fill="#c9d1d9">Kiro <tspan fill="#71717a">25K</tspan></text>
</g>
<!-- ═══ First-month signup credits ═══ -->
<text x="60" y="652" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#22c55e">+ FIRST MONTH &#183; ONE-TIME SIGNUP CREDITS <tspan fill="#7ee787">~626M</tspan></text>
<text x="60" y="622" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#22c55e">+ FIRST MONTH &#183; ONE-TIME SIGNUP CREDITS <tspan fill="#7ee787">~626M</tspan></text>
<g font-family="Consolas, 'Courier New', monospace">
<rect x="60" y="662" width="90" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="105" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">vertex 300M</text>
<rect x="160" y="662" width="123" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="222" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">agentrouter 200M</text>
<rect x="293" y="662" width="103" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="344" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">predibase 25M</text>
<rect x="406" y="662" width="96" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="454" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">together 25M</text>
<rect x="512" y="662" width="83" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="554" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">glm-cn 20M</text>
<rect x="605" y="662" width="83" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="646" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">doubao 15M</text>
<rect x="698" y="662" width="70" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="733" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">ai21 10M</text>
<rect x="778" y="662" width="90" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="823" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">deepseek 5M</text>
<rect x="878" y="662" width="103" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="930" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">hyperbolic 5M</text>
<rect x="991" y="662" width="90" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="1036" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">longcat 10M</text>
<rect x="1091" y="662" width="34" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="1108" y="677" font-size="11.5" fill="#7ee787" text-anchor="middle">&#8230;</text>
<rect x="60" y="632" width="90" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="105" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">vertex 300M</text>
<rect x="160" y="632" width="123" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="222" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">agentrouter 200M</text>
<rect x="293" y="632" width="103" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="344" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">predibase 25M</text>
<rect x="406" y="632" width="96" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="454" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">together 25M</text>
<rect x="512" y="632" width="83" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="554" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">glm-cn 20M</text>
<rect x="605" y="632" width="83" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="646" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">doubao 15M</text>
<rect x="698" y="632" width="70" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="733" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">ai21 10M</text>
<rect x="778" y="632" width="90" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="823" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">deepseek 5M</text>
<rect x="878" y="632" width="103" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="930" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">hyperbolic 5M</text>
<rect x="991" y="632" width="90" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="1036" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">longcat 10M</text>
<rect x="1091" y="632" width="34" height="22" rx="11" fill="#13311f" stroke="#238636" stroke-opacity="0.8"/>
<text x="1108" y="647" font-size="11.5" fill="#7ee787" text-anchor="middle">&#8230;</text>
</g>
<!-- ═══ Plus the un-countable ═══ -->
<text x="60" y="720" font-family="Consolas, 'Courier New', monospace" font-size="19" font-weight="800" fill="#8b5cf6">&#8734;<animate attributeName="opacity" values="1;0.55;1" dur="4s" repeatCount="indefinite"/></text>
<text x="86" y="718" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">PLUS THE UN-COUNTABLE <tspan fill="#71717a">&#8212; PERMANENTLY FREE &#183; NO TOKEN CAP</tspan></text>
<text x="60" y="690" font-family="Consolas, 'Courier New', monospace" font-size="19" font-weight="800" fill="#8b5cf6">&#8734;<animate attributeName="opacity" values="1;0.55;1" dur="4s" repeatCount="indefinite"/></text>
<text x="86" y="688" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">PLUS THE UN-COUNTABLE <tspan fill="#71717a">&#8212; PERMANENTLY FREE &#183; NO TOKEN CAP</tspan></text>
<g font-family="Consolas, 'Courier New', monospace" font-size="10.5" fill="#a1a1aa">
<rect x="60" y="732" width="94" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="107" y="747" text-anchor="middle">SiliconFlow</text>
<rect x="162" y="732" width="118" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="221" y="747" text-anchor="middle">Z.AI GLM-Flash</text>
<rect x="288" y="732" width="48" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="312" y="747" text-anchor="middle">Kilo</text>
<rect x="344" y="732" width="106" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="397" y="747" text-anchor="middle">OpenCode Zen</text>
<rect x="458" y="732" width="56" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="486" y="747" text-anchor="middle">baidu</text>
<rect x="522" y="732" width="30" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="537" y="747" text-anchor="middle">&#8230;</text>
<rect x="60" y="702" width="94" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="107" y="717" text-anchor="middle">SiliconFlow</text>
<rect x="162" y="702" width="118" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="221" y="717" text-anchor="middle">Z.AI GLM-Flash</text>
<rect x="288" y="702" width="48" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="312" y="717" text-anchor="middle">Kilo</text>
<rect x="344" y="702" width="106" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="397" y="717" text-anchor="middle">OpenCode Zen</text>
<rect x="458" y="702" width="56" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="486" y="717" text-anchor="middle">baidu</text>
<rect x="522" y="702" width="30" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="537" y="717" text-anchor="middle">&#8230;</text>
</g>
<text x="608" y="748" font-family="Consolas, 'Courier New', monospace" font-size="13.5" fill="#F7F6FC"><tspan fill="#f59e0b" font-weight="700">$10</tspan> OpenRouter top-up &#8594; <tspan fill="#22c55e" font-weight="700">+24M/mo</tspan></text>
<text x="60" y="776" font-family="Consolas, 'Courier New', monospace" font-size="11" fill="#71717a">surfaced separately &#8212; never inflates the headline</text>
<text x="608" y="718" font-family="Consolas, 'Courier New', monospace" font-size="13.5" fill="#F7F6FC"><tspan fill="#f59e0b" font-weight="700">$10</tspan> OpenRouter top-up &#8594; <tspan fill="#22c55e" font-weight="700">+24M/mo</tspan></text>
<text x="60" y="746" font-family="Consolas, 'Courier New', monospace" font-size="11" fill="#71717a">surfaced separately &#8212; never inflates the headline</text>
<!-- ═══ Footer strip ═══ -->
<rect x="60" y="800" width="1080" height="56" rx="14" fill="#161b22" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
<text x="84" y="822" font-family="Consolas, 'Courier New', monospace" font-size="9.5" letter-spacing="2" font-weight="700" fill="#71717a">CURRENT MONTH</text>
<rect x="84" y="830" width="240" height="8" rx="4" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
<rect x="85" y="831" width="132" height="6" rx="3" fill="#8b5cf6"/>
<rect x="60" y="770" width="1080" height="56" rx="14" fill="#161b22" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
<text x="84" y="792" font-family="Consolas, 'Courier New', monospace" font-size="9.5" letter-spacing="2" font-weight="700" fill="#71717a">CURRENT MONTH</text>
<rect x="84" y="800" width="240" height="8" rx="4" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
<rect x="85" y="801" width="132" height="6" rx="3" fill="#8b5cf6"/>
<circle r="2.6" fill="#F7F6FC">
<animateMotion path="M 85,834 L 217,834" dur="3s" repeatCount="indefinite"/>
<animateMotion path="M 85,804 L 217,804" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="344" cy="834" r="3.5" fill="#22c55e">
<circle cx="344" cy="804" r="3.5" fill="#22c55e">
<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"/>
</circle>
<text x="356" y="838" font-family="Consolas, 'Courier New', monospace" font-size="10" letter-spacing="1.5" font-weight="700" fill="#22c55e">LIVE</text>
<text x="424" y="838" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">used / remaining &#183; per-model breakdown &#183; transparent <tspan fill="#f59e0b">terms flag</tspan> per provider</text>
<text x="356" y="808" font-family="Consolas, 'Courier New', monospace" font-size="10" letter-spacing="1.5" font-weight="700" fill="#22c55e">LIVE</text>
<text x="424" y="808" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">used / remaining &#183; per-model breakdown &#183; transparent <tspan fill="#f59e0b">terms flag</tspan> per provider</text>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 357 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 150+ providers with a free tier and 54 recurring or keyless free-forever providers. Every tool works: 36 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 357 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 150+ providers with a free tier and 53 recurring or keyless free-forever providers. Every tool works: 36 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -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">150+ providers with a free tier, 54 free</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">150+ providers with a free tier, 53 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

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 357 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 357 AI providers, 150+ free tiers, about 1.66B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 357 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 357 AI providers, 150+ free tiers, about 1.63B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
<defs>
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -72,7 +72,7 @@
<text x="320" y="471" font-size="17" font-weight="800" fill="#7ee787">150+</text>
<text x="320" y="490" font-size="11" fill="#a1a1aa">FREE TIERS</text>
<rect x="420" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#22c55e" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="506" y="471" font-size="17" font-weight="800" fill="#7ee787">~1.66B</text>
<text x="506" y="471" font-size="17" font-weight="800" fill="#7ee787">~1.63B</text>
<text x="506" y="490" font-size="11" fill="#a1a1aa">FREE TOKENS / MO</text>
<rect x="606" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#e17055" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="692" y="471" font-size="17" font-weight="800" fill="#e17055">1595%</text>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,6 +1,6 @@
# Free Tiers Guide: Understand and Combine Free AI Access
> **TL;DR**: OmniRoute registers 351 provider IDs, with **152 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **39 recurring pool keys / 445 entries** (438 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 **152 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **38 recurring pool keys / 476 entries** (469 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
---
@@ -161,9 +161,9 @@ The live, pool-deduplicated catalog currently reports:
| Metric | Current audited value | Interpretation |
| ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- |
| 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 | **39 recurring pool keys / 445 catalog entries** | 438 active + 7 discontinued; distinct from the 351-provider catalog |
| Recurring quantified grant | **~1.63B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.25B tokens** | Recurring total plus one-time and recurring credits |
| Audited free-model inventory | **38 recurring pool keys / 476 catalog entries** | 469 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` | **152 / 351** | Broader provider metadata; not all have a quantifiable recurring quota |

View File

@@ -183,7 +183,7 @@ These providers offer **free access** with no credit card:
| **LongCat** | 10M one-time | LongCat-2.0 | API key + KYC |
| **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed |
| **NVIDIA NIM** | ~40 RPM | 129 models | API key needed |
| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed |
| **Cerebras** | $5 signup credit | GLM 4.7, GPT-OSS 120B | API key + card |
| **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed |
**Tip**: Connect multiple free providers for **unlimited free AI** with automatic fallback!

View File

@@ -613,7 +613,7 @@ In-process density (compression off the HTTP isolate) is [#11023](https://github
## Important Notes
- **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`.
- **`DISABLE_SQLITE_AUTO_BACKUP`:** Set to `true` if backups are managed externally.
- **`DISABLE_SQLITE_AUTO_BACKUP`:** Set to `true` if routine/pre-write backups are managed externally. Existing-database migrations still require their own durable safety snapshot and mass-migration guard.
- **Data Persistence:** Always mount a volume to `/app/data` to persist your database, keys, and configurations across container restarts.
- **Port Configuration:** Override `PORT` environment variable to change the default `20128` port.

View File

@@ -86,7 +86,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| Variable | Default | Source File | Description |
| -------------------------------------- | -------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. |
| `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR` | _(unset)_ | `src/lib/dataPaths.ts` | Escape hatch for the test-context DATA_DIR guard (#10428). Test runs with no `DATA_DIR` are redirected to a throwaway temp dir so they cannot open the operator's real database; set to `1` to opt back in to the real directory. |
| `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR` | _(unset)_ | `src/lib/dataPaths.ts` | Escape hatch for the test/eval DATA_DIR guard (#10428). Tests and Node eval/print probes (`-e`/`--eval`/`-p`/`--print`, including `--eval=`/`--print=` forms) with no `DATA_DIR` are redirected to a throwaway temp dir so they cannot open the operator's real database; set to `1` to opt back in to the real directory. |
| `OMNIROUTE_BUILD_SHA` | _(unset)_ | `src/lib/monitoring/buildSha.ts` | Git SHA of the running artifact. Stamped by `npm run build:release`; injectable in containers that ship without the `dist/BUILD_SHA` sentinel. Surfaced as `system.buildSha` on `/api/monitoring/health`. |
| `OMNIROUTE_RELEASE_REF` | `origin/main` | `scripts/build/buildProvenance.ts` | Ref the pack-artifact provenance gate checks the build SHA against (#10427). |
| `OMNIROUTE_ALLOW_CANARY_BUILD` | _(unset)_ | `scripts/build/buildProvenance.ts` | Set to `1` to allow packing a build whose SHA is not on the release line, recording it as a deliberate canary instead of failing the gate (#10427). |
@@ -97,7 +97,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_PLUGINS_DIR` | _(unset)_ | `src/lib/plugins/scanner.ts` | Directory the **runtime plugin scanner** reads — and the root the plugin manager installs into — overriding the home-derived default (#11827). Point it at the bind-mounted plugin tree in Docker/K8s instead of moving HOME just to relocate the scan path (HOME governs every other home-relative behaviour too). Unset = `~/.omniroute/plugins`, or `/tmp/.omniroute/plugins` when the process exports no home at all — the silent non-discovery this variable removes. The resolved directory is logged once at startup as `scanner.dir_resolved` with the input that won. Server-side only: CLI command plugins keep their own `OMNIROUTE_PLUGIN_PATH` (section 9). |
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips automatic + pre-write SQLite file backups (startup, models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. Non-manual backups are also **throttled to at most once per 60 minutes** so hourly models.dev sync does not copy the whole DB on every pricing write. Dashboard **Settings → Storage** can disable auto-backup independently. |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips routine/pre-write SQLite file backups (models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. It does **not** disable the migration runner's mandatory durable safety snapshot or mass-migration guard for an existing persistent DB. Non-manual backups are throttled to at most once per 60 minutes. Dashboard **Settings → Storage** can disable routine auto-backup independently. |
| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. |
| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. |
| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. |
@@ -121,6 +121,16 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `BATCH_BACKOFF_MAX_MS` | `3600000` (1h) | `open-sse/services/batchProcessor.ts` | Cap (ms) for exponential backoff between batch item retries. |
| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | Maximum number of batches processed concurrently. Raise to increase throughput; keep low to avoid rate-limit storms. |
> [!IMPORTANT]
> Before changing an existing persistent database, the migration runner publishes a complete,
> content-addressed snapshot under `DATA_DIR/db_backups/`. Publication requires a filesystem
> that supports same-filesystem, no-overwrite hard links plus durable file sync. POSIX hosts also
> require directory sync; on Windows, Node may reject directory handles, so OmniRoute flushes the
> published file and treats directory-entry sync as best effort.
> If the mounted `DATA_DIR` cannot provide those guarantees, startup fails closed before applying
> a migration. Move `DATA_DIR` to a volume with those primitives; do not use
> `DISABLE_SQLITE_AUTO_BACKUP` to bypass migration safety.
### Scenarios
| Scenario | Configuration |
@@ -1321,8 +1331,8 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. |
| `TAILSCALE_AUTHKEY` | _(unset)_ | `src/lib/tailscaleTunnel.ts` | Pre-shared Tailscale auth key for non-interactive / headless `tailscale up` (passed via `--auth-key=`). When unset, login falls back to the interactive browser auth URL. |
| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. |
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum SQLite backup files retained on disk. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained by manual/scheduled backup cleanup. Migration snapshots are content-addressed and reused for an identical DB state; they are not pruned inside the concurrent migration window. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) retained by manual/scheduled backup cleanup. `0` disables age-based pruning. Migration snapshots are not pruned inside the concurrent migration window. Overrides the value saved from Settings → Database backup retention. |
| `OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS` | `30000` | `src/lib/jobs/backupScheduleJob.ts` | Tick interval (ms) of the server-side job that executes `backup-schedule.json`. Must stay well under the 1-minute cron granularity; values below `5000` or unparseable fall back to `30000`. |
| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` for any Podman topology. Because the container cannot determine whether the engine is local or reached through Podman Machine, the warning stays topology-neutral and points to `contrib/podman/README.md`. |
| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. |

View File

@@ -15,21 +15,23 @@ lastUpdated: 2026-09-02
| Metric | Tokens / month | Meaning |
| ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Documented recurring grant (steady)** | **~1.66B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** |
| **+ first month with signup credits** | **~2.28B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. |
| **Documented recurring grant (steady)** | **~1.63B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** |
| **+ first month with signup credits** | **~2.25B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. |
| **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). |
| **+ 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.66B documented free tokens per month** (up to ~2.28B 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 (1595% token savings) stretches that further._
**Honest headline:** _OmniRoute aggregates **~1.63B documented free tokens per month** (up to ~2.25B in your first month with signup credits) across 38 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (1595% 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 on 2026-09-02 after adding xKiro:** the source now reports 39 recurring pool keys (38 after the 2026-08-26 Felo Web retirement — Felo Web stays 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()`).
> **Corrected to ~1.48B on 2026-09-03 (#11773):** `cerebras` was reclassified from a 30M/mo recurring grant (old no-card 1M tokens/day trial) to a one-time $5 signup credit that requires a payment method. Same honesty rule as LongCat.
>
> **Updated on 2026-09-02 after adding xKiro:** the source now reports 38 recurring pool keys — 37 after the 2026-08-26 Felo Web retirement and the #11773 Cerebras reclassification, plus the new `xkiro-free` pool (150M/mo). Felo Web stays 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, `xkiro` 150M, `nara` 150M, `gemini` 60M, `cerebras` 30M, `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `xkiro` 150M, `nara` 150M, `gemini` 60M, `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
> ⚠️ The theoretical ceiling (~10B) is inflated by rate-limit-only providers with **no published token cap** (`tencent`, `siliconflow`, `nvidia`, `baidu`, `glm-cn`, `sparkdesk`) whose figures would be `RPM/TPM × 24/7 × 30d` — a theoretical maximum no single account will sustain. They are **excluded** from the defensible number (shown in the "permanently free, no cap" row instead). This is the same inflation that makes competitors' multi-billion claims unreliable.
@@ -69,7 +71,7 @@ purpose.
## Methodology & caveats
- Numbers are **upper-bound estimates** from each provider's documented free-tier limits as of **2026-06-17**, gathered by web research. Free tiers change constantly — re-verify before relying on a figure.
- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 46 entries carry an independently documented hard stop (39 of them the xKiro rows, which all share one daily allowance), and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you".
- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 44 entries carry an independently documented hard stop (39 of them the xKiro rows, which all share one daily allowance), and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you".
- `estMonthlyFreeTokens` = recurring monthly tokens only. **One-time signup credits do not recur** and count as 0. Discontinued tiers are also 0.
- Daily token cap → `monthly = daily × 30`. Only RPD documented → `RPD × ~800 output tokens × 30`. Only RPM/TPM (no daily cap) → **uncapped** (see below).
- **Permanently free, but no published token cap** (`siliconflow`, `glm-cn`, `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`): these are real recurring free access, rate/concurrency-limited. We classify them `recurring-uncapped` and **never sum them** — multiplying `RPM × 24/7 × 30d` would produce a fantasy ceiling (the inflation we reject). They are listed so you know they exist.
@@ -195,7 +197,7 @@ purpose.
| `xkiro` | recurring | ~150M | — | caution | 39 |
| `longcat` | one-time | — | 10M | caution | 1 |
| `gemini` | recurring | ~60M | — | caution | 4 |
| `cerebras` | recurring | ~30M | | caution | 2 |
| `cerebras` | one-time | — | $5 credit | caution | 2 |
| `cloudflare-ai` | recurring | ~30M | — | caution | 9 |
| `api-airforce` | recurring | ~24M | — | caution | 7 |
| `ollama-cloud` | recurring | ~20M | — | ambiguous | 8 |
@@ -278,7 +280,7 @@ purpose.
- **`bluesminds`** — Our shipped freeNote was "(none)" — but BluesMinds does have a documented free tier: 500 pi credits, 20 RPM, 300 RPD, permanent free plan. The catalog significantly understates the offering.
- **`brave-search`** — The catalog notes "(none)" suggesting no free tier was tracked, but in reality there was a free 5,000 queries/month tier (no card) until February 12, 2026, which has since been replaced by a $5/month…
- **`byteplus`** — Our catalog shipped "(none)" but BytePlus ModelArk does have a free tier: a one-time trial credit of 500k tokens per LLM model for new accounts. The catalog underreports this.
- **`cerebras`** — TPM appears tightened from 60K to 30K on current documented models (gpt-oss-120b, zai-glm-4.7). RPM of 5 is now explicitly documented (was not in our shipped note). Daily token cap of 1M/day is uncha…
- **`cerebras`** — The no-card 1M tokens/day trial is gone. Live cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit, payment method required, 30-day validity. Reclassified as `one-time-initial` (LongCat-shaped); dropped from `LEGACY_FREE_PROVIDERS` and the recurring budget.
- **`chutes`** — The shipped freeNote says "Free tier available" but as of March 15, 2026, the free tier has been officially discontinued. The catalog note is stale and should be updated to reflect that there is no r…
- **`coze`** — The shipped note "Free ByteDance agent platform" is directionally accurate but omits that the free tier is now tightly credit-capped (10 credits/day ≈ 5100 messages depending on model), a constraint…
- **`deepinfra`** — Our shipped freeNote says "Free signup credits for API testing" — this appears stale. The official pricing page now requires card/prepayment with no documented general free signup credit. The free ti…

View File

@@ -1,14 +1,14 @@
---
title: "Provider Reference"
version: 3.8.51
lastUpdated: 2026-09-03
lastUpdated: 2026-09-04
---
# Provider Reference
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
> Regenerate with: `npm run gen:provider-reference`
> **Last generated:** 2026-09-03
> **Last generated:** 2026-09-04
Total providers: **357**. See category breakdown below.
@@ -151,7 +151,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 |
| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — |
| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier. |
| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup |
| `chat-oripe` | `chat-oripe` | Chat Oripe | API key, aggregator | [link](https://api.oriper.com) | Official metadata advertises 2M tokens/month, but the public site and documentation were blocked during audit; treat the quota and brand mapping as unconfirmed. |
| `chatanywhere` | `chatanywhere` | ChatAnywhere | API key, aggregator | [link](https://chatanywhere.tech) | Personal, educational or research use only: public documentation cites 10,000 points/day and 200 requests/day per IP/key; do not use for commercial traffic. |

View File

@@ -5,9 +5,9 @@
<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">20 free pools · 446 models · 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="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.63B</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="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.25B</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">13 providers</text>
<clipPath id="bar"><rect x="32" y="132" width="836" height="16" rx="8"/></clipPath>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,14 +1,16 @@
---
title: "Error Message Sanitization"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.51
lastUpdated: 2026-09-02
---
# Error Message Sanitization
> **Source of truth:** `open-sse/utils/error.ts` — `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult`
> **Tests:** `tests/unit/error-message-sanitization.test.ts`
> **Last updated:** 2026-06-28 — v3.8.40
> **Source of truth:** `open-sse/utils/errorSanitization.ts`,
> `open-sse/utils/errorPathRedaction.ts`, and the public builders in `open-sse/utils/error.ts`
> **Tests:** `tests/unit/error-message-sanitization.test.ts`,
> `tests/unit/error-public-boundaries-hardening.test.ts`
> **Last updated:** 2026-09-02 — v3.8.51
> **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers).
> **Status:** **MANDATORY** for every code path that returns an error message to a client.
@@ -20,10 +22,18 @@ CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an err
- Library / framework versions inferred from stack frames → targeted exploit selection.
- Sensitive runtime values that may be string-interpolated into errors (DB queries, config values).
The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage:
The `sanitizeErrorMessage` helper exported by `open-sse/utils/error.ts` strips these classes of
leakage:
1. Multi-line stack traces — only the first line (the actual error message) is kept.
2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) — replaced with `<path>`.
1. Physical, serialized, and unambiguously inline JavaScript stack-frame tails.
2. Absolute POSIX, Windows, UNC, and `file://` filesystem paths, while preserving safe HTTPS URLs
and explicitly marked API routes.
3. Credential assignments, common provider token formats, private-key PEM blocks, and base64 data
URLs.
The sanitizer caps input length and fails closed when a thrown value rejects string coercion.
Recursive upstream JSON sanitization also drops unsafe credential/path keys, session aliases, and
prototype-control keys before a response is serialized.
## The mandatory pattern
@@ -59,7 +69,10 @@ import {
} from "@omniroute/open-sse/utils/error.ts";
```
All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers.
All of these apply the canonical public-error boundary. `errorResponse`, `writeStreamError`, and
`createErrorResult` route through `buildErrorBody`; the three specialized retry/circuit helpers
project and sanitize their public context directly. **You never need to call
`sanitizeErrorMessage` manually** when using these helpers.
### 2. Custom error envelopes (rare)
@@ -81,17 +94,25 @@ This is the only sanctioned way to assemble a custom error body. See `open-sse/e
### 3. Logging vs. responding
`sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern:
Trusted internal exceptions may keep their full message and stack so operators can debug. Values
originating at provider, validation, browser-session, or credential-adjacent boundaries must be
sanitized before they enter console output, audit metadata, or persistent call logs. Pattern:
```ts
try {
// ...
} catch (err) {
log.error({ err }, "handler failed"); // full err with stack — internal log
log.error({ err }, "handler failed"); // trusted internal exception only
return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client
}
```
For provider-controlled failures, project the logged value too:
```ts
log.error({ message: sanitizeErrorMessage(err) || "Provider request failed" });
```
### 4. Forbidden patterns
**Never** put raw exception output in a Response body:
@@ -112,7 +133,9 @@ const safe = String(err).split("\n")[0];
**Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`).
**Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages — they bypass the path regex and reveal the deployment topology.
**Never** intentionally include `process.cwd()`, `__filename`, `__dirname`, or env-derived paths
in error messages. The sanitizer covers absolute paths as defense in depth, but callers must not
construct topology-bearing messages in the first place.
## Coverage in CI
@@ -129,7 +152,9 @@ When adding a new route or executor, copy the assertion pattern from this file.
## Related controls
- `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc.
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface.
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles trusted structured logs
separately. This document covers public response messages and provider-controlled values that
cross persistent call/proxy-log boundaries.
- Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern.
## Upstream details passthrough
@@ -138,27 +163,39 @@ When adding a new route or executor, copy the assertion pattern from this file.
parsed body from the upstream provider). When provided, it is sanitized by
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
An optional fourth argument `classification` (`{ type?: string; code?: string }`)
preserves an explicit error type/code instead of re-deriving both from the
status-code table — used when the caller already classified the failure (e.g.
HTTP 499 → `client_disconnected`).
An optional fourth argument `classification`
(`{ type?: string; code?: string; reason?: string }`) accepts an explicit public classification.
Every field is projected onto the bounded public-identifier vocabulary. Unsafe, credential-shaped,
control-character, or overlong values fall back to the status-derived type/code; an unsafe optional
reason is omitted. Three-digit HTTP status identifiers (`100` through `599`) remain valid for
provider contracts that expose the numeric upstream status as a machine-readable code. The same
bounded range is accepted in the locally generated HTTP-status placeholder form; arbitrary provider
numbers and names remain outside the vocabulary.
Pass every explicit classification in that fourth argument. Never overwrite
`body.error.code`, `body.error.type`, or `body.error.reason` after `buildErrorBody()` returns;
post-builder mutation bypasses the public projection.
Sanitization rules applied to `upstreamDetails`:
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
are removed.
2. Unsafe path, credential, session-alias, and prototype-control keys are removed.
3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`.
4. Arrays are capped at 32 elements.
Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass
`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content,
guardrail blocks) do not include `upstream_details`.
Only call sites with a parsed provider error body should pass `upstreamDetails`. Internal OmniRoute
errors (SSE parse failures, empty content, guardrail blocks) must not include it.
Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to
`upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)`
without an upstream body.
Selective upstream 4xx passthrough preserves the provider's safe JSON shape and wording required by
client auto-recovery, but it is not byte-for-byte passthrough: the recursive sanitizer always runs
before serialization. Cyclic, BigInt-bearing, or hostile `toJSON()` bodies fail closed and are not
eligible for passthrough. OCR and moderation apply the same rule; non-JSON, blank, or mislabeled
upstream bodies are converted to the canonical OmniRoute JSON error envelope.
## Known CodeQL limitation: custom sanitizers not recognized
The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`.

View File

@@ -106,9 +106,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "bytez", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "mistralai/Mistral-7B-Instruct-v0.3", displayName: "mistralai/Mistral-7B-Instruct-v0.3", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
// hardStopGuaranteed: Cerebras pricing page states "Free Trial: 1M tokens/day... no credit card" (open-sse/services/../providers/apikey/inference-hosts.ts:74-84).
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
// #11773: cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit
// gated on a payment method, 30-day expiry — not the old no-card 1M/day
// trial. creditTokens stays 0 because Cerebras publishes dollars, not a
// token grant. hardStopGuaranteed must stay unset: a stored card can bill.
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" },
// #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast.
{ provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
{ provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },

View File

@@ -16,7 +16,6 @@ export const FREE_TIER_BUDGETS: Record<string, number> = {
"cloudflare-ai": 122_000_000,
gemini: 60_000_000,
doubao: 60_000_000,
cerebras: 30_000_000,
"api-airforce": 24_000_000,
"ollama-cloud": 20_000_000,
groq: 15_000_000,

View File

@@ -33,6 +33,17 @@ export interface SearchProviderConfig {
*/
fallbackOnly?: boolean;
disabled?: boolean;
/**
* May a CALLER-supplied `provider_options.baseUrl` redirect this provider?
*
* Only ever true for a keyless, self-hosted provider. For anything with
* `authType: "apikey"` the builder attaches the OPERATOR's key to whatever
* host the base URL resolves to, so honoring a caller-chosen value hands that
* key to the caller's server (GHSA-3f8g-pfh9-j687). The invariant
* "never set alongside authType: apikey" is enforced by
* tests/unit/search-baseurl-client-override-3f8g.test.ts.
*/
allowClientBaseUrlOverride?: boolean;
}
export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
@@ -232,6 +243,11 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
timeoutMs: 10_000,
cacheTTLMs: 3 * 60 * 1000,
fallbackOnly: true,
// Keyless and self-hosted by definition: the caller names their own SearXNG
// instance and no operator credential travels with the request. This is a
// documented flow (tests/unit/search-route.test.ts). Still block-metadata
// guarded, so IMDS stays unreachable.
allowClientBaseUrlOverride: true,
},
"ollama-search": {

View File

@@ -1,10 +1,11 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
const ADAPTA_APP_URL = "https://agent.adapta.one";
const ADAPTA_CLERK_URL = "https://clerk.agent.adapta.one";
const ADAPTA_STREAM_URL = `${ADAPTA_APP_URL}/api/chat/stream/v1`;
const ADAPTA_PUBLIC_STREAM_ERROR = `\n\n[Erro: ${sanitizeErrorMessage("Adapta upstream error")}]`;
// Default model ID in Adapta's internal system (corresponds to "ONE" / auto-select)
const DEFAULT_AI_MODEL_ID = 14;
@@ -321,10 +322,9 @@ function transformStream(adaptaStream: ReadableStream, model: string): ReadableS
if (event.id === "quick-response") continue;
// Real text ended — stream will send more events or close
} else if (type === "error") {
const errText = String(event.errorText ?? "Adapta upstream error");
ensureRole();
// Emit the error as content so the user sees it
chunk({ content: `\n\n[Erro: ${errText}]` });
// Keep upstream diagnostics private: the transformed SSE is a public HTTP 200 body.
chunk({ content: ADAPTA_PUBLIC_STREAM_ERROR });
finalize();
return;
} else if (type === "done" || type === "end") {
@@ -480,9 +480,10 @@ export class AdaptaWebExecutor extends BaseExecutor {
const reader = resp.body!.getReader();
let buf = "";
let fullText = "";
let upstreamErrorMessage: string | null = null;
try {
while (true) {
readLoop: while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
@@ -494,6 +495,9 @@ export class AdaptaWebExecutor extends BaseExecutor {
const ev = JSON.parse(line.slice(6));
if (ev.type === "text-delta" && ev.id !== "quick-response") {
fullText += String(ev.delta ?? "");
} else if (ev.type === "error") {
upstreamErrorMessage = "Adapta upstream error";
break readLoop;
}
} catch {
// skip
@@ -501,9 +505,24 @@ export class AdaptaWebExecutor extends BaseExecutor {
}
}
} finally {
if (upstreamErrorMessage) {
void reader.cancel("Adapta upstream SSE error").catch(() => undefined);
}
reader.releaseLock();
}
if (upstreamErrorMessage) {
return {
response: new Response(JSON.stringify(buildErrorBody(502, upstreamErrorMessage)), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
url: ADAPTA_STREAM_URL,
headers,
transformedBody: requestPayload,
};
}
if (hasTools) {
const { content, toolCalls, finishReason } = buildToolAwareResult(
fullText,

View File

@@ -216,9 +216,10 @@ function makeErrorResponse(
extraHeaders?: Record<string, string>;
}
): Response {
const body = buildErrorBody(status, message, options?.details);
if (options?.type) body.error.type = options.type;
if (options?.code) body.error.code = options.code;
const body = buildErrorBody(status, message, options?.details, {
type: options?.type,
code: options?.code,
});
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (options?.extraHeaders) {
for (const [key, value] of Object.entries(options.extraHeaders)) {

View File

@@ -453,9 +453,10 @@ function makeChunk(
}
function protocolErrorBody(): Record<string, unknown> {
const body = buildErrorBody(502, "Claude Web stream protocol error");
body.error.type = "upstream_protocol_error";
body.error.code = "claude_web_protocol_error";
const body = buildErrorBody(502, "Claude Web stream protocol error", undefined, {
type: "upstream_protocol_error",
code: "claude_web_protocol_error",
});
return body as unknown as Record<string, unknown>;
}

View File

@@ -38,6 +38,7 @@ import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { projectCodexPublicError } from "../utils/codexPublicError.ts";
import { errorResponse } from "../utils/error.ts";
import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts";
import * as prl from "../utils/providerRequestLogging.ts";
@@ -493,7 +494,6 @@ function toCodexResponseFailedEvent(parsed: Record<string, unknown>): Record<str
typeof upstreamError.message === "string" && upstreamError.message.trim()
? upstreamError.message
: "Codex upstream error";
const error: Record<string, unknown> = { code, message };
const explicitStatus =
toStatusCode(parsed.status_code) ??
toStatusCode(parsed.status) ??
@@ -503,8 +503,10 @@ function toCodexResponseFailedEvent(parsed: Record<string, unknown>): Record<str
toStatusCode(upstreamError.status);
const statusCode =
explicitStatus ?? (looksLikeQuotaOrRateLimit(code, type, message) ? 429 : null);
const error: Record<string, unknown> = {
...projectCodexPublicError({ status: statusCode, code, type }),
};
if (type) error.type = type;
if (statusCode !== null) error.status_code = statusCode;
return {
@@ -955,7 +957,7 @@ export class CodexExecutor extends BaseExecutor {
}
};
const failController = (code: string, message: string) => {
const failController = (code: string, _message: string) => {
if (closed) return;
const controller = streamController;
const payload = JSON.stringify({
@@ -963,7 +965,7 @@ export class CodexExecutor extends BaseExecutor {
response: {
id: null,
status: "failed",
error: { code, message },
error: projectCodexPublicError({ status: 502, code, type: "provider_error" }),
},
});
try {

View File

@@ -19,7 +19,7 @@ import {
type ExecuteInput,
type ExecutorLog,
} from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { FETCH_TIMEOUT_MS, STREAM_READINESS_TIMEOUT_MS } from "../config/constants.ts";
import { buildGrokCookieHeader } from "@/lib/providers/webCookieAuth";
import {
tlsFetchGrok,
@@ -27,7 +27,8 @@ import {
isCloudflareChallenge,
type TlsFetchResult,
} from "../services/grokTlsClient.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
import {
shouldUseGrokBrowserBacked,
acquireFreshGrokClearance,
@@ -119,12 +120,29 @@ async function* readGrokNdjsonEvents(
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let reachedEnd = false;
let cancelRequested = false;
const requestReaderCancel = (reason?: unknown) => {
if (cancelRequested || reachedEnd) return;
cancelRequested = true;
// Cancellation must release the upstream promptly even when a provider's
// underlying cancel promise never settles.
void reader.cancel(reason).catch(() => {});
};
const handleAbort = () => requestReaderCancel(signal?.reason);
if (signal?.aborted) requestReaderCancel(signal.reason);
else signal?.addEventListener("abort", handleAbort, { once: true });
try {
while (true) {
if (signal?.aborted) return;
const { value, done } = await reader.read();
if (done) break;
if (done) {
reachedEnd = true;
break;
}
buffer += decoder.decode(value, { stream: true });
while (true) {
@@ -142,6 +160,8 @@ async function* readGrokNdjsonEvents(
}
}
if (signal?.aborted) return;
// Flush remaining buffer
buffer += decoder.decode();
const remaining = buffer.trim();
@@ -153,7 +173,11 @@ async function* readGrokNdjsonEvents(
}
}
} finally {
reader.releaseLock();
signal?.removeEventListener("abort", handleAbort);
if (!reachedEnd) requestReaderCancel(signal?.reason ?? "Grok stream reader closed early");
try {
reader.releaseLock();
} catch {}
}
}
@@ -271,6 +295,8 @@ async function* extractContent(
}
}
if (signal?.aborted) return;
const trailingThinking =
suppressThinkingAfterVisibleContent && emittedVisibleContent ? "" : thinkingFilter.flush();
if (trailingThinking) {
@@ -292,6 +318,25 @@ function sseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
const GROK_STREAM_FAILURE_MESSAGE = "Grok upstream stream failed";
const GROK_STREAM_FAILURE_CODE = "GROK_STREAM_ERROR";
function grokStreamErrorChunk(): string {
return sseChunk(
buildErrorBody(502, GROK_STREAM_FAILURE_MESSAGE, undefined, {
type: "upstream_error",
code: GROK_STREAM_FAILURE_CODE,
})
);
}
function grokStreamFailure(): Error & { statusCode: number; code: string } {
return Object.assign(new Error(GROK_STREAM_FAILURE_MESSAGE), {
statusCode: 502,
code: GROK_STREAM_FAILURE_CODE,
});
}
function enqueueStreamingToolCalls(
controller: ReadableStreamDefaultController<Uint8Array>,
encoder: TextEncoder,
@@ -349,63 +394,77 @@ function buildStreamingResponse(
signal?: AbortSignal | null
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const streamAbortController = new AbortController();
const requestStreamCancel = (reason?: unknown) => {
if (!streamAbortController.signal.aborted) streamAbortController.abort(reason);
};
const handleParentAbort = () => requestStreamCancel(signal?.reason);
if (signal?.aborted) requestStreamCancel(signal.reason);
else signal?.addEventListener("abort", handleParentAbort, { once: true });
return new ReadableStream(
{
async start(controller) {
let roleSent = false;
let firstOutputHandedOff = false;
try {
// Initial role chunk
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null },
],
})
)
);
let fp = "";
let buffered = "";
const enqueueRole = () => {
if (roleSent) return;
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [
{
index: 0,
delta: { role: "assistant" },
finish_reason: null,
logprobs: null,
},
],
})
)
);
roleSent = true;
};
const handOffFirstOutput = async () => {
if (firstOutputHandedOff) return;
firstOutputHandedOff = true;
// Give readiness/finalization wrappers one turn to attach before a later
// upstream failure errors the stream and invalidates queued chunks.
await new Promise<void>((resolve) => setImmediate(resolve));
};
for await (const chunk of extractContent(
eventStream,
isThinkingModel,
toolRegistry,
signal,
streamAbortController.signal,
true
)) {
if (chunk.fingerprint) fp = chunk.fingerprint;
if (chunk.error) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [
{
index: 0,
delta: { content: `[Error: ${chunk.error}]` },
finish_reason: null,
logprobs: null,
},
],
})
)
);
break;
if (roleSent) {
controller.error(grokStreamFailure());
return;
}
controller.enqueue(encoder.encode(grokStreamErrorChunk()));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
return;
}
if (chunk.thinking) {
enqueueRole();
controller.enqueue(
encoder.encode(
sseChunk({
@@ -425,10 +484,12 @@ function buildStreamingResponse(
})
)
);
await handOffFirstOutput();
continue;
}
if (chunk.toolCalls) {
enqueueRole();
enqueueStreamingToolCalls(controller, encoder, {
id: cid,
created,
@@ -444,6 +505,7 @@ function buildStreamingResponse(
if (chunk.fullMessage) {
const toolCalls = parseClientToolCallMarkup(chunk.fullMessage, toolRegistry);
if (toolCalls) {
enqueueRole();
enqueueStreamingToolCalls(controller, encoder, {
id: cid,
created,
@@ -453,6 +515,30 @@ function buildStreamingResponse(
});
return;
}
if (!buffered) {
enqueueRole();
buffered = chunk.fullMessage;
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: fp || null,
choices: [
{
index: 0,
delta: { content: chunk.fullMessage },
finish_reason: null,
logprobs: null,
},
],
})
)
);
await handOffFirstOutput();
}
}
if (chunk.delta) {
@@ -469,6 +555,7 @@ function buildStreamingResponse(
return;
}
if (hasOpenToolCallMarkup(buffered)) continue;
enqueueRole();
controller.enqueue(
encoder.encode(
sseChunk({
@@ -488,10 +575,13 @@ function buildStreamingResponse(
})
)
);
await handOffFirstOutput();
}
}
// Stop chunk
if (streamAbortController.signal.aborted || !roleSent) return;
// Stop chunk — only after legitimate content/reasoning/tool output.
controller.enqueue(
encoder.encode(
sseChunk({
@@ -505,37 +595,24 @@ function buildStreamingResponse(
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (err) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: {
content: sanitizeErrorMessage(
`[Stream error: ${err instanceof Error ? err.message : String(err)}]`
),
},
finish_reason: "stop",
logprobs: null,
},
],
})
)
);
} catch {
if (streamAbortController.signal.aborted) return;
if (roleSent) {
controller.error(grokStreamFailure());
return;
}
controller.enqueue(encoder.encode(grokStreamErrorChunk()));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} finally {
signal?.removeEventListener("abort", handleParentAbort);
try {
controller.close();
} catch {}
}
},
cancel(reason) {
requestStreamCancel(reason);
},
},
{ highWaterMark: 16384 }
);
@@ -1026,6 +1103,13 @@ export class GrokWebExecutor extends BaseExecutor {
"X-Accel-Buffering": "no",
},
});
const readiness = await ensureStreamReadiness(finalResponse, {
timeoutMs: STREAM_READINESS_TIMEOUT_MS,
provider: this.provider,
model,
log,
});
finalResponse = readiness.response;
} else {
finalResponse = await buildNonStreamingResponse(
tlsResult.body,

View File

@@ -27,7 +27,11 @@ import {
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
import { streamJsonlToOpenAi, readJsonlResponse } from "./huggingchat/jsonlStream.ts";
import {
HuggingChatStreamError,
readJsonlResponse,
streamJsonlToOpenAi,
} from "./huggingchat/jsonlStream.ts";
const HUGGINGFACE_BASE = "https://huggingface.co";
const CONVERSATION_URL = `${HUGGINGFACE_BASE}/chat/conversation`;
@@ -38,6 +42,7 @@ const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const DEFAULT_MODEL = "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT";
const HUGGINGCHAT_PUBLIC_STREAM_ERROR = "HuggingChat generation failed";
// -- Helpers -----------------------------------------------------------------
@@ -400,13 +405,15 @@ export class HuggingChatExecutor extends BaseExecutor {
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
log?.error?.("HUGGINGCHAT", `Conversation creation failed: ${message}`);
return {
response: new Response(
JSON.stringify({
error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" },
}),
JSON.stringify(
buildErrorBody(502, `HuggingChat connection failed: ${message}`, undefined, {
type: "upstream_error",
})
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: CONVERSATION_URL,
@@ -463,13 +470,15 @@ export class HuggingChatExecutor extends BaseExecutor {
signal: combinedSignal,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
log?.error?.("HUGGINGCHAT", `Message send failed: ${message}`);
return {
response: new Response(
JSON.stringify({
error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" },
}),
JSON.stringify(
buildErrorBody(502, `HuggingChat connection failed: ${message}`, undefined, {
type: "upstream_error",
})
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: messageUrl,
@@ -523,25 +532,80 @@ export class HuggingChatExecutor extends BaseExecutor {
if (stream) {
const encoder = new TextEncoder();
const streamCancellationController = new AbortController();
const jsonlStream = streamJsonlToOpenAi(
upstreamResponse.body,
resolvedModel,
id,
created,
signal
signal,
streamCancellationController.signal
);
const sseStream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of jsonlStream) {
controller.enqueue(encoder.encode(chunk));
}
} catch (err) {
log?.error?.("HUGGINGCHAT", `Stream error: ${err}`);
} finally {
controller.close();
const primedChunks: string[] = [];
try {
const first = await jsonlStream.next();
if (!first.done) {
primedChunks.push(first.value);
if (first.value.includes('"role":"assistant"')) {
const content = await jsonlStream.next();
if (!content.done) primedChunks.push(content.value);
}
}
} catch (err) {
if (!(err instanceof HuggingChatStreamError)) throw err;
const message = err instanceof Error ? err.message : String(err);
const safeMessage = sanitizeErrorMessage(message);
log?.error?.("HUGGINGCHAT", `Stream failed before content: ${safeMessage}`);
return {
response: new Response(
JSON.stringify(
buildErrorBody(502, message, undefined, {
type: "upstream_error",
code: "huggingchat_generation_error",
})
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: messageUrl,
headers: baseHeaders,
transformedBody: sendDataPayload,
};
}
let primedChunkIndex = 0;
let streamCancelled = false;
const sseStream = new ReadableStream<Uint8Array>({
async pull(controller) {
if (streamCancelled) return;
if (primedChunkIndex < primedChunks.length) {
controller.enqueue(encoder.encode(primedChunks[primedChunkIndex]));
primedChunkIndex += 1;
return;
}
try {
const chunk = await jsonlStream.next();
if (streamCancelled) return;
if (chunk.done) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunk.value));
} catch (err) {
if (streamCancelled) return;
const message = err instanceof Error ? err.message : String(err);
const safeMessage = sanitizeErrorMessage(message);
log?.error?.("HUGGINGCHAT", `Stream error: ${safeMessage}`);
controller.error(
Object.assign(new Error(HUGGINGCHAT_PUBLIC_STREAM_ERROR), { statusCode: 502 })
);
}
},
cancel() {
streamCancelled = true;
streamCancellationController.abort();
void jsonlStream.return(undefined).catch(() => undefined);
},
});
@@ -560,7 +624,29 @@ export class HuggingChatExecutor extends BaseExecutor {
};
}
const fullText = await readJsonlResponse(upstreamResponse.body, signal);
let fullText: string;
try {
fullText = await readJsonlResponse(upstreamResponse.body, signal);
} catch (err) {
if (!(err instanceof HuggingChatStreamError)) throw err;
const message = err instanceof Error ? err.message : String(err);
const safeMessage = sanitizeErrorMessage(message);
log?.error?.("HUGGINGCHAT", `Generation error: ${safeMessage}`);
return {
response: new Response(
JSON.stringify(
buildErrorBody(502, message, undefined, {
type: "upstream_error",
code: "huggingchat_generation_error",
})
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: messageUrl,
headers: baseHeaders,
transformedBody: sendDataPayload,
};
}
const completionTokens = estimateTokens(fullText);
return {

View File

@@ -1,5 +1,36 @@
// Pure JSONL stream translation (HuggingChat NDJSON -> OpenAI SSE). Verbatim from huggingchat.ts.
export class HuggingChatStreamError extends Error {
constructor(message: string) {
super(message);
this.name = "HuggingChatStreamError";
}
}
function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
try {
void reader.cancel().catch(() => undefined);
} catch {
// The error event is authoritative; transport cleanup is best effort.
}
}
function bindReaderCancellation(
reader: ReadableStreamDefaultReader<Uint8Array>,
signal?: AbortSignal | null
): () => void {
if (!signal) return () => undefined;
const cancel = () => cancelReader(reader);
if (signal.aborted) {
cancel();
return () => undefined;
}
signal.addEventListener("abort", cancel, { once: true });
return () => signal.removeEventListener("abort", cancel);
}
export function sseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
@@ -42,9 +73,11 @@ export async function* streamJsonlToOpenAi(
model: string,
id: string,
created: number,
signal?: AbortSignal | null
signal?: AbortSignal | null,
cancellationSignal?: AbortSignal | null
): AsyncGenerator<string> {
const reader = body.getReader();
const unbindReaderCancellation = bindReaderCancellation(reader, cancellationSignal);
const decoder = new TextDecoder();
let buffer = "";
let emittedRole = false;
@@ -70,16 +103,8 @@ export async function* streamJsonlToOpenAi(
const parsed = parseJsonlLine(trimmed);
if (parsed.error) {
yield sseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
yield "data: [DONE]\n\n";
finished = true;
return;
cancelReader(reader);
throw new HuggingChatStreamError(parsed.error);
}
if (parsed.token) {
@@ -140,6 +165,9 @@ export async function* streamJsonlToOpenAi(
if (!finished && buffer.trim()) {
const parsed = parseJsonlLine(buffer.trim());
if (parsed.error) {
throw new HuggingChatStreamError(parsed.error);
}
if (parsed.token && !signal?.aborted) {
if (!emittedRole) {
emittedRole = true;
@@ -161,10 +189,11 @@ export async function* streamJsonlToOpenAi(
}
}
} finally {
unbindReaderCancellation();
reader.releaseLock();
}
if (!signal?.aborted) {
if (!signal?.aborted && !cancellationSignal?.aborted) {
yield sseChunk({
id,
object: "chat.completion.chunk",
@@ -172,7 +201,9 @@ export async function* streamJsonlToOpenAi(
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
yield "data: [DONE]\n\n";
if (!signal?.aborted && !cancellationSignal?.aborted) {
yield "data: [DONE]\n\n";
}
}
}
@@ -204,7 +235,10 @@ export async function readJsonlResponse(
const parsed = parseJsonlLine(trimmed);
if (parsed.token) fullText += parsed.token;
if (parsed.text) return parsed.text;
if (parsed.error) throw new Error(parsed.error);
if (parsed.error) {
cancelReader(reader);
throw new HuggingChatStreamError(parsed.error);
}
}
}
@@ -212,6 +246,7 @@ export async function readJsonlResponse(
const parsed = parseJsonlLine(buffer.trim());
if (parsed.text) return parsed.text;
if (parsed.token) fullText += parsed.token;
if (parsed.error) throw new HuggingChatStreamError(parsed.error);
}
} finally {
reader.releaseLock();

View File

@@ -72,8 +72,7 @@ export class NineRouterExecutor extends BaseExecutor {
* Message goes through buildErrorBody to satisfy hard rule #12 (no raw err.message).
*/
private buildServiceUnavailableResponse(message: string): Response {
const body = buildErrorBody(503, message);
body.error.code = "service_not_running";
const body = buildErrorBody(503, message, undefined, { code: "service_not_running" });
return new Response(JSON.stringify(body), {
status: 503,
headers: {

View File

@@ -16,6 +16,8 @@ type OpenAIMessage = {
};
const CHAT_URL = "https://api.1min.ai/api/chat-with-ai";
const MAX_STREAM_ERROR_DATA_CHARS = 64 * 1024;
const STREAM_ERROR_FALLBACK = "1min.ai upstream stream failed";
const ROLE_LABELS: Record<string, string> = {
system: "System",
developer: "System",
@@ -69,7 +71,35 @@ function buildSseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
function buildOpenAiJsonCompletion(content: string, model: string, id: string, created: number): Response {
function parseStreamErrorMessage(data: string): string {
if (!data || data.length > MAX_STREAM_ERROR_DATA_CHARS) return STREAM_ERROR_FALLBACK;
try {
const parsed = asRecord(JSON.parse(data));
const directMessage = typeof parsed.message === "string" ? parsed.message.trim() : "";
if (directMessage) return directMessage;
if (typeof parsed.error === "string") {
const errorMessage = parsed.error.trim();
if (errorMessage) return errorMessage;
}
const nestedError = asRecord(parsed.error);
const nestedMessage = typeof nestedError.message === "string" ? nestedError.message.trim() : "";
if (nestedMessage) return nestedMessage;
} catch {
// Malformed and over-complex payloads use the fixed public fallback below.
}
return STREAM_ERROR_FALLBACK;
}
function buildOpenAiJsonCompletion(
content: string,
model: string,
id: string,
created: number
): Response {
return new Response(
JSON.stringify({
id,
@@ -84,7 +114,11 @@ function buildOpenAiJsonCompletion(content: string, model: string, id: string, c
);
}
function toOpenAiErrorResponse(status: number, message: string, upstreamDetails?: unknown): Response {
function toOpenAiErrorResponse(
status: number,
message: string,
upstreamDetails?: unknown
): Response {
return new Response(JSON.stringify(buildErrorBody(status, message, upstreamDetails)), {
status,
headers: { "Content-Type": "application/json" },
@@ -96,109 +130,214 @@ function toOpenAiErrorResponse(status: number, message: string, upstreamDetails?
* data: {...}) from the upstream Response body and re-emit them as standard
* OpenAI chat.completion.chunk SSE.
*/
function translateSseStream(upstreamBody: ReadableStream<Uint8Array>, model: string, id: string, created: number): ReadableStream<Uint8Array> {
function translateSseStream(
upstreamBody: ReadableStream<Uint8Array>,
model: string,
id: string,
created: number
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const reader = upstreamBody.getReader();
const pendingChunks: Uint8Array[] = [];
let buffer = "";
let finished = false;
let roleEmitted = false;
let terminalError: Error | null = null;
let upstreamCancelRequested = false;
let downstreamCancelled = false;
let readInFlight = false;
let readerReleased = false;
const releaseReader = () => {
if (readerReleased) return;
readerReleased = true;
reader.releaseLock();
};
const cancelUpstream = (reason: unknown) => {
if (upstreamCancelRequested) return;
upstreamCancelRequested = true;
try {
// Upstream cleanup is provider-controlled and may never settle. The
// translated stream owns the reader lock and releases it independently.
void reader.cancel(reason).catch(() => {});
} catch {
// Cancellation is cleanup-only; the terminal state is already fixed.
}
};
const queueChunk = (text: string) => {
pendingChunks.push(encoder.encode(text));
};
const emitRole = () => {
if (roleEmitted) return;
roleEmitted = true;
queueChunk(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
);
};
const finish = () => {
if (finished) return;
finished = true;
queueChunk(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})
);
queueChunk("data: [DONE]\n\n");
};
const emitContent = (text: string) => {
if (!text) return;
emitRole();
queueChunk(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
})
);
};
const emitError = (data: string) => {
if (finished) return;
finished = true;
cancelUpstream("1min.ai upstream stream error");
if (!roleEmitted) {
const message = parseStreamErrorMessage(data);
queueChunk(buildSseChunk(buildErrorBody(502, message)));
queueChunk("data: [DONE]\n\n");
return;
}
// A bare `{ error }` frame is dropped by the OpenAI passthrough sanitizer.
// Preserve every content delta already queued, then error the source with
// a fixed public message. pipeWithDisconnect() converts it into a native
// terminal error frame and drives usage, call-log, and fallback finalizers.
terminalError = Object.assign(new Error(STREAM_ERROR_FALLBACK), {
statusCode: 502,
});
};
// SSE event framing: "event:"/"data:" lines, blank-line separated records.
const processEvent = (eventText: string) => {
let eventType = "message";
const dataLines: string[] = [];
for (const rawLine of eventText.split("\n")) {
if (rawLine.startsWith("event:")) {
eventType = rawLine.slice(6).trim();
} else if (rawLine.startsWith("data:")) {
dataLines.push(rawLine.slice(5).trim());
}
}
const data = dataLines.join("\n");
if (eventType === "content") {
try {
const parsed = asRecord(JSON.parse(data));
if (typeof parsed.content === "string") emitContent(parsed.content);
} catch {
// Ignore malformed content events rather than surfacing partial JSON.
}
} else if (eventType === "error") {
emitError(data);
} else if (eventType === "done") {
finish();
}
// "result" carries the final full aiRecord, redundant with the content
// events already streamed — intentionally ignored.
};
const processBufferedEvents = () => {
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1 && !finished) {
processEvent(buffer.slice(0, separatorIndex));
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf("\n\n");
}
};
return new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
)
);
async pull(controller) {
if (downstreamCancelled) return;
const reader = upstreamBody.getReader();
let buffer = "";
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
const emitContent = (text: string) => {
if (!text) return;
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
})
)
);
};
// SSE event framing: "event:"/"data:" lines, blank-line separated records.
const processEvent = (eventText: string) => {
let eventType = "message";
const dataLines: string[] = [];
for (const rawLine of eventText.split("\n")) {
if (rawLine.startsWith("event:")) {
eventType = rawLine.slice(6).trim();
} else if (rawLine.startsWith("data:")) {
dataLines.push(rawLine.slice(5).trim());
}
}
const data = dataLines.join("\n");
if (eventType === "content") {
try {
const parsed = asRecord(JSON.parse(data));
if (typeof parsed.content === "string") emitContent(parsed.content);
} catch {
// Ignore malformed content events rather than surfacing partial JSON.
}
} else if (eventType === "error") {
emitContent(`\n[1min.ai error: ${data}]`);
finish();
} else if (eventType === "done") {
finish();
}
// "result" carries the final full aiRecord, redundant with the content
// events already streamed — intentionally ignored.
};
try {
while (!finished) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1) {
processEvent(buffer.slice(0, separatorIndex));
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf("\n\n");
}
}
if (!finished && buffer.trim()) processEvent(buffer);
finish();
} catch (error) {
controller.error(error);
} finally {
reader.releaseLock();
if (pendingChunks.length > 0) {
controller.enqueue(pendingChunks.shift()!);
return;
}
if (terminalError) {
releaseReader();
controller.error(terminalError);
return;
}
if (finished) {
releaseReader();
controller.close();
return;
}
readInFlight = true;
try {
while (pendingChunks.length === 0 && !finished && !downstreamCancelled) {
const { done, value } = await reader.read();
if (downstreamCancelled) return;
if (done) {
buffer += decoder.decode();
if (buffer.trim()) processEvent(buffer);
finish();
break;
}
buffer += decoder.decode(value, { stream: true });
// Process the complete upstream chunk, even after it queues output.
// One network read may contain multiple content events followed by
// an error; the internal queue preserves all of them in order.
processBufferedEvents();
}
if (downstreamCancelled) return;
if (pendingChunks.length > 0) {
controller.enqueue(pendingChunks.shift()!);
} else if (terminalError) {
releaseReader();
controller.error(terminalError);
} else if (finished) {
releaseReader();
controller.close();
}
} catch (error) {
releaseReader();
if (!downstreamCancelled) controller.error(error);
} finally {
readInFlight = false;
if (downstreamCancelled) releaseReader();
}
},
cancel(reason) {
downstreamCancelled = true;
pendingChunks.length = 0;
// A client disconnect must release the upstream reader even when its
// next pull never settles. Do not await provider cleanup here: the
// downstream cancellation contract must remain bounded.
cancelUpstream(reason ?? "1min.ai downstream cancelled");
if (!readInFlight) releaseReader();
},
});
}
@@ -290,7 +429,9 @@ export class OneMinAiExecutor extends BaseExecutor {
const aiRecord = asRecord(json.aiRecord);
const detail = asRecord(aiRecord.aiRecordDetail);
const resultObject = Array.isArray(detail.resultObject) ? detail.resultObject : [];
const content = resultObject.filter((part): part is string => typeof part === "string").join("");
const content = resultObject
.filter((part): part is string => typeof part === "string")
.join("");
return {
response: buildOpenAiJsonCompletion(content, model, id, created),

View File

@@ -17,6 +17,7 @@ import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { buildSessionCookieHeader, mergeRefreshedCookie } from "../utils/nextAuthCookie.ts";
import { formatTranslatedStreamError } from "../utils/streamErrorFormat.ts";
import {
PPLX_SSE_ENDPOINT,
PPLX_USER_AGENT,
@@ -35,6 +36,8 @@ import {
const SESSION_MAX_AGE_MS = 3600_000;
const SESSION_MAX_ENTRIES = 200;
const PPLX_STREAM_ERROR_MESSAGE = "Perplexity upstream stream failed";
const PPLX_STREAM_ERROR_CODE = "PPLX_STREAM_ERROR";
interface SessionEntry {
backendUuid: string;
@@ -102,155 +105,223 @@ function buildStreamingResponse(
signal?: AbortSignal | null
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const streamAbortController = new AbortController();
const forwardInputAbort = () =>
streamAbortController.abort(signal?.reason ?? "perplexity_request_aborted");
if (signal?.aborted) forwardInputAbort();
else signal?.addEventListener("abort", forwardInputAbort, { once: true });
let inputAbortListenerAttached = Boolean(signal && !signal.aborted);
const removeInputAbortListener = () => {
if (!inputAbortListenerAttached) return;
inputAbortListenerAttached = false;
signal?.removeEventListener("abort", forwardInputAbort);
};
const abortEventStream = (reason: unknown) => {
removeInputAbortListener();
if (!streamAbortController.signal.aborted) streamAbortController.abort(reason);
};
const contentIterator = extractContent(eventStream, streamAbortController.signal)[
Symbol.asyncIterator
]();
let fullAnswer = "";
let respBackendUuid: string | null = null;
let roleEmitted = false;
let finished = false;
let pendingFailure: (Error & { statusCode: number }) | null = null;
return new ReadableStream(
{
async start(controller) {
try {
// Initial role chunk
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null },
],
})
)
);
const enqueuePreContentFailure = (controller: ReadableStreamDefaultController<Uint8Array>) => {
controller.enqueue(
encoder.encode(
formatTranslatedStreamError({
status: 502,
message: PPLX_STREAM_ERROR_MESSAGE,
type: "upstream_error",
code: PPLX_STREAM_ERROR_CODE,
})
)
);
};
let fullAnswer = "";
let respBackendUuid: string | null = null;
const takeAssistantRoleChunk = (): string => {
if (roleEmitted) return "";
roleEmitted = true;
return sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { role: "assistant" },
finish_reason: null,
logprobs: null,
},
],
});
};
for await (const chunk of extractContent(eventStream, signal)) {
if (chunk.backendUuid) respBackendUuid = chunk.backendUuid;
const completeStream = (controller: ReadableStreamDefaultController<Uint8Array>) => {
if (finished) return;
finished = true;
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid);
removeInputAbortListener();
controller.close();
};
if (chunk.error) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { content: `[Error: ${chunk.error}]` },
finish_reason: null,
logprobs: null,
},
],
})
)
);
break;
}
const failStream = (controller: ReadableStreamDefaultController<Uint8Array>) => {
if (roleEmitted) {
pendingFailure = Object.assign(new Error(PPLX_STREAM_ERROR_MESSAGE), {
statusCode: 502,
});
finished = true;
controller.close();
return;
}
finished = true;
enqueuePreContentFailure(controller);
controller.close();
};
if (chunk.thinking) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { reasoning_content: chunk.thinking + "\n" },
finish_reason: null,
logprobs: null,
},
],
})
)
);
continue;
}
const providerStream = new ReadableStream<Uint8Array>({
async pull(controller) {
if (finished) return;
if (chunk.done) {
fullAnswer = chunk.answer || fullAnswer;
break;
}
let dt = chunk.delta || "";
if (dt) {
dt = cleanResponse(dt, false);
if (dt) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{ index: 0, delta: { content: dt }, finish_reason: null, logprobs: null },
],
})
)
);
}
}
if (chunk.answer) fullAnswer = chunk.answer;
}
// Stop chunk
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid);
} catch (err) {
controller.enqueue(
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: {
content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`,
},
finish_reason: "stop",
logprobs: null,
},
],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} finally {
try {
controller.close();
} catch {}
try {
const next = await contentIterator.next();
if (streamAbortController.signal.aborted) {
finished = true;
controller.close();
return;
}
},
if (next.done === true) {
completeStream(controller);
return;
}
const chunk = next.value;
if (chunk.backendUuid) respBackendUuid = chunk.backendUuid;
if (chunk.error) {
failStream(controller);
removeInputAbortListener();
void contentIterator.return?.(undefined).catch(() => undefined);
return;
}
if (chunk.thinking) {
controller.enqueue(
encoder.encode(
takeAssistantRoleChunk() +
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { reasoning_content: chunk.thinking + "\n" },
finish_reason: null,
logprobs: null,
},
],
})
)
);
return;
}
if (chunk.done) {
fullAnswer = chunk.answer || fullAnswer;
completeStream(controller);
await contentIterator.return?.(undefined);
return;
}
let dt = chunk.delta || "";
if (dt) {
dt = cleanResponse(dt, false);
if (dt) {
controller.enqueue(
encoder.encode(
takeAssistantRoleChunk() +
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{ index: 0, delta: { content: dt }, finish_reason: null, logprobs: null },
],
})
)
);
}
}
if (chunk.answer) fullAnswer = chunk.answer;
} catch {
failStream(controller);
removeInputAbortListener();
void contentIterator.return?.(undefined).catch(() => undefined);
}
},
{ highWaterMark: 16384 }
);
cancel(reason) {
finished = true;
abortEventStream(reason);
void contentIterator.return?.(undefined).catch(() => undefined);
},
});
// Erroring the provider stream immediately would discard output buffered by the readiness
// handoff. Drain each provider chunk through a backpressure-aware reader, then reject only the
// read after the last legitimate chunk. The outer pipeline converts that fixed public error to
// the client's canonical terminal frame and records the stream failure.
const providerReader = providerStream.getReader();
let cancelled = false;
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await providerReader.read();
if (cancelled) return;
if (next.done === false) {
controller.enqueue(next.value);
return;
}
if (pendingFailure) {
controller.error(pendingFailure);
return;
}
controller.close();
} catch (error) {
if (!cancelled) controller.error(error);
}
},
cancel(reason) {
if (cancelled) return;
cancelled = true;
abortEventStream(reason);
void providerReader.cancel(reason).catch(() => undefined);
},
});
}
async function buildNonStreamingResponse(

View File

@@ -213,6 +213,19 @@ export async function* readPplxSseEvents(
const decoder = new TextDecoder();
let buffer = "";
let dataLines: string[] = [];
let readerFinished = false;
let readerCancelRequested = false;
const cancelReader = (reason: unknown) => {
if (readerFinished || readerCancelRequested) return;
readerCancelRequested = true;
// Cancellation is a client-facing latency boundary. Request upstream cleanup once, but never
// await a hostile underlying source whose cancel hook does not settle.
void reader.cancel(reason).catch(() => undefined);
};
const handleAbort = () => cancelReader(signal?.reason ?? "perplexity_stream_aborted");
if (signal?.aborted) handleAbort();
else signal?.addEventListener("abort", handleAbort, { once: true });
function flush(): PplxStreamEvent | null | "done" {
if (dataLines.length === 0) return null;
@@ -231,7 +244,10 @@ export async function* readPplxSseEvents(
while (true) {
if (signal?.aborted) return;
const { value, done } = await reader.read();
if (done) break;
if (done) {
readerFinished = true;
break;
}
buffer += decoder.decode(value, { stream: true });
while (true) {
@@ -263,7 +279,13 @@ export async function* readPplxSseEvents(
const tail = flush();
if (tail && tail !== "done") yield tail;
} finally {
reader.releaseLock();
signal?.removeEventListener("abort", handleAbort);
cancelReader(signal?.reason ?? "perplexity_stream_reader_closed");
try {
reader.releaseLock();
} catch {
// A hostile source may keep its cancel promise pending; the lock can be released later by GC.
}
}
}
@@ -915,6 +937,10 @@ export async function* extractContent(
}
}
// Cancellation is not a successful terminal event. In particular, do not synthesize the final
// `done` chunk: streaming callers use that signal to emit stop/[DONE] and persist the session.
if (signal?.aborted) return;
// End-of-stream without a COMPLETED frame still try the last text blob.
if (!fullAnswer.trim() && lastEventText) {
const fromText = extractAnswerFromFinalText(lastEventText);

View File

@@ -1,4 +1,4 @@
import { sanitizeErrorMessage } from "../../utils/error.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../../utils/error.ts";
export interface ZaiDelta {
content: string;
@@ -113,22 +113,73 @@ function parseSsePayload(data: string): ZaiDelta | null {
}
}
type ZaiDeltaSource = {
deltas: AsyncGenerator<ZaiDelta, void, void>;
cancel: (reason?: unknown) => void;
};
function createZaiDeltaSource(sourceBody: ReadableStream<Uint8Array>): ZaiDeltaSource {
const decoder = new TextDecoder();
const reader = sourceBody.getReader();
const buffer = { text: "" };
let upstreamDone = false;
let cancelRequested = false;
let readerReleased = false;
const releaseReader = () => {
if (readerReleased) return;
readerReleased = true;
try {
reader.releaseLock();
} catch {
// A concurrent read cancellation owns the final release.
}
};
const cancel = (reason?: unknown) => {
if (upstreamDone || cancelRequested) return;
cancelRequested = true;
try {
// Do not await an upstream cancel hook: a stalled provider is allowed to
// ignore cancellation, but it must never keep the client cancellation open.
void reader.cancel(reason).catch(() => {});
} catch {
// The reader may already have closed or released concurrently.
}
};
async function* iterate(): AsyncGenerator<ZaiDelta, void, void> {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
upstreamDone = true;
return;
}
const payloads = extractSseDataPayloads(buffer, decoder.decode(value, { stream: true }));
for (const raw of payloads) {
const delta = parseSsePayload(raw);
if (delta) yield delta;
}
}
} finally {
if (!upstreamDone && !cancelRequested) cancel("Z.ai delta iteration ended");
releaseReader();
}
}
return { deltas: iterate(), cancel };
}
async function drainSseDeltas(
sourceBody: ReadableStream<Uint8Array>,
onDelta: (delta: ZaiDelta) => boolean
): Promise<boolean> {
const decoder = new TextDecoder();
const reader = sourceBody.getReader();
const buffer = { text: "" };
while (true) {
const { done, value } = await reader.read();
if (done) return false;
const payloads = extractSseDataPayloads(buffer, decoder.decode(value, { stream: true }));
for (const raw of payloads) {
const delta = parseSsePayload(raw);
if (delta && onDelta(delta)) return true;
}
const { deltas } = createZaiDeltaSource(sourceBody);
for await (const delta of deltas) {
if (onDelta(delta)) return true;
}
return false;
}
function emitDeltaChunks(
@@ -137,17 +188,32 @@ function emitDeltaChunks(
emitChunk: ZaiChunkEmitter,
roleState: { emitted: boolean }
): boolean {
if (!roleState.emitted && (delta.content || delta.reasoning || delta.error)) {
if (delta.error) {
const errorBody = buildErrorBody(502, `Z.ai stream failed: ${delta.error}`, undefined, {
type: "upstream_error",
code: "zai_stream_error",
});
if (!roleState.emitted) {
// Keep a pre-content failure as an error-only Chat frame. Stream readiness
// rejects it before response headers are committed, so fallback receives a 502.
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(errorBody)}\n\n`));
controller.close();
} else {
// Once content is public, error the protocol-neutral producer. The shared
// pipeline preserves prior chunks, records the failure, and emits the terminal
// error in the client's native Chat, Claude, or Responses wire format.
controller.error(Object.assign(new Error(errorBody.error.message), { statusCode: 502 }));
}
return true;
}
if (!roleState.emitted && (delta.content || delta.reasoning)) {
roleState.emitted = true;
emitChunk(controller, { role: "assistant", content: "" });
}
if (delta.reasoning) emitChunk(controller, { reasoning_content: delta.reasoning });
if (delta.content) emitChunk(controller, { content: delta.content });
// Surfaced as visible content, matching the other web executors' mid-stream
// error convention (see zed-hosted's createErrorChunk): the 200 is already on
// the wire, so the status cannot change — but the caller must not be left
// reading an empty success. Any content streamed before the failure is kept.
if (delta.error) emitChunk(controller, { content: `[Z.ai error] ${delta.error}` });
if (delta.done) {
emitChunk(controller, {}, "stop");
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
@@ -162,19 +228,32 @@ export function buildZaiStreamingBody(
emitChunk: ZaiChunkEmitter,
signal: AbortSignal | null | undefined
): ReadableStream {
const deltaSource = createZaiDeltaSource(sourceBody);
const { deltas } = deltaSource;
const roleState = { emitted: false };
let terminated = false;
return new ReadableStream({
async start(controller) {
const roleState = { emitted: false };
async pull(controller) {
if (terminated) return;
try {
const ended = await drainSseDeltas(sourceBody, (delta) =>
emitDeltaChunks(controller, delta, emitChunk, roleState)
);
if (ended) return;
const next = await deltas.next();
if (terminated) return;
if (next.done === false) {
if (emitDeltaChunks(controller, next.value, emitChunk, roleState)) {
terminated = true;
await deltas.return(undefined);
}
return;
}
terminated = true;
if (!roleState.emitted) emitChunk(controller, { role: "assistant", content: "" });
emitChunk(controller, {}, "stop");
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
} catch (error) {
terminated = true;
if (!signal?.aborted) {
try {
controller.error(error);
@@ -184,6 +263,11 @@ export function buildZaiStreamingBody(
}
}
},
cancel(reason) {
terminated = true;
deltaSource.cancel(reason);
void deltas.return(undefined).catch(() => {});
},
});
}

View File

@@ -44,6 +44,8 @@ import {
zedLlmFetch,
type ZedCredentials,
} from "../shared/zedAuth.ts";
import { buildErrorBody } from "../utils/error.ts";
import { hasUsefulStreamContent } from "../utils/streamReadiness.ts";
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
// Wire values for the `provider` field of POST /completions. These are NOT
@@ -122,37 +124,72 @@ function convertProviderEvent(
return event;
}
function createErrorChunk(model: string, message: string): Record<string, unknown> {
return {
id: `chatcmpl-zed-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" }],
};
const MAX_ZED_FAILURE_MESSAGE_LENGTH = 512;
const MAX_PENDING_ZED_OUTPUT_LENGTH = 64 * 1024;
const ZED_STREAM_FAILURE_PUBLIC_MESSAGE = "Zed upstream stream failed";
function boundedFailureText(value: unknown): string | null {
if (typeof value !== "string" && typeof value !== "number") return null;
const text = String(value).trim();
return text ? text.slice(0, MAX_ZED_FAILURE_MESSAGE_LENGTH) : null;
}
function extractZedFailureMessage(failed: Record<string, unknown>): string {
const nestedError =
failed.error && typeof failed.error === "object" && !Array.isArray(failed.error)
? (failed.error as Record<string, unknown>)
: null;
const candidates = [
failed.message,
nestedError?.message,
typeof failed.error === "object" ? undefined : failed.error,
failed.code,
nestedError?.code,
];
for (const candidate of candidates) {
const text = boundedFailureText(candidate);
if (text) return text;
}
return "request failed";
}
function createErrorChunk(message: string): ReturnType<typeof buildErrorBody> {
return buildErrorBody(502, `Zed stream failed: ${message}`, undefined, {
type: "upstream_error",
code: "ZED_STREAM_FAILED",
});
}
/**
* The single controller capability these SSE helpers use. They only ever enqueue
* never `close()`, never read `desiredSize` — so typing them by that one method lets
* the same code serve both stream kinds. The wider
* The controller capabilities these SSE helpers use. Normal frames only enqueue;
* terminal failures also terminate so they do not depend on the upstream socket
* eventually reaching EOF. Narrow controller types keep the helpers honest. The wider
* `ReadableStreamDefaultController` annotation rejected every call site, because the
* helpers are driven from a TransformStream and `TransformStreamDefaultController`
* has no `close()`.
*/
type SseEnqueueTarget = Pick<ReadableStreamDefaultController<Uint8Array>, "enqueue">;
type SseProcessTarget = Pick<TransformStreamDefaultController<Uint8Array>, "enqueue" | "terminate">;
function serializeSseObject(chunk: unknown): string {
if (!chunk) return "";
let serialized = "";
const items = Array.isArray(chunk) ? chunk : [chunk];
for (const item of items) {
if (!item) continue;
serialized += `data: ${JSON.stringify(item)}\n\n`;
}
return serialized;
}
function enqueueSseObject(
controller: SseEnqueueTarget,
encoder: TextEncoder,
chunk: unknown
): void {
if (!chunk) return;
const items = Array.isArray(chunk) ? chunk : [chunk];
for (const item of items) {
if (!item) continue;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`));
}
const serialized = serializeSseObject(chunk);
if (!serialized) return;
controller.enqueue(encoder.encode(serialized));
}
type ZedLine = { done?: true; status?: unknown; event?: unknown } | null;
@@ -226,16 +263,47 @@ function wrapZedCompletionStream(
}
let buffer = "";
let done = false;
let providerOutputForwarded = false;
let pendingProviderOutput = "";
let pendingFailure: (Error & { statusCode: number }) | null = null;
const forwardProviderOutput = (controller: SseEnqueueTarget, chunk: unknown) => {
const serialized = serializeSseObject(chunk);
if (!serialized) return;
if (providerOutputForwarded) {
controller.enqueue(encoder.encode(serialized));
return;
}
// A role/bootstrap-only chunk makes ensureStreamReadiness release the response before any
// model output exists. If the next chunk is status.failed, downstream read-ahead can discard
// the first real content while propagating the error. Hold structural frames until the first
// substantive text/reasoning/tool delta, then release them atomically with that output.
const outputWithBootstrap = pendingProviderOutput + serialized;
if (!hasUsefulStreamContent(outputWithBootstrap)) {
pendingProviderOutput =
outputWithBootstrap.length <= MAX_PENDING_ZED_OUTPUT_LENGTH
? outputWithBootstrap
: serialized.length <= MAX_PENDING_ZED_OUTPUT_LENGTH
? serialized
: "";
return;
}
controller.enqueue(encoder.encode(outputWithBootstrap));
pendingProviderOutput = "";
providerOutputForwarded = true;
};
const finish = (controller: SseEnqueueTarget) => {
if (done) return;
const finalChunk = convertProviderEvent(provider, null, state);
enqueueSseObject(controller, encoder, finalChunk);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
const finalOutput = `${pendingProviderOutput}${serializeSseObject(finalChunk)}data: [DONE]\n\n`;
pendingProviderOutput = "";
controller.enqueue(encoder.encode(finalOutput));
done = true;
};
const processLine = (line: string, controller: SseEnqueueTarget) => {
const processLine = (line: string, controller: SseProcessTarget) => {
if (done) return;
const payload = unwrapZedLine(line);
if (!payload) return;
@@ -246,17 +314,29 @@ function wrapZedCompletionStream(
if (payload.status) {
const status = normalizeStatus(payload.status);
if (status?.type === "failed" || status?.failed) {
const failed = (status.failed as Record<string, unknown>) || status;
const message = String(failed.message || failed.error || failed.code || "request failed");
enqueueSseObject(controller, encoder, createErrorChunk(model, message));
finish(controller);
const failed =
status.failed && typeof status.failed === "object" && !Array.isArray(status.failed)
? (status.failed as Record<string, unknown>)
: status;
if (providerOutputForwarded) {
pendingFailure = Object.assign(new Error(ZED_STREAM_FAILURE_PUBLIC_MESSAGE), {
statusCode: 502,
});
done = true;
controller.terminate();
return;
}
pendingProviderOutput = "";
enqueueSseObject(controller, encoder, createErrorChunk(extractZedFailureMessage(failed)));
done = true;
controller.terminate();
} else if (status?.type === "stream_ended" || status === ("stream_ended" as unknown)) {
finish(controller);
}
return;
}
const converted = convertProviderEvent(provider, payload.event, state);
enqueueSseObject(controller, encoder, converted);
forwardProviderOutput(controller, converted);
};
const transformed = response.body.pipeThrough(
@@ -281,7 +361,47 @@ function wrapZedCompletionStream(
})
);
return new Response(transformed, {
// `TransformStreamDefaultController.error()` discards already-enqueued output. A failed
// status can share one upstream network chunk with the last content delta, so erroring the
// transform immediately would erase that partial answer. Drain the transformed chunks through
// a backpressure-aware reader first, then reject the next read with the fixed public error.
// The normal chat pipeline turns that rejection into its client-format terminal frame and
// records the 502 through the existing failure finalizers.
const transformedReader = transformed.getReader();
let guardedStreamCancelled = false;
const cancelTransformedReader = (reason: unknown) => {
if (guardedStreamCancelled) return;
guardedStreamCancelled = true;
// Client cancellation must settle independently of an upstream body whose cancel hook hangs.
// Request cancellation once, but do not await provider cleanup on the client-facing boundary.
void transformedReader.cancel(reason).catch(() => {
console.debug("[ZED] upstream stream cancellation rejected");
});
};
const guardedStream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await transformedReader.read();
if (guardedStreamCancelled) return;
if (!next.done) {
controller.enqueue(next.value);
return;
}
if (pendingFailure) {
controller.error(pendingFailure);
return;
}
controller.close();
} catch (error) {
if (!guardedStreamCancelled) controller.error(error);
}
},
cancel(reason) {
cancelTransformedReader(reason);
},
});
return new Response(guardedStream, {
status: response.status,
statusText: response.statusText,
headers: {

View File

@@ -5,7 +5,8 @@ import {
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { buildFailureUsageRecord, projectFailureUsageErrorCode } from "./chatCore/failureUsage.ts";
import { createTranslationFailureResult } from "./chatCore/translationFailure.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import {
extractSystemRoleMessages,
@@ -2513,35 +2514,11 @@ export async function handleChatCore({
: HTTP_STATUS.SERVER_ERROR;
const message = error?.message || "Invalid request";
const errorType = typeof error?.errorType === "string" ? error.errorType : null;
log?.warn?.("TRANSLATE", `Request translation failed: ${message}`);
if (errorType) {
trackPendingRequest(model, provider, connectionId, false);
return {
success: false,
status: statusCode,
error: message,
response: new Response(
JSON.stringify({
error: {
message,
type: errorType,
code: errorType,
},
}),
{
status: statusCode,
headers: {
"Content-Type": "application/json",
},
}
),
};
}
const result = createTranslationFailureResult(statusCode, message, errorType);
log?.warn?.("TRANSLATE", `Request translation failed: ${result.error}`);
trackPendingRequest(model, provider, connectionId, false);
return createErrorResult(statusCode, message);
return result;
}
// The latest OmniGlyph release has protocol-native OpenAI transforms. Run
@@ -3924,10 +3901,14 @@ export async function handleChatCore({
streamController.handleError(error);
return createErrorResult(499, "Request aborted");
}
persistFailureUsage(
failureStatus,
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error")
);
const persistentErrorCode = projectFailureUsageErrorCode({
statusCode: failureStatus,
message: failureMessage,
errorCode:
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error"),
errorType: upstreamErrorType,
});
persistFailureUsage(failureStatus, persistentErrorCode);
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
if (stream && upstreamErrorCode) {
const result = createStreamingErrorResult(
@@ -4253,6 +4234,9 @@ export async function handleChatCore({
`${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})`
);
}
// Classifiers and recovery paths above consume the raw provider wording.
// Project a separate value only at persistent connection-state boundaries.
const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed";
const errorConnectionId = getCurrentConnectionId();
if (errorConnectionId && errorType) {
try {
@@ -4264,7 +4248,7 @@ export async function handleChatCore({
{
testStatus: "banned",
isActive: false,
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4295,7 +4279,7 @@ export async function handleChatCore({
) {
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4308,7 +4292,7 @@ export async function handleChatCore({
{
testStatus: "deactivated",
isActive: false,
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4332,7 +4316,7 @@ export async function handleChatCore({
errorConnectionId,
{
testStatus: "credits_exhausted",
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4418,7 +4402,7 @@ export async function handleChatCore({
rateLimitedUntil: kimiRateLimitResetAt,
backoffLevel: 0,
lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4447,7 +4431,7 @@ export async function handleChatCore({
errorConnectionId,
{
testStatus: "credits_exhausted",
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4463,14 +4447,14 @@ export async function handleChatCore({
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
} else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) {
// OAuth 401 with invalid credentials - token refresh can recover
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4480,7 +4464,7 @@ export async function handleChatCore({
// Cloud Code 403 with stale project: not a ban, keep account active.
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4496,7 +4480,7 @@ export async function handleChatCore({
const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000;
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
// T-PROBE: the 24h exclusion is a routing mutation — a probe must
@@ -4521,7 +4505,7 @@ export async function handleChatCore({
const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000;
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
try {
@@ -5305,9 +5289,12 @@ export async function handleChatCore({
}).catch(() => {});
const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason);
const malformedMessage = `[${provider}/${model}] ${malformed.message}`;
const malformedClientBody = buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage);
malformedClientBody.error.code = malformed.code;
malformedClientBody.error.type = malformed.type;
const malformedClientBody = buildErrorBody(
HTTP_STATUS.BAD_GATEWAY,
malformedMessage,
undefined,
{ code: malformed.code, type: malformed.type }
);
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
tokens: usage,

View File

@@ -19,6 +19,7 @@ import { saveCallLog } from "@/lib/usageDb";
import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge";
import { FORMATS } from "../../translator/formats.ts";
import { takeEarlyKeepaliveBytes } from "../../utils/earlyKeepaliveByteBuffer.ts";
import { sanitizeErrorMessage } from "../../utils/error.ts";
import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts";
import { attachLogMeta } from "./cacheUsageMeta.ts";
@@ -317,7 +318,9 @@ export function resolveRequestLifecycleEvent(input: {
name: "request.failed",
payload: {
id: traceId,
error: error || `HTTP ${status}`,
// Dashboard listeners and event history cross a public WebSocket boundary. Keep the raw
// diagnostic in the call log/pipeline above, but expose only the canonical safe projection.
error: sanitizeErrorMessage(error || `HTTP ${status}`),
statusCode: typeof status === "number" ? status : undefined,
latencyMs,
model: model || undefined,

View File

@@ -8,6 +8,21 @@
* `latencyMs` (Date.now() - startTime) and fires the fire-and-forget saveRequestUsage(...).catch().
*/
import { buildErrorBody } from "../../utils/error.ts";
export function projectFailureUsageErrorCode(opts: {
statusCode: number;
message: string;
errorCode?: string | null;
errorType?: string | null;
}): string {
const errorBody = buildErrorBody(opts.statusCode, opts.message, undefined, {
code: opts.errorCode || undefined,
type: opts.errorType || undefined,
});
return errorBody.error.code || String(opts.statusCode);
}
export function buildFailureUsageRecord(opts: {
provider: string | null | undefined;
model: string | null | undefined;

View File

@@ -25,13 +25,7 @@ export function createStreamingErrorResult(
code?: string,
type?: string
) {
const errorBody = buildErrorBody(statusCode, message);
if (code) {
errorBody.error.code = code;
}
if (type) {
errorBody.error.type = type;
}
const errorBody = buildErrorBody(statusCode, message, undefined, { code, type });
const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`;

View File

@@ -0,0 +1,24 @@
import { buildErrorBody, createErrorResult } from "../../utils/error.ts";
export function createTranslationFailureResult(
status: number,
message: string,
errorType: string | null
) {
if (!errorType) return createErrorResult(status, message);
const body = buildErrorBody(
status,
message,
undefined,
{ type: errorType, code: errorType }
);
return {
success: false as const,
status,
error: body.error.message,
response: new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
};
}

View File

@@ -6,7 +6,8 @@ import { CORS_HEADERS } from "../utils/cors.ts";
*/
import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts";
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
@@ -57,14 +58,11 @@ export async function handleModeration({ body, credentials }) {
if (!res.ok) {
const errText = await res.text();
// secret-leak hardening: redact any credential the upstream echoed back
// before relaying the error body to the client (structure-preserving).
return new Response(redactSensitiveErrorText(errText), {
return buildSanitizedUpstreamErrorResponse({
status: res.status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
rawBody: errText,
fallbackMessage: `Moderation provider returned HTTP ${res.status}`,
headers: CORS_HEADERS,
});
}
@@ -79,6 +77,10 @@ export async function handleModeration({ body, credentials }) {
});
return new Response(JSON.stringify(data), { status: 200, headers });
} catch (err) {
return errorResponse(500, `Moderation request failed: ${err.message}`);
const safeDetail =
sanitizeErrorMessage(err)
.replace(/^[A-Za-z]*Error:\s*/, "")
.trim() || "unknown upstream failure";
return errorResponse(500, `Moderation request failed: ${safeDetail}`);
}
}

View File

@@ -11,7 +11,8 @@ import {
parseOcrModel,
OCR_PROVIDERS,
} from "../config/ocrRegistry.ts";
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import {
@@ -151,15 +152,11 @@ export async function handleOcr({
if (!res.ok) {
const errText = await res.text();
// secret-leak hardening: an upstream OCR provider can echo the offending
// request (Authorization header / api key) inside its error text. Redact
// secret patterns (structure-preserving) before relaying to the client.
return new Response(redactSensitiveErrorText(errText), {
return buildSanitizedUpstreamErrorResponse({
status: res.status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
rawBody: errText,
fallbackMessage: `OCR provider returned HTTP ${res.status}`,
headers: CORS_HEADERS,
});
}
@@ -184,7 +181,8 @@ export async function handleOcr({
});
return new Response(JSON.stringify(parsed), { status: 200, headers });
} catch (err) {
console.error("[OCR]", err);
const safeErrorMessage = sanitizeErrorMessage(err).trim() || "OCR request failed";
console.error("[OCR]", safeErrorMessage);
return errorResponse(500, "OCR request failed");
}
}

View File

@@ -20,6 +20,9 @@ import { randomUUID } from "crypto";
* }
*/
export { resolveSearchBaseUrl, SearchBaseUrlOverrideError } from "./search/baseUrl.ts";
import { resolveSearchBaseUrl } from "./search/baseUrl.ts";
import {
getSearchProvider,
isUnconfiguredLoopbackSearchProvider,
@@ -36,7 +39,6 @@ import * as anysearchSearch from "./search/anysearchSearch.ts";
import { freeWebSearch } from "../services/freeWebSearch.ts";
import { saveCallLog } from "@/lib/usageDb";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
@@ -319,25 +321,6 @@ function getProviderSettingString(
return undefined;
}
export function resolveSearchBaseUrl(
config: SearchProviderConfig,
params: SearchRequestParams
): string {
const override = getProviderSettingString(params, "baseUrl");
if (override) {
// GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options /
// providerSpecificData) and flows into a plain fetch() sink — validate it
// before any builder uses it as the server-side fetch target. Mode is
// block-metadata (NOT public-only): the primary searxng use case is a
// self-hosted instance on loopback/LAN, so private hosts keep working,
// while cloud-metadata endpoints (IMDS credential theft) are rejected.
// The catalog's own config.baseUrl is operator config and stays untouched.
parseAndValidateNonMetadataUrl(override);
return override.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined;
return Math.floor(offset / maxResults) + 1;

View File

@@ -0,0 +1,66 @@
/**
* Base-URL resolution for /v1/search — the trust decision, on its own.
*
* The two override sources are NOT equally trusted, and reading them through
* one call is what produced GHSA-3f8g-pfh9-j687:
*
* - `providerSpecificData` is the stored provider connection
* (`credentials?.providerSpecificData`) — OPERATOR config. Honored under
* block-metadata, so a self-hosted SearXNG on loopback/LAN keeps working
* while cloud metadata (IMDS credential theft) stays rejected (GHSA-j7j4).
* - `providerOptions` is `body.provider_options` — CALLER input. Honored only
* by a provider that is keyless AND opted in via
* `allowClientBaseUrlOverride`. A keyed builder attaches the OPERATOR's key
* to whatever host this resolves to (`key=`/`api_key=` in the query for
* google-pse/searchapi, `X-API-Key`/`Authorization` for
* you.com/linkup/nimble/ollama), so a caller-chosen host would collect it —
* and a block-metadata check does nothing about that, because the attacker
* simply names their own public host.
*
* Full coverage: tests/unit/search-baseurl-client-override-3f8g.test.ts.
*/
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
interface BaseUrlParams {
providerOptions?: Record<string, unknown>;
providerSpecificData?: Record<string, unknown>;
}
/** Read one string setting from a SINGLE source, so callers can distinguish trust. */
function readSetting(source: Record<string, unknown> | undefined, key: string): string | undefined {
const value = source?.[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/** Refusal for a caller-supplied `provider_options.baseUrl` (GHSA-3f8g-pfh9-j687). */
export class SearchBaseUrlOverrideError extends Error {
readonly code = "SEARCH_BASE_URL_OVERRIDE_REFUSED";
constructor(providerId: string) {
super(
`provider_options.baseUrl is not accepted for search provider "${providerId}". ` +
`Set the base URL on the provider connection instead.`
);
this.name = "SearchBaseUrlOverrideError";
}
}
export function resolveSearchBaseUrl(config: SearchProviderConfig, params: BaseUrlParams): string {
const operatorOverride = readSetting(params.providerSpecificData, "baseUrl");
if (operatorOverride) {
parseAndValidateNonMetadataUrl(operatorOverride);
return operatorOverride.replace(/\/+$/, "");
}
const callerOverride = readSetting(params.providerOptions, "baseUrl");
if (callerOverride) {
if (!config.allowClientBaseUrlOverride || config.authType === "apikey") {
throw new SearchBaseUrlOverrideError(config.id);
}
parseAndValidateNonMetadataUrl(callerOverride);
return callerOverride.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}

View File

@@ -0,0 +1,13 @@
import { sanitizeErrorMessage } from "../utils/error.ts";
export function toSafeMcpErrorMessage(
value: unknown,
fallback = "MCP tool execution failed"
): string {
try {
const raw = value instanceof Error ? value.message : value;
return sanitizeErrorMessage(raw) || fallback;
} catch {
return fallback;
}
}

View File

@@ -93,7 +93,7 @@ import {
import { getDbInstance, ensureDbInitialized } from "../../src/lib/db/core.ts";
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { toSafeMcpErrorMessage } from "./errorMessage.ts";
import { mcpFetchTimeoutSignal } from "./fetchTimeout.ts";
import { getMcpModelsCatalog } from "./catalog.ts";
import { registerRadarCatalogTool } from "./radarCatalog.ts";
@@ -328,9 +328,7 @@ async function handleGetHealth() {
.filter(({ settled }) => settled.status === "rejected")
.map(({ source, settled }) => ({
source,
error: sanitizeErrorMessage(
settled.status === "rejected" ? (settled as PromiseRejectedResult).reason : undefined
),
error: toSafeMcpErrorMessage((settled as PromiseRejectedResult).reason, ""),
}));
const result = {
@@ -378,7 +376,7 @@ async function handleGetHealth() {
await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_get_health", {}, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -420,7 +418,7 @@ async function handleListCombos(args: { includeMetrics?: boolean }) {
await logToolCall("omniroute_list_combos", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_list_combos", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -435,7 +433,7 @@ async function handleGetComboMetrics(args: { comboId: string }) {
await logToolCall("omniroute_get_combo_metrics", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_get_combo_metrics", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -451,7 +449,7 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) {
await logToolCall("omniroute_switch_combo", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_switch_combo", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -472,7 +470,7 @@ async function handleCreateCombo(args: {
await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -493,7 +491,7 @@ async function handleCheckQuota(args: { provider?: string; connectionId?: string
await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_check_quota", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -562,7 +560,7 @@ async function handleRouteRequest(args: {
);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall(
"omniroute_route_request",
{ model: args.model },
@@ -611,7 +609,7 @@ async function handleCostReport(args: { period?: string }) {
await logToolCall("omniroute_cost_report", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_cost_report", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -631,7 +629,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_list_models_catalog", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -660,7 +658,7 @@ async function handleWebSearch(args: {
await logToolCall("omniroute_web_search", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_web_search", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -686,7 +684,7 @@ async function handleXSearch(args: {
await logToolCall("omniroute_x_search", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_x_search", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -726,7 +724,7 @@ async function handleWebFetch(args: {
await logToolCall("omniroute_web_fetch", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_web_fetch", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -1182,7 +1180,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Memory tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1209,7 +1207,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Skill tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1234,7 +1232,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Agent skill tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
})
@@ -1259,7 +1257,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "GitHub skill tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1286,7 +1284,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Plugin tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1313,7 +1311,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Compression tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1350,7 +1348,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Pool tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1378,7 +1376,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Gamification tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1405,7 +1403,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Notion tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1432,8 +1430,9 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (error) {
const msg = toSafeMcpErrorMessage(error, "Local corpus tool execution failed");
return {
content: [{ type: "text" as const, text: `Error: ${sanitizeErrorMessage(error)}` }],
content: [{ type: "text" as const, text: `Error: ${msg}` }],
isError: true,
};
}
@@ -1461,7 +1460,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Obsidian tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1502,7 +1501,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Skill execution failed");
return {
content: [{ type: "text" as const, text: `Error: ${msg}` }],
isError: true,

View File

@@ -60,10 +60,10 @@ describe("TierResolver", () => {
expect(result.hasFreeTier).toBe(true);
});
it("classifies Cerebras as free", () => {
it("classifies Cerebras as not free after the no-card trial ended (#11773)", () => {
const result = classifyTier("cerebras", "llama-3.1-70b");
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
expect(result.tier).not.toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(false);
});
it("classifies Groq as free", () => {
@@ -228,7 +228,6 @@ describe("TierResolver", () => {
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq",
]) {
expect(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`).toBe(

View File

@@ -52,7 +52,6 @@ export const LEGACY_FREE_PROVIDERS: readonly string[] = [
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq",
];

View File

@@ -19,7 +19,6 @@
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq"
]
}

View File

@@ -5,6 +5,7 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { projectCompletedStreamError } from "../../utils/streamErrorFormat.ts";
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
import { getReadableReasoningValue } from "../../utils/reasoningFields.ts";
@@ -746,6 +747,7 @@ function sendCompleted(state, emit) {
// translator or the OpenAI-Responses translator itself when the upstream
// SSE stream emits a JSON error object after partial content.
const upstreamErr = state.upstreamError;
const publicUpstreamError = projectCompletedStreamError(upstreamErr);
const response: Record<string, unknown> = {
id: state.responseId,
@@ -753,9 +755,7 @@ function sendCompleted(state, emit) {
created_at: state.created,
status: upstreamErr ? "failed" : "completed",
background: false,
error: upstreamErr
? { code: String(upstreamErr.status ?? ""), message: upstreamErr.message ?? "" }
: null,
error: publicUpstreamError,
output,
};

View File

@@ -0,0 +1,110 @@
import { sanitizeErrorMessage } from "./error.ts";
export const CODEX_PUBLIC_ERROR_MESSAGE = sanitizeErrorMessage("Codex provider request failed");
export interface CodexPublicError {
message: string;
type: string;
code: string;
}
interface CodexPublicErrorInput {
status?: number | null;
type?: unknown;
code?: unknown;
}
interface CodexPublicErrorRule {
type: string;
allowsStatus: (status: number) => boolean;
}
const exactStatuses =
(...statuses: number[]) =>
(status: number): boolean =>
statuses.includes(status);
const CODEX_PUBLIC_ERROR_RULES = new Map<string, CodexPublicErrorRule>([
["browser_stream_inconsistent", { type: "server_error", allowsStatus: exactStatuses(502) }],
["chatgpt_session_expired", { type: "authentication_error", allowsStatus: exactStatuses(401) }],
["chatgpt_submission_ambiguous", { type: "server_error", allowsStatus: exactStatuses(502) }],
["chatgpt_submitted_turn_failed", { type: "server_error", allowsStatus: exactStatuses(502) }],
["chatgpt_subscription_unavailable", { type: "server_error", allowsStatus: exactStatuses(503) }],
["client_cancelled", { type: "invalid_request_error", allowsStatus: exactStatuses(499) }],
["client_closed_request", { type: "invalid_request_error", allowsStatus: exactStatuses(499) }],
["codex_app_server_turn_failed", { type: "provider_error", allowsStatus: exactStatuses(502) }],
[
"compaction_control_unavailable",
{ type: "invalid_request_error", allowsStatus: exactStatuses(409) },
],
[
"compaction_handoff_failed",
{ type: "invalid_request_error", allowsStatus: exactStatuses(409) },
],
[
"compaction_source_unavailable",
{ type: "invalid_request_error", allowsStatus: exactStatuses(409) },
],
["connector_not_found", { type: "connector_error", allowsStatus: exactStatuses(424) }],
[
"context_length_exceeded",
{ type: "invalid_request_error", allowsStatus: exactStatuses(400, 413) },
],
["insufficient_quota", { type: "insufficient_quota", allowsStatus: exactStatuses(429) }],
["invalid_api_key", { type: "authentication_error", allowsStatus: exactStatuses(401) }],
["invalid_output_schema", { type: "invalid_request_error", allowsStatus: exactStatuses(400) }],
["invalid_request_error", { type: "invalid_request_error", allowsStatus: exactStatuses(400) }],
["multipart_protocol_violation", { type: "server_error", allowsStatus: exactStatuses(502) }],
["origin_rejected", { type: "invalid_request_error", allowsStatus: exactStatuses(403) }],
["permission_denied", { type: "permission_error", allowsStatus: exactStatuses(403) }],
["prompt_attachment_integrity", { type: "server_error", allowsStatus: exactStatuses(502) }],
["rate_limit_exceeded", { type: "rate_limit_error", allowsStatus: exactStatuses(429) }],
["server_is_overloaded", { type: "server_error", allowsStatus: exactStatuses(503) }],
[
"structured_output_validation_failed",
{ type: "server_error", allowsStatus: exactStatuses(502) },
],
["subscription_required", { type: "permission_error", allowsStatus: exactStatuses(403) }],
[
"upstream_server_error",
{
type: "server_error",
allowsStatus: (status) => status >= 500 && status <= 599 && status !== 503,
},
],
[
"upstream_websocket_connect_failed",
{ type: "provider_error", allowsStatus: exactStatuses(502) },
],
["upstream_websocket_error", { type: "provider_error", allowsStatus: exactStatuses(502) }],
["usage_limit_reached", { type: "rate_limit_error", allowsStatus: exactStatuses(429) }],
]);
function defaultPublicClassification(status: number): Pick<CodexPublicError, "type" | "code"> {
if (status === 429) return { type: "rate_limit_error", code: "rate_limit_exceeded" };
if (status === 401) return { type: "authentication_error", code: "invalid_api_key" };
if (status === 403) return { type: "permission_error", code: "permission_denied" };
if (status === 499) return { type: "invalid_request_error", code: "client_closed_request" };
if (status === 503) return { type: "server_error", code: "server_is_overloaded" };
if (status >= 500) return { type: "server_error", code: "upstream_server_error" };
return { type: "invalid_request_error", code: "invalid_request_error" };
}
/**
* Project an internally classified Codex failure onto its public Responses contract.
*
* Upstream message, code, and type fields are untrusted. The public message is fixed,
* while code/type retain only closed, protocol-level identifiers already produced by
* OmniRoute. Everything else falls back to the HTTP status classification.
*/
export function projectCodexPublicError(input: CodexPublicErrorInput): CodexPublicError {
const status =
typeof input.status === "number" && Number.isInteger(input.status) ? input.status : 502;
const fallback = defaultPublicClassification(status);
const rule =
typeof input.code === "string" ? CODEX_PUBLIC_ERROR_RULES.get(input.code) : undefined;
if (!rule || !rule.allowsStatus(status)) {
return { message: CODEX_PUBLIC_ERROR_MESSAGE, ...fallback };
}
return { message: CODEX_PUBLIC_ERROR_MESSAGE, type: rule.type, code: input.code as string };
}

View File

@@ -0,0 +1,79 @@
/** Pure credential signatures shared by guardrails and public error sanitization. */
export interface CredentialPattern {
name: string;
regex: RegExp;
replacement: string;
}
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
{
name: "anthropic",
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{
name: "anthropic_alt",
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{ name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" },
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
{
name: "postman",
regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g,
replacement: "[REDACTED:postman]",
},
{
name: "discord",
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
replacement: "[REDACTED:discord]",
},
{
name: "stripe",
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
replacement: "[REDACTED:stripe]",
},
{
name: "square",
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
replacement: "[REDACTED:square]",
},
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
{
name: "sendgrid",
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
replacement: "[REDACTED:sendgrid]",
},
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
{
name: "private_key",
regex:
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
replacement: "[REDACTED:private_key]",
},
{
name: "jwt",
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
replacement: "[REDACTED:jwt]",
},
{
name: "connection_string",
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
replacement: "[REDACTED:connection_string]",
},
{
name: "auth_header",
regex:
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
replacement: "$1[REDACTED:auth_header]",
},
];

View File

@@ -1,15 +1,18 @@
import { CORS_HEADERS } from "./cors.ts";
import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
import {
redactSensitiveErrorText,
sanitizeErrorMessage,
sanitizeUpstreamDetails,
} from "./errorSanitization.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
/**
* Sanitize an error message to prevent stack trace exposure in API responses.
* Strips stack traces, file paths, and absolute Windows/POSIX paths from
* error messages before they reach the client.
*/
export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails };
/** Client-visible error shape; dynamic fields are projected through canonical boundaries. */
interface ErrorResponseBody {
error: {
message: string;
@@ -20,91 +23,6 @@ interface ErrorResponseBody {
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
}
// Length cap protects against pathological inputs even before tokenization.
const MAX_ERROR_LEN = 4096;
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const;
function looksLikeAbsolutePath(tok: string): boolean {
// POSIX: "/<...>.ts" (optionally followed by :line[:col]).
// Windows: "C:\<...>.ts" or "C:/<...>.ts".
if (tok.length < 4 || tok.length > 2048) return false;
const isPosix = tok.charCodeAt(0) === 0x2f; // '/'
const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]);
if (!isPosix && !isWindows) return false;
const dot = tok.lastIndexOf(".");
if (dot <= 0 || dot === tok.length - 1) return false;
const ext = tok
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (SOURCE_EXT as readonly string[]).includes(ext);
}
export function redactSensitiveErrorText(value: string): string {
return value
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["'])[^"']*(["'])/gi,
"$1[REDACTED]$2"
)
.replace(
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*)[^"',\s}]+/gi,
"$1[REDACTED]"
);
}
/**
* Strip stack-trace tail and absolute source paths from error messages.
*
* Implemented via simple whitespace tokenization (linear time) instead of a
* single complex regex, so CodeQL `js/polynomial-redos` stays clean even when
* the runtime error message is attacker-controlled.
*/
export function sanitizeErrorMessage(message: unknown): string {
let str = typeof message === "string" ? message : String(message ?? "");
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
const nl = str.indexOf("\n");
const firstLine = nl >= 0 ? str.slice(0, nl) : str;
// Preserve original whitespace by splitting on captured separator.
const parts = firstLine.split(/(\s+)/);
for (let i = 0; i < parts.length; i++) {
if (looksLikeAbsolutePath(parts[i])) parts[i] = "<path>";
}
return redactSensitiveErrorText(parts.join(""));
}
const BLOCKED_KEYS =
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie/i;
const MAX_DEPTH = 4;
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
* - Arrays capped at 32 elements.
* - Returns null for null/undefined/non-JSON-serializable values.
*/
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") return sanitizeErrorMessage(value);
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (BLOCKED_KEYS.test(k)) continue;
out[k] = sanitizeUpstreamDetails(v, depth + 1);
}
return out;
}
return null;
}
/** Optional caller classification; when set, wins over status-derived defaults. */
export type ErrorBodyClassification = {
type?: string;
@@ -112,6 +30,279 @@ export type ErrorBodyClassification = {
reason?: string;
};
const PUBLIC_ERROR_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SAFE_PUBLIC_ERROR_IDENTIFIERS = new Set([
"abort",
"aborted",
"account_semaphore_capacity",
"acp_cancelled",
"acp_early_exit",
"acp_error",
"acp_output_too_large",
"acp_session_mismatch",
"acp_timeout",
"admission_aborted",
"admission_deadline",
"admission_lane_evicted",
"admission_oversized",
"admission_queue_full",
"admission_shutdown",
"admission_unavailable",
"all_accounts_inactive",
"all_targets_skipped",
"antigravity_pre_response_timeout",
"api_error",
"authentication_error",
"authentication_required",
"auth_error",
"bad_gateway",
"bad_request",
"bedrock_stream_error",
"billing_error",
"blackbox_auth_required",
"blackbox_rate_limit",
"blackbox_subscription_required",
"body_exceeds_budget",
"browser_stream_inconsistent",
"capability_mismatch",
"cf_mitigated_challenge",
"chat_admission_busy",
"chat_history_too_large",
"chatgpt_web_codex_error",
"chatgpt_web_codex_turn_failed",
"chatgpt_session_expired",
"chatgpt_submission_ambiguous",
"chatgpt_submitted_turn_failed",
"chatgpt_subscription_unavailable",
"client_cancelled",
"client_closed_request",
"client_disconnected",
"cli_not_found",
"cloudflare_challenge",
"cloudflare_or_bot",
"codex_app_server_unconfigured",
"codex_app_server_turn_failed",
"combo_target_timeout",
"combo_timeout",
"compaction_control_unavailable",
"compaction_handoff_failed",
"connector_error",
"connector_not_found",
"connection_error",
"context_length_exceeded",
"context_window",
"chipotle_error",
"devin_agentic_error",
"devin_cli_error",
"devin_desktop_error",
"devin_internal_tool_execution",
"duplicate_tool_use_id",
"direct_response_start_timeout",
"eai_again",
"econnrefused",
"econnreset",
"empty_acp_output",
"empty_content",
"empty_messages",
"empty_response",
"executor_contract_violation",
"error",
"etimedout",
"executor_error",
"feature_disabled",
"gateway_timeout",
"gemini_tpm_exhausted",
"gcp_project_required",
"grok_error",
"insufficient_quota",
"incompatible_reasoning_effort",
"internal_server_error",
"invalid_acp_frame",
"invalid_acp_upstream",
"invalid_api_key",
"invalid_kiro_tool_call",
"invalid_request",
"invalid_request_error",
"invalid_previous_response_binding",
"invalid_tool_arguments",
"invalid_tool_choice",
"invalid_tool_json",
"invalid_tool_name",
"invalid_tools",
"invalid_trailer",
"lease_action_invalid",
"lease_api_key_invalid",
"lease_authentication_required",
"lease_authorization_mismatch",
"lease_capacity_unavailable",
"lease_connection_mismatch",
"lease_content_type_required",
"lease_context_invalid",
"lease_context_required",
"lease_error",
"lease_fence_stale",
"lease_key_configuration_invalid",
"lease_key_policy_invalid",
"lease_model_invalid",
"lease_no_eligible_connection",
"lmarena_error",
"lease_required",
"lease_scope_required",
"lease_service_unavailable",
"lease_eligibility_unavailable",
"lease_unsupported_route",
"lease_unsupported_transport",
"message_limit",
"missing_credits",
"meta_ai_empty_response",
"meta_ai_mode_switch_failed",
"meta_ai_warmup_failed",
"meta_ai_ws_error",
"missing_tool_name",
"missing_tool_use_id",
"mixed_tool_narrative",
"missing_authorization",
"missing_cookie",
"missing_project_id",
"missing_credentials",
"missing_session_id",
"model_not_found",
"model_not_supported",
"model_shutdown",
"multipart_protocol_violation",
"multiple_tool_requests",
"native_codex_pinned_model_unavailable",
"network_error",
"no_free_eligible_connection",
"not_found",
"oauth_missing_project_id",
"orphan_tool_result",
"payload_too_large",
"payment_required",
"permission_error",
"premium_model_requires_key",
"prompt_attachment_integrity",
"provider_error",
"provider_retired",
"provider_unavailable",
"pplx_error",
"proxy_unavailable",
"proxy_family_unavailable",
"proxy_request_failed",
"proxy_unreachable",
"quota_exhausted",
"quota_not_allocated",
"quota_only",
"rate_limit_error",
"rate_limit_execution_timeout",
"rate_limit_exceeded",
"rate_limit_queue_full",
"rate_limit_queue_timeout",
"rate_limit_queue_wedged",
"rate_limit_longer_reached",
"rate_limit_reached",
"rate_limited",
"reached_limit",
"relay_timeout",
"resource_pressure",
"resource_exhausted",
"request_failed",
"risk_session_stale",
"server_error",
"semaphore_queue_full",
"semaphore_timeout",
"service_unavailable",
"service_not_running",
"session_expired",
"session_pool_exhausted",
"spawn_failed",
"stream_error",
"stream_disconnected",
"stream_early_eof",
"stream_idle_timeout",
"stream_pipeline_error",
"stream_readiness_timeout",
"stream_terminated",
"stream_timeout",
"storage_encryption_stale",
"structure_limit",
"structured_output",
"structured_output_validation_failed",
"timeout_error",
"timeout",
"token_limit_exceeded",
"token_required",
"tls_client_unavailable",
"tls_circuit_open",
"tls_fingerprint_failed",
"tls_session_capacity",
"tool_calling_not_supported",
"tools",
"undeclared_historical_tool",
"und_err_body_timeout",
"und_err_connect_timeout",
"und_err_headers_timeout",
"und_err_socket",
"unexpected_acp_response",
"unexecuted_tool_intent",
"unavailable",
"unknown_devin_model",
"unknown_tool",
"unverified_codex_client",
"unsafe_devin_home",
"unsupported_acp_version",
"unsupported_content_block",
"unsupported_control_for_provider",
"unsupported_endpoint",
"unsupported_image_block",
"unsupported_role",
"unsupported_system_block",
"upstream_error",
"upstream_access_denied",
"upstream_auth_error",
"upstream_empty_response",
"upstream_response_failed",
"upstream_response_error",
"upstream_server_error",
"upstream_protocol_error",
"upstream_timeout",
"upstream_websocket_connect_failed",
"upstream_websocket_error",
"usage_limit_reached",
"unsupported_feature",
"unsupported_runtime",
"video_artifact_content_type_invalid",
"video_artifact_download_failed",
"video_artifact_not_ready",
"video_artifact_signature_invalid",
"video_artifact_too_large",
"video_artifact_unavailable",
"video_artifact_url_blocked",
"video_artifact_url_invalid",
"vision",
"claude_web_protocol_error",
"wreq_unavailable",
]);
function isSafePublicErrorIdentifier(value: string): boolean {
if (!PUBLIC_ERROR_IDENTIFIER.test(value)) return false;
if (/^[1-5]\d{2}$/.test(value)) return true;
if (/^HTTP_[1-5]\d{2}$/i.test(value)) return true;
return SAFE_PUBLIC_ERROR_IDENTIFIERS.has(value.toLowerCase());
}
/** Project an internal classification onto the bounded client-visible identifier vocabulary. */
export function projectPublicErrorIdentifier(value: unknown, fallback: unknown): string {
const safeFallback =
fallback === ""
? ""
: typeof fallback === "string" && isSafePublicErrorIdentifier(fallback)
? fallback
: "error";
if (typeof value !== "string") return safeFallback;
return isSafePublicErrorIdentifier(value) ? value : safeFallback;
}
/**
* Build OpenAI-compatible error response body. Message is always sanitized
* so callers do not need to remember to strip stack traces themselves.
@@ -128,13 +319,17 @@ export function buildErrorBody(
): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
const safeReason =
typeof classification?.reason === "string" && isSafePublicErrorIdentifier(classification.reason)
? classification.reason
: undefined;
const body: ErrorResponseBody = {
error: {
message: safeMessage,
type: classification?.type ?? errorInfo.type,
code: classification?.code ?? errorInfo.code,
reason: classification?.reason,
type: projectPublicErrorIdentifier(classification?.type, errorInfo.type),
code: projectPublicErrorIdentifier(classification?.code, errorInfo.code),
reason: safeReason,
},
};
@@ -183,7 +378,7 @@ export interface ComboRecoveryHint {
action: ComboRecoveryAction;
/** Seconds the client should wait before retrying. Only meaningful when action="wait". */
retry_after_seconds?: number;
/** Human-readable next step — included verbatim in the error body for non-MCP clients. */
/** Human-readable next step — sanitized and length-capped for non-MCP clients. */
next_step: string;
}
@@ -203,21 +398,36 @@ export interface ComboDiagnostics {
}
function clampDiagStr(v: unknown, max = 128): string {
return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : "";
return typeof v === "string" ? sanitizeErrorMessage(v).slice(0, max) : "";
}
const RECOVERY_ROUTE_PLACEHOLDERS = [
["/dashboard/providers", "OMNIROUTE_SAFE_DASHBOARD_PROVIDERS_ROUTE"],
] as const;
function clampRecoveryStr(value: unknown, max: number): string {
if (typeof value !== "string") return "";
let projected = value;
for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) {
projected = projected.replaceAll(route, placeholder);
}
projected = sanitizeErrorMessage(projected);
for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) {
projected = projected.replaceAll(placeholder, route);
}
return projected.slice(0, max);
}
/**
* HTTP header values must be Latin1/ByteString (undici throws a TypeError
* otherwise — see #6612). Replace any codepoint outside the Latin1 range
* (0-255) with "?" so header construction never throws. Only used for the
* literal header value; the JSON body keeps the original, unsanitized
* readable text via `sanitizeComboDiagnostics`.
* HTTP header values must exclude controls and remain ByteString-compatible
* (undici throws a TypeError otherwise — see #6612). Replace every codepoint
* outside printable ASCII with "?" so header construction never throws.
*/
function toHeaderSafeAscii(v: string): string {
let out = "";
for (let i = 0; i < v.length; i++) {
const code = v.charCodeAt(i);
out += code > 255 ? "?" : v[i];
out += code < 0x20 || code > 0x7e ? "?" : v[i];
}
return out;
}
@@ -242,7 +452,7 @@ export function sanitizeRecoveryHint(
if (!action || !RECOVERY_ACTIONS.has(action)) return undefined;
// Reject empty OR whitespace-only next_step — the value must render usefully as a
// header and as a body field. A whitespace-only string would print as a blank hint.
const next_step = clampDiagStr(r.next_step, 200).trim();
const next_step = clampRecoveryStr(r.next_step, 200).trim();
if (!next_step) return undefined;
const hint: ComboRecoveryHint = { action, next_step };
if (typeof r.retry_after_seconds === "number" && Number.isFinite(r.retry_after_seconds)) {
@@ -293,12 +503,10 @@ export function errorResponseWithComboDiagnostics(
opts: { code?: string; type?: string } = {}
): Response {
const safe = sanitizeComboDiagnostics(diagnostics);
const body = buildErrorBody(statusCode, message) as ErrorResponseBody & {
const body = buildErrorBody(statusCode, message, undefined, opts) as ErrorResponseBody & {
diagnostics?: ComboDiagnostics;
recovery_hint?: ComboRecoveryHint;
};
if (opts.code) body.error.code = opts.code;
if (opts.type) body.error.type = opts.type;
body.diagnostics = safe;
if (safe.recovery) body.recovery_hint = safe.recovery;
const excludedHeader = toHeaderSafeAscii(
@@ -399,6 +607,29 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null):
return 1;
}
const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256;
function projectPublicContextLabel(value: unknown): string | null {
if (typeof value !== "string") return null;
const label = value.trim();
if (
label.length === 0 ||
label.length > MAX_PUBLIC_CONTEXT_LABEL_LENGTH ||
/[\u0000-\u001f\u007f]/.test(label)
) {
return null;
}
return sanitizeErrorMessage(label) === label ? label : null;
}
function projectPublicRetryTimestamp(value: unknown): string | null {
if (typeof value !== "string") return null;
const timestamp = value.trim();
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) return null;
const parsed = Date.parse(timestamp);
return Number.isFinite(parsed) && new Date(parsed).toISOString() === timestamp ? timestamp : null;
}
/**
* Parse Antigravity error message to extract retry time
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
@@ -442,7 +673,7 @@ export function parseAntigravityRetryTime(message: unknown): number | null {
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
*/
export async function parseUpstreamError(response: Response, provider: string | null = null) {
let message: unknown = "";
let message = "";
let retryAfterMs: number | null = null;
let responseBody: unknown = null;
let errorCode: unknown = undefined;
@@ -462,9 +693,15 @@ export async function parseUpstreamError(response: Response, provider: string |
// stack) — still routed through sanitizeErrorMessage/buildErrorBody by
// every consumer below (Rule #12).
const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider);
message = clinepassEnvError
const extractedMessage = clinepassEnvError
? clinepassEnvError.message
: json.error?.message || json.message || json.error || text;
: json.error?.message ||
json.message ||
(typeof json.error === "string" ? json.error : null);
message =
typeof extractedMessage === "string"
? extractedMessage
: `Upstream error: ${response.status}`;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
@@ -475,7 +712,7 @@ export async function parseUpstreamError(response: Response, provider: string |
responseBody = { _rawText: message };
}
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
const messageStr = message;
const retryAfterHeader = response.headers?.get?.("retry-after");
if (retryAfterHeader && !retryAfterMs) {
@@ -545,13 +782,10 @@ export function createErrorResult(
upstreamDetails?: unknown,
opts?: { passthrough?: boolean }
) {
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
body.error.code = errorCode;
}
if (errorType) {
body.error.type = errorType;
}
const body = buildErrorBody(statusCode, message, upstreamDetails, {
code: errorCode,
type: errorType,
});
const result: {
success: false;
@@ -591,8 +825,8 @@ export function createErrorResult(
result.retryAfterMs = retryAfterMs;
}
// Opt-in relay of the verbatim upstream error body (Claude Code auto-recover
// contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// Opt-in relay of the recursively sanitized upstream JSON shape (Claude Code
// auto-recover contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so
// server-side classification (checkFallbackError, combo retry logic, etc.)
// never sees a different value depending on this flag.
@@ -625,7 +859,9 @@ export function unavailableResponse(
retryAfterHuman?: string
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message;
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : "";
const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage;
return new Response(JSON.stringify({ error: { message: msg } }), {
status: statusCode,
headers: {
@@ -640,13 +876,14 @@ export function providerCircuitOpenResponse(
retryAfter?: string | number | Date | null
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const safeProvider = projectPublicContextLabel(provider) ?? "unknown";
return new Response(
JSON.stringify({
error: {
message: `Provider ${provider} circuit breaker is open`,
message: `Provider ${safeProvider} circuit breaker is open`,
type: "server_error",
code: "provider_circuit_open",
provider,
provider: safeProvider,
retry_after: retryAfterSec,
},
}),
@@ -672,9 +909,10 @@ export function buildModelCooldownBody({
retryAfterAt?: string | null;
credentialsCoolingCount?: number | null;
}): ModelCooldownErrorPayload {
const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
const resolvedRetryAfterAt =
typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null;
const resolvedModel = projectPublicContextLabel(model);
const resolvedRetryAfterAt = projectPublicRetryTimestamp(retryAfterAt);
const resolvedResetSeconds =
Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? Math.max(Math.ceil(retryAfterSec), 1) : 1;
const resolvedCoolingCount =
typeof credentialsCoolingCount === "number" &&
Number.isFinite(credentialsCoolingCount) &&
@@ -690,7 +928,7 @@ export function buildModelCooldownBody({
type: "rate_limit_error",
code: "model_cooldown",
...(resolvedModel ? { model: resolvedModel } : {}),
reset_seconds: Math.max(Math.ceil(retryAfterSec), 1),
reset_seconds: resolvedResetSeconds,
...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}),
...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}),
},

View File

@@ -0,0 +1,905 @@
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"] as const;
const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const;
const LEADING_PATH_PUNCTUATION = "'\"`([{<";
const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?";
const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?";
const FILE_URI_PREFIX = "file://";
const HTTP_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
"CONNECT",
"TRACE",
] as const;
const CLEAR_PROSE_BOUNDARIES = [
"after",
"because",
"before",
"but",
"crashed",
"denied",
"eacces",
"enoent",
"expired",
"failed",
"rejected",
"retry",
"then",
"when",
"while",
] as const;
const POSIX_FILESYSTEM_ROOTS = [
"/Users",
"/app",
"/boot",
"/data",
"/dev",
"/etc",
"/home",
"/media",
"/mnt",
"/nix",
"/opt",
"/private",
"/proc",
"/root",
"/run",
"/srv",
"/sys",
"/tmp",
"/usr",
"/var",
"/workspace",
] as const;
const WINDOWS_ROOT_RELATIVE_ROOTS = new Set([
"program files",
"programdata",
"temp",
"users",
"windows",
]);
function isWindowsAbsolutePathAt(value: string, start: number): boolean {
const remaining = value.length - start;
if (remaining > 2) {
const first = value.charCodeAt(start);
const second = value.charCodeAt(start + 1);
if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) {
return true;
}
}
if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false;
const driveLetter = value.charCodeAt(start);
const isAsciiLetter =
(driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a);
return (
isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c)
);
}
function isWindowsAbsolutePath(value: string): boolean {
return isWindowsAbsolutePathAt(value, 0);
}
function isWindowsRootRelativePathAt(value: string, start: number): boolean {
if (
value.charCodeAt(start) !== 0x5c ||
value.charCodeAt(start + 1) === 0x5c ||
isWhitespace(value[start + 1])
) {
return false;
}
const tokenEnd = findTokenEnd(value, start);
let firstSeparator = start + 1;
while (firstSeparator < tokenEnd && value.charCodeAt(firstSeparator) !== 0x5c) {
firstSeparator++;
}
const root = value.slice(start + 1, firstSeparator).toLowerCase();
if (WINDOWS_ROOT_RELATIVE_ROOTS.has(root)) return true;
return (
firstSeparator < tokenEnd - 1 || tokenContainsPathExtensionEvidence(value, start + 1, tokenEnd)
);
}
function hasAbsoluteFileUriAt(value: string, start: number): boolean {
const prefixEnd = start + FILE_URI_PREFIX.length;
return (
value.length > prefixEnd &&
value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX &&
!isWhitespace(value[prefixEnd])
);
}
function hasAbsoluteFileUri(value: string): boolean {
return hasAbsoluteFileUriAt(value, 0);
}
function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean {
return (
value.charCodeAt(start) === 0x2f ||
isWindowsAbsolutePathAt(value, start) ||
isWindowsRootRelativePathAt(value, start) ||
hasAbsoluteFileUriAt(value, start)
);
}
function isAsciiDigit(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
function isAsciiLetter(code: number): boolean {
return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
}
function isAsciiAlphaNumeric(code: number): boolean {
return isAsciiDigit(code) || isAsciiLetter(code);
}
function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean {
for (const scheme of ["http:", "https:"]) {
const schemeStart = slashIndex - scheme.length;
if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue;
if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true;
}
return false;
}
function isWhitespace(value: string): boolean {
return /\s/.test(value);
}
function isRouteContextWord(value: string): boolean {
return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value);
}
function hasRouteContextBefore(value: string, candidateIndex: number): boolean {
let index = candidateIndex - 1;
while (
index >= 0 &&
(isWhitespace(value[index]) ||
value.charCodeAt(index) === 0x28 ||
value.charCodeAt(index) === 0x3a)
) {
index--;
}
const contextEnd = index + 1;
while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--;
return isRouteContextWord(value.slice(index + 1, contextEnd));
}
function isRouteContextToken(value: string): boolean {
let end = value.length;
while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--;
let start = end;
while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--;
return isRouteContextWord(value.slice(start, end));
}
function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean {
if (!value.startsWith(root, start)) return false;
const rootEnd = start + root.length;
return (
rootEnd === value.length ||
value.charCodeAt(rootEnd) === 0x2f ||
PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd])
);
}
function isKnownPosixFilesystemPathAt(value: string, start: number): boolean {
return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root));
}
function isKnownPosixFilesystemPath(value: string): boolean {
return isKnownPosixFilesystemPathAt(value, 0);
}
function looksLikeAbsolutePath(token: string): boolean {
// POSIX: common filesystem roots, with or without a source extension.
// Windows: drive-letter, UNC, or extended-length absolute paths.
// Source-file paths rooted elsewhere remain covered by SOURCE_EXT below.
if (token.length < 4 || token.length > 2048) return false;
const isPosix = token.charCodeAt(0) === 0x2f;
const isWindows = isWindowsAbsolutePath(token) || isWindowsRootRelativePathAt(token, 0);
if (!isPosix && !isWindows) return false;
if (isWindows) return true;
if (isKnownPosixFilesystemPath(token)) return true;
const dot = token.lastIndexOf(".");
if (dot <= 0 || dot === token.length - 1) return false;
const extension = token
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (
(SOURCE_EXT as readonly string[]).includes(extension) ||
(NATIVE_EXT as readonly string[]).includes(extension)
);
}
function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string {
let start = 0;
let end = token.length;
while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++;
while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--;
const candidate = token.slice(start, end);
const isFileUri = hasAbsoluteFileUri(candidate);
const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate;
if (
!isFileUri &&
!isWindowsAbsolutePath(pathCandidate) &&
!isWindowsRootRelativePathAt(pathCandidate, 0) &&
pathCandidate.charCodeAt(0) === 0x2f &&
followsRouteContext
) {
return token;
}
if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token;
return `${token.slice(0, start)}<path>${token.slice(end)}`;
}
function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number {
let candidate = value.indexOf(quote, start);
if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate;
while (candidate < value.length) {
const nextQuote = value.indexOf(quote, candidate + 1);
if (nextQuote < 0) return candidate;
// Two separately quoted absolute paths are unambiguous. Close the first
// candidate so the second one is scanned on its own; otherwise keep
// consuming quotes fail-closed because POSIX filenames may contain them.
if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate;
candidate = nextQuote;
}
return value.length;
}
function redactQuotedAbsolutePaths(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const quote = value[index];
if (quote !== "'" && quote !== '"' && quote !== "`") {
index++;
continue;
}
const candidateStart = index + 1;
if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) {
index++;
continue;
}
const isShieldedRoute =
value.charCodeAt(candidateStart) === 0x2f &&
!isWindowsAbsolutePathAt(value, candidateStart) &&
hasRouteContextBefore(value, index);
// Route/API contexts use their first closing quote so a later quoted
// filesystem path is still scanned independently. Filesystem candidates
// take the last matching quote on the line: POSIX filenames may themselves
// contain quote characters, whitespace, and punctuation, so earlier
// matches are ambiguous and must fail closed rather than expose a suffix.
const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute);
if (isShieldedRoute) {
if (closingQuote >= value.length) break;
index = closingQuote + 1;
continue;
}
parts.push(value.slice(copyStart, candidateStart), "<path>");
copyStart = closingQuote;
if (closingQuote >= value.length) break;
index = closingQuote + 1;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function findPathExtensionEnd(value: string, dot: number): number {
let end = dot + 1;
const maxExtensionEnd = Math.min(value.length, end + 16);
while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++;
if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) {
return -1;
}
let hasLetter = false;
for (let index = dot + 1; index < end; index++) {
if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true;
}
if (!hasLetter) return -1;
while (value.charCodeAt(end) === 0x3a) {
let coordinateEnd = end + 1;
if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break;
while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) {
coordinateEnd++;
}
end = coordinateEnd;
}
if (
end === value.length ||
isWhitespace(value[end]) ||
PATH_SPAN_END_PUNCTUATION.includes(value[end])
) {
return end;
}
return -1;
}
function findTokenEnd(value: string, start: number): number {
let end = start;
while (end < value.length && !isWhitespace(value[end])) end++;
return end;
}
function findExtensionEndInToken(value: string, start: number, end: number): number {
let lastExtensionEnd = -1;
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) {
lastExtensionEnd = -1;
continue;
}
if (code !== 0x2e) continue;
const extensionEnd = findPathExtensionEnd(value, index);
if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd;
}
return lastExtensionEnd;
}
function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean {
for (let dot = start; dot < end; dot++) {
if (value.charCodeAt(dot) !== 0x2e) continue;
let extensionEnd = dot + 1;
const maxExtensionEnd = Math.min(end, extensionEnd + 16);
let hasLetter = false;
while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) {
if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true;
extensionEnd++;
}
if (
extensionEnd === dot + 1 ||
!hasLetter ||
(extensionEnd === maxExtensionEnd &&
extensionEnd < end &&
isAsciiAlphaNumeric(value.charCodeAt(extensionEnd)))
) {
continue;
}
if (
extensionEnd === end ||
value.charCodeAt(extensionEnd) === 0x2f ||
value.charCodeAt(extensionEnd) === 0x5c ||
PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd])
) {
return true;
}
}
return false;
}
function tokenContainsPathSeparator(value: string, start: number, end: number): boolean {
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) return true;
}
return false;
}
function remainderContainsFilesystemSeparator(value: string, start: number): boolean {
let tokenStart = start;
let previousToken = "";
while (tokenStart < value.length) {
while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++;
if (tokenStart >= value.length) return false;
const tokenEnd = findTokenEnd(value, tokenStart);
const token = value.slice(tokenStart, tokenEnd).toLowerCase();
const isHttpUrl = token.includes("http://") || token.includes("https://");
let separatorIndex = tokenStart;
while (
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) !== 0x2f &&
value.charCodeAt(separatorIndex) !== 0x5c
) {
separatorIndex++;
}
const precedingSeparatorCode =
separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1;
const contextIndex =
precedingSeparatorCode === 0x27 ||
precedingSeparatorCode === 0x22 ||
precedingSeparatorCode === 0x60
? separatorIndex - 1
: separatorIndex;
const isShieldedRoute =
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) === 0x2f &&
!isWindowsAbsolutePathAt(value, separatorIndex) &&
(isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex));
if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true;
previousToken = value.slice(tokenStart, tokenEnd);
tokenStart = tokenEnd;
}
return false;
}
function trimPathSpanEnd(value: string, start: number, end: number): number {
while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--;
return end;
}
function isClearProseBoundaryToken(value: string, start: number, end: number): boolean {
while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++;
end = trimPathSpanEnd(value, start, end);
return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes(
value.slice(start, end).toLowerCase()
);
}
function findUnquotedPathEnd(
value: string,
start: number,
acceptFirstTokenPunctuation: boolean,
acceptEndpointBeforeAnotherAbsolute: boolean,
failClosedAmbiguity: boolean
): number {
let tokenStart = start;
let isFirstToken = true;
let firstTokenEnd = -1;
let firstTrimmedTokenEnd = -1;
let lastPathTokenEnd = -1;
let resolvedExtensionEnd = -1;
let hasFilesystemEvidence = false;
let hasUnresolvedFragments = false;
const resolveEndpoint = (): number => {
if (hasUnresolvedFragments) {
return failClosedAmbiguity || hasFilesystemEvidence ? value.length : -1;
}
if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd;
if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd;
if (
acceptFirstTokenPunctuation &&
firstTrimmedTokenEnd >= 0 &&
firstTrimmedTokenEnd < firstTokenEnd
) {
return firstTrimmedTokenEnd;
}
return -1;
};
while (tokenStart < value.length) {
const tokenEnd = findTokenEnd(value, tokenStart);
const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd);
const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd);
if (isFirstToken) {
firstTokenEnd = tokenEnd;
firstTrimmedTokenEnd = trimmedTokenEnd;
lastPathTokenEnd = trimmedTokenEnd;
// A prose-looking token may itself be a directory name. It is a safe
// boundary only when no later token carries path-separator evidence;
// otherwise keep scanning so a filesystem suffix cannot survive.
} else if (
isClearProseBoundaryToken(value, tokenStart, tokenEnd) &&
(!remainderContainsFilesystemSeparator(value, tokenEnd) ||
(!failClosedAmbiguity && !hasFilesystemEvidence))
) {
return resolveEndpoint();
}
const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd);
const containsExtensionEvidence = tokenContainsPathExtensionEvidence(
value,
tokenStart,
tokenEnd
);
if (containsSeparator) {
lastPathTokenEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1;
if (extensionEnd < 0 && containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
}
} else if (extensionEnd >= 0) {
resolvedExtensionEnd = extensionEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (!isFirstToken) {
hasUnresolvedFragments = true;
}
let nextTokenStart = tokenEnd;
while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++;
if (nextTokenStart >= value.length) return resolveEndpoint();
if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) {
const endpoint = resolveEndpoint();
if (endpoint >= 0) return endpoint;
return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1;
}
tokenStart = nextTokenStart;
isFirstToken = false;
}
return resolveEndpoint();
}
function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean {
const tokenEnd = findTokenEnd(value, start);
const token = value.slice(start, tokenEnd);
if (isKnownPosixFilesystemPath(token)) return true;
if (
findExtensionEndInToken(value, start, tokenEnd) >= 0 ||
tokenContainsPathExtensionEvidence(value, start, tokenEnd)
) {
return true;
}
let slashCount = 0;
for (let index = start; index < tokenEnd; index++) {
if (value.charCodeAt(index) === 0x2f) slashCount++;
}
// Any boundary-delimited absolute POSIX token is filesystem-sensitive by
// default. Explicit Route/HTTP context is shielded by the caller before this
// candidate check, so `/vault` is redacted while `Route /vault` is retained.
return slashCount >= 1 && token.length > 1;
}
function redactUnquotedAbsolutePathSpans(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const previous = index > 0 ? value[index - 1] : "";
const followsQuote = previous === "'" || previous === '"' || previous === "`";
const hasCommonBoundary =
index === 0 ||
isWhitespace(previous) ||
LEADING_PATH_PUNCTUATION.includes(previous) ||
previous === "=" ||
previous === ":" ||
previous === "," ||
previous === ";" ||
previous === "." ||
previous === ">" ||
previous === "|";
const startsForwardSlashUnc =
value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f;
const startsHttpUrl =
startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index);
const isWindowsPath =
!followsQuote &&
(isWindowsAbsolutePathAt(value, index) || isWindowsRootRelativePathAt(value, index)) &&
!startsHttpUrl;
const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index);
const isPosixPath =
!followsQuote &&
value.charCodeAt(index) === 0x2f &&
value.charCodeAt(index + 1) !== 0x2f &&
!hasRouteContextBefore(value, index) &&
isUnquotedPosixSpanCandidateAt(value, index);
const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":");
if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) {
index++;
continue;
}
// Whitespace makes an unquoted path ambiguous. Extend through adjacent
// separator-bearing tokens or to a deterministic filename extension.
// Unequivocal Windows, file-URI, and known-root candidates fail closed;
// arbitrary extensionless POSIX text falls back to token-level handling so
// ordinary `/x/y` route text is not redacted indiscriminately.
const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index);
const pathEnd = findUnquotedPathEnd(
value,
index,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath
);
if (pathEnd < 0) {
const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath;
if (mustFailClosed) {
// An unequivocal filesystem prefix with an unknowable endpoint must
// fail closed over the rest of the first line rather than expose a
// suffix such as `Files\\secret` or `My Project`.
parts.push(value.slice(copyStart, index), "<path>");
copyStart = value.length;
index = value.length;
break;
}
index++;
continue;
}
parts.push(value.slice(copyStart, index), "<path>");
copyStart = pathEnd;
index = pathEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function isPhysicalLineSeparator(code: number): boolean {
return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029;
}
function serializedLineSeparatorLengthAt(value: string, start: number): number {
if (value.charCodeAt(start) !== 0x5c) return 0;
const marker = value[start + 1]?.toLowerCase();
if (marker === "n" || marker === "r") return 2;
const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase();
return unicodeMarker === "u000a" ||
unicodeMarker === "u000d" ||
unicodeMarker === "u2028" ||
unicodeMarker === "u2029"
? 6
: 0;
}
function looksLikeRelativeStackLocation(token: string): boolean {
if (token.length < 6 || token.length > 2048) return false;
const lastForwardSlash = token.lastIndexOf("/");
const lastBackslash = token.lastIndexOf("\\");
const lastSeparator = Math.max(lastForwardSlash, lastBackslash);
if (lastSeparator === token.length - 1) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator < 0 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
const queryIndex = token.indexOf("?", lastSeparator + 1);
const fragmentIndex = token.indexOf("#", lastSeparator + 1);
const metadataIndexes = [queryIndex, fragmentIndex].filter(
(index) => index >= 0 && index < lineSeparator
);
const extensionEnd = metadataIndexes.length > 0 ? Math.min(...metadataIndexes) : lineSeparator;
const dot = token.lastIndexOf(".", extensionEnd - 1);
if (dot <= lastSeparator || dot === extensionEnd - 1) return false;
const extension = token.slice(dot + 1, extensionEnd).toLowerCase();
if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false;
return true;
}
function looksLikeUrlStackLocation(token: string): boolean {
if (token.length < 12 || token.length > 2048) return false;
const lower = token.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
return lineSeparator > 0 && hasNumericLineColumnSuffix(token, lineSeparator);
}
function hasNumericLineColumnSuffix(value: string, separator: number): boolean {
if (value.charCodeAt(separator) !== 0x3a) return false;
let index = separator + 1;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
if (value.charCodeAt(index) !== 0x3a) return false;
index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
return index === value.length;
}
function isNodeModulePathCode(code: number): boolean {
return (
isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d
);
}
function looksLikeNodeStackLocation(token: string): boolean {
if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
for (let index = 5; index < lineSeparator; index++) {
if (!isNodeModulePathCode(token.charCodeAt(index))) return false;
}
return true;
}
function looksLikeEvalStackLocation(token: string): boolean {
return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6);
}
function isRecognizedStackPathAt(value: string, start: number): boolean {
if (hasAbsoluteFileUriAt(value, start)) return true;
const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start));
const token = value.slice(start, tokenEnd);
return (
looksLikeAbsolutePath(token) ||
looksLikeRelativeStackLocation(token) ||
looksLikeUrlStackLocation(token) ||
looksLikeNodeStackLocation(token) ||
looksLikeEvalStackLocation(token)
);
}
function isStackFrameLabel(value: string, start: number, end: number): boolean {
const label = value.slice(start, end).trim();
if (label.length === 0 || label.length > 256) return false;
if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false;
if (!/\s/.test(label)) return true;
return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label);
}
function skipAsyncStackPrefix(value: string, start: number): number {
if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start;
let locationStart = start + 6;
while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++;
return locationStart;
}
function isAggregateIndexLocationAt(value: string, start: number): boolean {
if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false;
let index = start + 6;
while (index < value.length && isWhitespace(value[index])) index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
while (index < value.length && isWhitespace(value[index])) index++;
return value.charCodeAt(index) === 0x29;
}
function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean {
if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false;
let labelStart = atIndex + 2;
if (!isWhitespace(value[labelStart])) return false;
while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++;
labelStart = skipAsyncStackPrefix(value, labelStart);
if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true;
const openParen = value.indexOf("(", labelStart);
if (openParen < 0 || openParen - labelStart > 256) return false;
let pathStart = openParen + 1;
while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++;
return (
isStackFrameLabel(value, labelStart, openParen) &&
(isRecognizedStackPathAt(value, pathStart) ||
(allowDirectPath && isAggregateIndexLocationAt(value, pathStart)))
);
}
function looksLikeAtSignStackFrameAt(value: string, frameStart: number): boolean {
const tokenEnd = trimPathSpanEnd(value, frameStart, findTokenEnd(value, frameStart));
const atSign = value.indexOf("@", frameStart);
if (atSign <= frameStart || atSign >= tokenEnd || atSign - frameStart > 256) return false;
return isStackFrameLabel(value, frameStart, atSign) && isRecognizedStackPathAt(value, atSign + 1);
}
function findSerializedStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = serializedLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length) {
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart);
if (adjacentSeparatorLength === 0) break;
frameStart += adjacentSeparatorLength;
}
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
let separatorStart = index;
while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) {
separatorStart--;
}
return separatorStart;
}
}
return -1;
}
function findInlineStackFrameStart(value: string): number {
let marker = value.indexOf(" at ");
while (marker >= 0) {
if (looksLikeStackFrameAt(value, marker + 1, false)) return marker;
marker = value.indexOf(" at ", marker + 4);
}
return -1;
}
function findInlineAtSignStackFrameStart(value: string): number {
let frameStart = 0;
while (frameStart < value.length) {
if (looksLikeAtSignStackFrameAt(value, frameStart)) {
return frameStart > 0 && isWhitespace(value[frameStart - 1]) ? frameStart - 1 : frameStart;
}
const tokenEnd = findTokenEnd(value, frameStart);
frameStart = tokenEnd;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
}
return -1;
}
function physicalLineSeparatorLengthAt(value: string, start: number): number {
const code = value.charCodeAt(start);
if (!isPhysicalLineSeparator(code)) return 0;
return code === 0x0d && value.charCodeAt(start + 1) === 0x0a ? 2 : 1;
}
function findPhysicalStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = physicalLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
return index;
}
index += separatorLength - 1;
}
return -1;
}
/** Strip only recognized physical, serialized, and inline JavaScript stack-frame tails. */
export function stripRecognizedErrorStackTail(value: string): string {
const candidates = [
findPhysicalStackFrameStart(value),
findSerializedStackFrameStart(value),
findInlineStackFrameStart(value),
findInlineAtSignStackFrameStart(value),
].filter((candidate) => candidate >= 0);
if (candidates.length === 0) return value;
return value.slice(0, Math.min(...candidates));
}
/**
* Public exception messages remain fail-closed at the first physical line.
* Provider passthroughs that require multiline capability wording use the
* narrower recognized-frame helper above instead.
*/
export function stripErrorStackTail(value: string): string {
let firstLineEnd = value.length;
for (let index = 0; index < value.length; index++) {
if (isPhysicalLineSeparator(value.charCodeAt(index))) {
firstLineEnd = index;
break;
}
}
return stripRecognizedErrorStackTail(value.slice(0, firstLineEnd));
}
/**
* Redact absolute filesystem paths while preserving URLs, explicitly marked
* API routes, and punctuation around determinable endpoints. Unequivocal
* filesystem prefixes fail closed when an unquoted endpoint is ambiguous.
*/
export function redactErrorPaths(value: string): string {
const quotedPathsRedacted = redactQuotedAbsolutePaths(value);
const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted);
const parts = pathSpansRedacted.split(/(\s+)/);
let previousToken = "";
for (let index = 0; index < parts.length; index++) {
const token = parts[index];
if (isWhitespace(token)) continue;
parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken));
previousToken = token;
}
return parts.join("");
}

View File

@@ -0,0 +1,895 @@
import {
redactErrorPaths,
stripErrorStackTail,
stripRecognizedErrorStackTail,
} from "./errorPathRedaction.ts";
import { CREDENTIAL_PATTERNS } from "./credentialPatterns.ts";
// Length cap protects against pathological inputs even before tokenization.
const MAX_ERROR_LEN = 4096;
const MAX_ERROR_SCAN_HEADROOM = 512;
const MAX_SECURITY_ESCAPE_LAYERS = 3;
const STRONG_CREDENTIAL_TOKEN_SOURCE =
"(?:eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|" +
"github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|" +
"xox[a-z]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|" +
"(?<![A-Za-z0-9])sk[-_][A-Za-z0-9._~+/=-]{8,}|" +
"[A-Za-z0-9]{3,}sk[-_][A-Za-z0-9._~+/=-]{8,})";
const STRONG_CREDENTIAL_TOKEN = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "i");
const STRONG_CREDENTIAL_TOKEN_GLOBAL = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "gi");
export function containsStrongCredentialToken(value: string): boolean {
return STRONG_CREDENTIAL_TOKEN.test(value);
}
const CREDENTIAL_LABELS = [
["__secure-next-auth.session-token", true],
["arena-auth-prod-v1", true],
["__cf_bm", true],
["_cfuvid", true],
["_puid", true],
["access_token_v2", true],
["token_v2", true],
["tokenv2", true],
["cf_clearance", true],
["credentials", true],
["credential", true],
["session id", true],
["session-id", true],
["session_id", true],
["sessionid", true],
["encryption key", true],
["encryption-key", true],
["encryption_key", true],
["encryptionkey", true],
["private key", true],
["private-key", true],
["private_key", true],
["privatekey", true],
["session key", true],
["session-key", true],
["session_key", true],
["sessionkey", true],
["secret key", true],
["secret-key", true],
["secret_key", true],
["secretkey", true],
["signing key", true],
["signing-key", true],
["signing_key", true],
["signingkey", true],
["refresh token", false],
["refresh-token", false],
["refresh_token", false],
["refreshtoken", false],
["access token", false],
["access-token", false],
["access_token", false],
["accesstoken", false],
["authorization", true],
["sso-rw", true],
["session", true],
["sso", true],
["api key", false],
["api-key", false],
["api_key", false],
["apikey", false],
["password", true],
["cookie", true],
["secret", true],
["token", false],
] as const;
type CredentialAssignment = {
valueStart: number;
failClosed: boolean;
};
function isAsciiAlphaNumericCode(code: number): boolean {
return (
(code >= 0x30 && code <= 0x39) ||
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a)
);
}
function asciiHexValue(code: number): number {
if (code >= 0x30 && code <= 0x39) return code - 0x30;
if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10;
if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10;
return -1;
}
function unicodeEscapeCodeAt(value: string, start: number): number | null {
if (
value.charCodeAt(start) !== 0x5c ||
(value[start + 1] !== "u" && value[start + 1] !== "U") ||
start + 5 >= value.length
) {
return null;
}
let decoded = 0;
for (let digit = start + 2; digit <= start + 5; digit++) {
const nibble = asciiHexValue(value.charCodeAt(digit));
if (nibble < 0) return null;
decoded = decoded * 16 + nibble;
}
return decoded;
}
function isPrintableAscii(code: number | null): code is number {
return code !== null && code >= 0x20 && code <= 0x7e;
}
function isSecurityWhitespaceCode(code: number | null): boolean {
return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
}
function isEscapeTokenBoundary(code: number): boolean {
return !isAsciiAlphaNumericCode(code) && code !== 0x2e && code !== 0x5f && code !== 0x2d;
}
function shouldPreserveUnicodeUncEvidence(
value: string,
runStart: number,
runEnd: number,
decoded: number
): boolean {
if (
runEnd - runStart < 2 ||
decoded === 0x2f ||
decoded === 0x5c ||
decoded === 0x3a ||
(runStart > 0 && !isEscapeTokenBoundary(value.charCodeAt(runStart - 1)))
) {
return false;
}
const afterEscape = runEnd + 5;
let tokenEnd = afterEscape;
while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
if (value.slice(afterEscape, tokenEnd).includes("=")) return false;
return afterEscape < tokenEnd;
}
function decodeSecurityEscapesOnce(
value: string,
decodeQuotes: boolean,
maxLength: number
): string {
const output: string[] = [];
let changed = false;
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) !== 0x5c) {
output.push(value[index]);
continue;
}
const runStart = index;
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
const runEnd = index;
if (runEnd >= value.length) {
output.push(value.slice(runStart));
break;
}
const escaped = value[runEnd];
if (escaped === "u" || escaped === "U") {
const decoded = unicodeEscapeCodeAt(value, runEnd - 1);
const isQuote = decoded === 0x22 || decoded === 0x27;
if (isSecurityWhitespaceCode(decoded)) {
output.push(" ");
index = runEnd + 4;
changed = true;
continue;
}
if (
isPrintableAscii(decoded) &&
(decodeQuotes || !isQuote) &&
!shouldPreserveUnicodeUncEvidence(value, runStart, runEnd, decoded)
) {
output.push(String.fromCharCode(decoded));
index = runEnd + 4;
changed = true;
continue;
}
output.push(value.slice(runStart, runEnd + 5));
index = runEnd + 4;
continue;
}
if (
escaped === "b" ||
escaped === "f" ||
escaped === "n" ||
escaped === "r" ||
escaped === "t"
) {
output.push(" ");
index = runEnd;
changed = true;
continue;
}
if (escaped === "/" || (decodeQuotes && (escaped === '"' || escaped === "'"))) {
output.push(escaped);
index = runEnd;
changed = true;
continue;
}
output.push(value.slice(runStart, runEnd));
index = runEnd - 1;
}
return changed ? output.join("").slice(0, maxLength) : value;
}
function hasResidualSecurityEscape(value: string): boolean {
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) !== 0x5c) continue;
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
if (index >= value.length) return false;
const escaped = value[index];
if (
escaped === "b" ||
escaped === "f" ||
escaped === "n" ||
escaped === "r" ||
escaped === "t"
) {
return true;
}
if (escaped === "/" || escaped === '"' || escaped === "'") return true;
if (escaped === "u" || escaped === "U") {
const decoded = unicodeEscapeCodeAt(value, index - 1);
if (isPrintableAscii(decoded) || isSecurityWhitespaceCode(decoded)) return true;
}
}
return false;
}
/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */
function normalizeSecurityEscapes(
value: string,
decodeQuotes: boolean,
maxLength = MAX_ERROR_LEN
): string {
let normalized = value.slice(0, maxLength);
for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) {
const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes, maxLength);
if (decoded === normalized) break;
normalized = decoded.slice(0, maxLength);
}
return normalized;
}
function isCredentialLabelBoundary(code: number): boolean {
return !isAsciiAlphaNumericCode(code) && code !== 0x5f && code !== 0x2d;
}
function matchCredentialAssignmentAt(value: string, start: number): CredentialAssignment | null {
const keyQuote = value[start] === '"' || value[start] === "'" ? value[start] : "";
const labelStart = start + (keyQuote ? 1 : 0);
const cliFlag =
!keyQuote &&
labelStart >= 2 &&
value.slice(labelStart - 2, labelStart) === "--" &&
(labelStart === 2 || isCredentialLabelBoundary(value.charCodeAt(labelStart - 3)));
for (const [label, failClosed] of CREDENTIAL_LABELS) {
const labelEnd = labelStart + label.length;
if (value.slice(labelStart, labelEnd).toLowerCase() !== label) continue;
let index = labelEnd;
if (
(label === "arena-auth-prod-v1" || label === "__secure-next-auth.session-token") &&
value[index] === "."
) {
const chunkStart = ++index;
while (index < value.length && /\d/.test(value[index])) index++;
if (index === chunkStart) continue;
}
if (keyQuote) {
if (value[index] !== keyQuote) continue;
index++;
} else if (!isCredentialLabelBoundary(value.charCodeAt(index))) {
continue;
} else if (value[index] === '"' || value[index] === "'") {
index++;
}
const separatorStart = index;
while (/\s/.test(value[index])) index++;
if (value[index] === ":" || value[index] === "=") {
index++;
while (/\s/.test(value[index])) index++;
} else if (!(cliFlag && index > separatorStart)) {
continue;
}
return { valueStart: index, failClosed };
}
return null;
}
function findQuotedCredentialEnd(value: string, start: number, quote: string): number {
let index = start + 1;
while (index < value.length) {
if (value.charCodeAt(index) === 0x5c) {
index += 2;
continue;
}
if (value[index] === quote) return index;
index++;
}
return -1;
}
function findUnquotedCredentialEnd(value: string, start: number): number {
let end = start;
while (end < value.length) {
const char = value[end];
if (/\s/.test(char) || char === '"' || char === "'" || char === "," || char === "}") break;
end++;
}
return end;
}
function redactLabeledCredentialAssignments(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const assignment = matchCredentialAssignmentAt(value, index);
if (!assignment) {
index++;
continue;
}
const { valueStart, failClosed } = assignment;
const quote = value[valueStart] === '"' || value[valueStart] === "'" ? value[valueStart] : "";
if (quote) {
const closingQuote = findQuotedCredentialEnd(value, valueStart, quote);
parts.push(value.slice(copyStart, valueStart + 1), "[REDACTED]");
if (closingQuote < 0) {
copyStart = value.length;
index = value.length;
} else {
parts.push(quote);
copyStart = closingQuote + 1;
index = copyStart;
}
continue;
}
// A leading backslash may be a serialized quote or another encoded
// delimiter. Do not redact only that prefix and leave the value behind.
const valueEnd =
failClosed || value.charCodeAt(valueStart) === 0x5c
? value.length
: findUnquotedCredentialEnd(value, valueStart);
parts.push(value.slice(copyStart, valueStart), "[REDACTED]");
copyStart = valueEnd;
index = Math.max(valueEnd, valueStart + 1);
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function redactPrivateKeyPemBlocks(value: string): string {
// ASCII-only fold keeps offsets aligned even when the surrounding message
// contains Unicode characters whose full uppercase form expands in length.
const upperValue = value.replace(/[a-z]/g, (char) => char.toUpperCase());
const beginPrefix = "-----BEGIN ";
const parts: string[] = [];
let copyStart = 0;
let searchStart = 0;
while (searchStart < value.length) {
const blockStart = upperValue.indexOf(beginPrefix, searchStart);
if (blockStart < 0) break;
const labelStart = blockStart + beginPrefix.length;
const headerEnd = upperValue.indexOf("-----", labelStart);
if (headerEnd < 0) break;
const label = upperValue.slice(labelStart, headerEnd).trim();
if (!/^(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?$/.test(label)) {
searchStart = headerEnd + 5;
continue;
}
const endMarker = `-----END ${label}-----`;
const closingStart = upperValue.indexOf(endMarker, headerEnd + 5);
const blockEnd = closingStart < 0 ? value.length : closingStart + endMarker.length;
parts.push(value.slice(copyStart, blockStart), "[REDACTED]");
copyStart = blockEnd;
searchStart = blockEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
const DATA_URL_PREFIX = "data:";
const BASE64_DATA_URL_MARKER = ";base64";
const REDACTED_DATA_URL = "[REDACTED_DATA_URL]";
function matchesAsciiCaseInsensitiveAt(value: string, start: number, expected: string): boolean {
if (start < 0 || start + expected.length > value.length) return false;
for (let offset = 0; offset < expected.length; offset++) {
const code = value.charCodeAt(start + offset);
const foldedCode = code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
if (foldedCode !== expected.charCodeAt(offset)) return false;
}
return true;
}
function isBase64DataUrlPayloadCode(code: number): boolean {
return (
isAsciiAlphaNumericCode(code) ||
code === 0x2b ||
code === 0x2f ||
code === 0x3d ||
code === 0x5f ||
code === 0x2d
);
}
function isEcmaScriptWhitespaceCode(code: number): boolean {
return (
(code >= 0x09 && code <= 0x0d) ||
code === 0x20 ||
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
);
}
/** Redact base64 data URLs in one pass, including input with many repeated `data:` prefixes. */
function redactBase64DataUrls(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
if (!matchesAsciiCaseInsensitiveAt(value, index, DATA_URL_PREFIX)) {
index++;
continue;
}
const dataUrlStart = index;
const mediaTypeStart = dataUrlStart + DATA_URL_PREFIX.length;
let delimiter = mediaTypeStart;
while (
delimiter < value.length &&
value[delimiter] !== "," &&
!isEcmaScriptWhitespaceCode(value.charCodeAt(delimiter))
) {
delimiter++;
}
const markerStart = delimiter - BASE64_DATA_URL_MARKER.length;
const hasBase64Marker =
delimiter < value.length &&
value[delimiter] === "," &&
markerStart >= mediaTypeStart &&
matchesAsciiCaseInsensitiveAt(value, markerStart, BASE64_DATA_URL_MARKER);
if (!hasBase64Marker) {
index = delimiter < value.length ? delimiter + 1 : value.length;
continue;
}
let payloadEnd = delimiter + 1;
while (payloadEnd < value.length && isBase64DataUrlPayloadCode(value.charCodeAt(payloadEnd))) {
payloadEnd++;
}
if (payloadEnd === delimiter + 1) {
index = delimiter + 1;
continue;
}
parts.push(value.slice(copyStart, dataUrlStart), REDACTED_DATA_URL);
copyStart = payloadEnd;
index = payloadEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
const HTTP_URL_RE = /https?:\/\//gi;
const URL_QUERY_PARAM_RE = /([?&])([^=&#]+)=([^&#]*)/g;
function isUrlTerminator(char: string): boolean {
return (
/\s/.test(char) ||
char === '"' ||
char === "'" ||
char === "`" ||
char === "<" ||
char === ">" ||
char === ")" ||
char === "]" ||
char === "}" ||
char === "," ||
char === ";"
);
}
function normalizeUrlQueryKey(key: string): string {
let decoded = key.replace(/\+/g, " ");
try {
decoded = decodeURIComponent(decoded);
} catch {
// Malformed percent escapes stay visible to the conservative ASCII fold.
}
return decoded.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
}
function isSensitiveUrlQueryKey(key: string): boolean {
const normalized = normalizeUrlQueryKey(key);
return (
normalized === "sig" ||
normalized === "signature" ||
normalized === "key" ||
normalized === "apikey" ||
normalized === "token" ||
normalized === "accesstoken" ||
normalized === "refreshtoken" ||
normalized === "credential" ||
normalized === "password" ||
normalized === "secret" ||
normalized === "awsaccesskeyid" ||
normalized === "googleaccessid" ||
normalized === "xamzcredential" ||
normalized === "xamzsignature" ||
normalized === "xamzsecuritytoken" ||
normalized === "xgoogcredential" ||
normalized === "xgoogsignature"
);
}
function redactUrlSegment(segment: string): string {
const schemeEnd = segment.indexOf("//") + 2;
let authorityEnd = segment.length;
for (const delimiter of ["/", "?", "#"]) {
const candidate = segment.indexOf(delimiter, schemeEnd);
if (candidate >= 0) authorityEnd = Math.min(authorityEnd, candidate);
}
let redacted = segment;
const userInfoEnd = segment.lastIndexOf("@", authorityEnd);
if (userInfoEnd >= schemeEnd) {
redacted = `${segment.slice(0, schemeEnd)}[REDACTED]@${segment.slice(userInfoEnd + 1)}`;
}
URL_QUERY_PARAM_RE.lastIndex = 0;
return redacted.replace(URL_QUERY_PARAM_RE, (match, separator: string, key: string) =>
isSensitiveUrlQueryKey(key) ? `${separator}redacted=[REDACTED]` : match
);
}
function redactSensitiveUrlCredentials(value: string): string {
HTTP_URL_RE.lastIndex = 0;
const parts: string[] = [];
let copyStart = 0;
let match = HTTP_URL_RE.exec(value);
while (match) {
const start = match.index;
let end = HTTP_URL_RE.lastIndex;
while (end < value.length && !isUrlTerminator(value[end])) end++;
const segment = value.slice(start, end);
const redacted = redactUrlSegment(segment);
if (redacted !== segment) {
parts.push(value.slice(copyStart, start), redacted);
copyStart = end;
}
HTTP_URL_RE.lastIndex = Math.max(end, HTTP_URL_RE.lastIndex);
match = HTTP_URL_RE.exec(value);
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function redactKnownCredentialPatterns(value: string): string {
let redacted = value;
for (const pattern of CREDENTIAL_PATTERNS) {
if (pattern.name === "auth_header") continue;
pattern.regex.lastIndex = 0;
redacted = redacted.replace(pattern.regex, "[REDACTED]");
}
return redacted;
}
export function redactSensitiveErrorText(value: string): string {
const normalized = normalizeSecurityEscapes(
value,
false,
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
);
const catalogRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized));
const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(catalogRedacted))
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
return redactLabeledCredentialAssignments(commonCredentialsRedacted);
}
export function containsSensitiveErrorCredential(value: string): boolean {
const normalized = normalizeSecurityEscapes(
value,
false,
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
);
const directRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized))
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
if (directRedacted !== normalized) return true;
if (
/(?:^|\s)--(?:api[-_]?key|token|password|secret)\s+(?:"[^"]*"|'[^']*'|\S+)/i.test(normalized)
) {
return true;
}
return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["']?[^"'\\,\s}]{6,}/i.test(
normalized
);
}
function coerceErrorText(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return String(value);
} catch {
// Fail closed when an attacker-controlled toString/valueOf accessor throws.
return "";
}
}
function truncateSanitizedErrorText(value: string): string {
if (value.length <= MAX_ERROR_LEN) return value;
const markerStart = value.lastIndexOf("[REDACTED", MAX_ERROR_LEN);
const markerEnd = markerStart >= 0 ? value.indexOf("]", markerStart) : -1;
if (
markerStart >= 0 &&
markerStart < MAX_ERROR_LEN &&
markerEnd >= MAX_ERROR_LEN &&
markerEnd - markerStart <= 128
) {
const marker = value.slice(markerStart, markerEnd + 1);
return `${value.slice(0, MAX_ERROR_LEN - marker.length)}${marker}`;
}
return value.slice(0, MAX_ERROR_LEN);
}
/**
* Strip stack-trace tails, credentials, and absolute source paths from a
* client-visible error message.
*/
function sanitizeErrorMessageWithStackPolicy(
message: unknown,
stripStackTail: (value: string) => string
): string {
let str = coerceErrorText(message);
if (str.length > MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM) {
str = str.slice(0, MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM);
}
// Preserve quote provenance until hidden labels/delimiters have been
// exposed and redacted, then decode safe quote escapes in the clean text.
// Raw URI credentials must be projected before the path tokenizer consumes
// the URI tail; Windows path evidence still stays intact until after this
// credential-only pass and is redacted before escape normalization.
str = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(stripStackTail(str)));
str = redactErrorPaths(str);
str = redactSensitiveErrorText(str);
str = truncateSanitizedErrorText(str);
str = normalizeSecurityEscapes(str, false);
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
str = normalizeSecurityEscapes(str, true);
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
return hasResidualSecurityEscape(str) ? "[REDACTED]" : str.trimEnd();
}
export function sanitizeErrorMessage(message: unknown): string {
return sanitizeErrorMessageWithStackPolicy(message, stripErrorStackTail);
}
function sanitizePassthroughErrorMessage(message: unknown): string {
return sanitizeErrorMessageWithStackPolicy(message, stripRecognizedErrorStackTail);
}
const BLOCKED_KEYS =
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie|credential|session(?!_?(?:count|status)$)/i;
const BLOCKED_CREDENTIAL_ALIAS_KEYS =
/^(?:cf_clearance|__cf_bm|_cfuvid|_puid|sso|sso-rw|arena-auth-prod-v1(?:\.\d+)?)$/i;
const PROTOTYPE_CONTROL_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 4;
const MAX_UPSTREAM_KEY_LEN = 256;
type UpstreamClassificationKey = "code" | "reason" | "status" | "type";
const SAFE_UPSTREAM_STATUS_IDENTIFIERS = new Set([
"ABORTED",
"ALREADY_EXISTS",
"CANCELLED",
"DATA_LOSS",
"DEADLINE_EXCEEDED",
"FAILED_PRECONDITION",
"INTERNAL",
"INVALID_ARGUMENT",
"NOT_FOUND",
"OK",
"OUT_OF_RANGE",
"PERMISSION_DENIED",
"RESOURCE_EXHAUSTED",
"UNAUTHENTICATED",
"UNAVAILABLE",
"UNIMPLEMENTED",
"UNKNOWN",
]);
const SAFE_UPSTREAM_ERROR_IDENTIFIERS = new Set([
"api_error",
"auth_error",
"authentication_error",
"bad_gateway",
"bad_request",
"billing_error",
"context_length_exceeded",
"error",
"gateway_timeout",
"insufficient_quota",
"invalid_api_key",
"invalid_request",
"invalid_request_error",
"model_not_found",
"not_found",
"payment_required",
"permission_error",
"provider_error",
"quota_exhausted",
"rate_limit_error",
"rate_limit_exceeded",
"server_error",
"upstream_error",
"upstream_timeout",
]);
function describeOpaqueBinaryDetail(value: ArrayBuffer | ArrayBufferView): string {
return `[binary ${value.byteLength} bytes]`;
}
function normalizeUpstreamClassificationKey(key: string): UpstreamClassificationKey | null {
const normalized = key.replace(/[-_]/g, "").toLowerCase();
if (normalized === "code" || normalized === "errorcode") return "code";
if (normalized === "reason" || normalized === "errorreason") return "reason";
if (
normalized === "status" ||
normalized === "statuscode" ||
normalized === "errorstatus" ||
normalized === "errorstatuscode"
) {
return "status";
}
if (normalized === "type" || normalized === "errortype" || normalized === "subtype") {
return "type";
}
return null;
}
function projectUpstreamErrorIdentifier(key: UpstreamClassificationKey, value: unknown): unknown {
if (typeof value === "number") {
if (!Number.isInteger(value)) return undefined;
if (key === "code" && value >= 0 && value <= 16) return value;
return (key === "code" || key === "status") && value >= 100 && value <= 599 ? value : undefined;
}
if (typeof value !== "string") return undefined;
if (key === "status" && SAFE_UPSTREAM_STATUS_IDENTIFIERS.has(value.toUpperCase())) {
return value;
}
if (
/^[1-5]\d{2}$/.test(value) ||
/^HTTP_[1-5]\d{2}$/i.test(value) ||
SAFE_UPSTREAM_ERROR_IDENTIFIERS.has(value.toLowerCase())
) {
return value;
}
if (key === "type") return "upstream_error";
if (key === "code") return "";
return undefined;
}
function isSafeUpstreamDetailKey(key: string): boolean {
if (
key.length === 0 ||
key.length > MAX_UPSTREAM_KEY_LEN ||
BLOCKED_KEYS.test(key) ||
BLOCKED_CREDENTIAL_ALIAS_KEYS.test(key) ||
PROTOTYPE_CONTROL_KEYS.has(key.toLowerCase())
) {
return false;
}
return sanitizeErrorMessage(key) === key;
}
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* Unsafe keys are dropped rather than renamed so sanitized-key collisions
* cannot restore a secret under a public placeholder.
*/
function sanitizeUpstreamDetailsInternal(
value: unknown,
depth: number,
preserveSafeMultiline: boolean,
projectClassification: boolean
): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") {
return preserveSafeMultiline
? sanitizePassthroughErrorMessage(value)
: sanitizeErrorMessage(value);
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "object") {
try {
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return describeOpaqueBinaryDetail(value);
}
if (Array.isArray(value)) {
return value
.slice(0, 32)
.map((entry) =>
sanitizeUpstreamDetailsInternal(
entry,
depth + 1,
preserveSafeMultiline,
projectClassification
)
);
}
const out = Object.create(null) as Record<string, unknown>;
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
if (!isSafeUpstreamDetailKey(key)) continue;
const normalizedKey = key.toLowerCase();
const classificationKey = normalizeUpstreamClassificationKey(normalizedKey);
if (projectClassification && classificationKey) {
const projected = projectUpstreamErrorIdentifier(classificationKey, entryValue);
if (projected !== undefined) out[key] = projected;
continue;
}
const childProjectsClassification =
normalizedKey === "error" ||
normalizedKey === "errors" ||
normalizedKey === "warning" ||
normalizedKey === "warnings";
out[key] = sanitizeUpstreamDetailsInternal(
entryValue,
depth + 1,
preserveSafeMultiline,
childProjectsClassification
);
}
return out;
} catch {
return null;
}
}
return null;
}
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
return sanitizeUpstreamDetailsInternal(value, depth, false, depth === 0);
}
/** Provider-only projection that preserves safe multiline capability wording. */
export function sanitizePassthroughUpstreamDetails(value: unknown, depth = 0): unknown {
return sanitizeUpstreamDetailsInternal(value, depth, true, depth === 0);
}

View File

@@ -9,6 +9,7 @@ import {
stripResponsesLifecycleEcho,
} from "./responsesStreamHelpers.ts";
import { getAnyReasoningValue } from "./reasoningFields.ts";
import { projectStreamFailureEvent, type StreamFailurePayload } from "./streamErrorFormat.ts";
type JsonRecord = Record<string, unknown>;
@@ -47,6 +48,7 @@ export type PassthroughTailProcessorContext = {
hasPassthroughToolCalls: () => boolean;
toResponsesCompletedWithToolCalls: (parsed: JsonRecord) => JsonRecord;
restoreOpenAIToolNames: (parsed: JsonRecord) => boolean;
abortFailure: (failure: StreamFailurePayload, publicMessage: string) => void;
};
function asRecord(value: unknown): JsonRecord {
@@ -284,7 +286,13 @@ export function processBufferedPassthroughLine(
context.updateClaudeEmptyResponseLifecycle(parsedPassthroughData);
}
const parsed = parsedPassthroughData as JsonRecord;
const projectedFailure = projectStreamFailureEvent(parsedPassthroughData);
const parsed = projectedFailure
? projectedFailure.publicPayload
: (parsedPassthroughData as JsonRecord);
if (projectedFailure) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
}
if (context.sanitizeUsagePayload(parsed)) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
}
@@ -301,6 +309,14 @@ export function processBufferedPassthroughLine(
}
context.pushClientPayload(parsed);
output = context.passthroughEventPrefix.prefixData(output, line);
context.emitConvertedOutput(output);
if (projectedFailure) {
context.abortFailure(projectedFailure.internalFailure, projectedFailure.publicMessage);
return true;
}
return false;
}
output = context.passthroughEventPrefix.prefixData(output, line);

View File

@@ -0,0 +1,70 @@
type JsonRecord = Record<string, unknown>;
export type ResponsesFailureOutputStringField = "id" | "text" | "refusal";
export type ResponsesFailureOutputStringProjector = (
field: ResponsesFailureOutputStringField,
value: string
) => string;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
/**
* Retain only public assistant text/refusal output from a failed Responses payload.
* Failure envelopes may contain reasoning, tool arguments, annotations, commentary,
* or provider diagnostics, so every retained field is reconstructed explicitly.
*/
export function projectResponsesFailureOutput(
value: unknown,
projectString: ResponsesFailureOutputStringProjector
): JsonRecord[] {
if (!Array.isArray(value)) return [];
const output: JsonRecord[] = [];
for (const item of value) {
const record = asRecord(item);
if (record.type !== "message" || record.role !== "assistant" || record.phase === "commentary") {
continue;
}
const content: JsonRecord[] = [];
if (Array.isArray(record.content)) {
for (const part of record.content) {
const contentPart = asRecord(part);
if (contentPart.phase === "commentary") continue;
if (contentPart.type === "output_text" && typeof contentPart.text === "string") {
content.push({
type: "output_text",
text: projectString("text", contentPart.text),
// Preserve the required Responses schema without forwarding any
// untrusted citation/file metadata supplied by the provider.
annotations: [],
});
} else if (contentPart.type === "refusal" && typeof contentPart.refusal === "string") {
content.push({
type: "refusal",
refusal: projectString("refusal", contentPart.refusal),
});
}
}
}
const projected: JsonRecord = {
type: "message",
role: "assistant",
content,
};
if (typeof record.id === "string") projected.id = projectString("id", record.id);
if (
record.status === "in_progress" ||
record.status === "completed" ||
record.status === "incomplete"
) {
projected.status = record.status;
}
output.push(projected);
}
return output;
}

View File

@@ -50,9 +50,11 @@ import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./te
import { stripObfuscationZeroWidth } from "./zeroWidth.ts";
import {
formatTranslatedStreamError,
normalizeStreamFailurePayload,
prepareTranslatedStreamFailure,
projectStreamFailureEvent,
type StreamFailurePayload,
} from "./streamErrorFormat.ts";
import { createStreamFailureAborter } from "./streamFailureBoundary.ts";
import { recordToolLatency } from "../services/toolLatencyTracker.ts";
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
import {
@@ -178,8 +180,6 @@ type StreamOptions = {
* codex-compatible `namespace` + `name` fields.
*/
requestToolIdentityMap?: Map<string, { namespace: string; name: string }> | null;
/** High water mark for the TransformStream internal buffer (default: 16384) */
highWaterMark?: number;
};
type TranslateState = ReturnType<typeof initState> & {
@@ -1175,7 +1175,39 @@ export function createSSEStream(options: StreamOptions = {}) {
}
};
const highWaterMark = options.highWaterMark ?? 16384;
const abortStreamFailure = createStreamFailureAborter({
onFailure,
onComplete,
getUsage: () => state?.usage,
timing,
buildProviderPayload: () =>
providerPayloadCollector.build(providerPayloadCollector.getSummary(), {
includeEvents: false,
}),
buildClientPayload: (body) => clientPayloadCollector.build(body, { includeEvents: false }),
clearIdleTimer,
clearPendingRequest: clearPendingRequestFromStream,
markPendingRequestCleared,
model,
});
const emitTranslatedFailureAndAbort = (
controller: TransformStreamDefaultController<Uint8Array>,
payload: unknown
): boolean => {
const failure = prepareTranslatedStreamFailure(payload);
if (!failure) return false;
providerPayloadCollector.push(failure.providerPayload);
const output = formatTranslatedStreamError(failure.record, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
upstreamErrorForwarded = true;
doneSent = true;
abortStreamFailure(controller, failure.internalFailure, failure.publicMessage, {
notifyComplete: true,
});
return true;
};
return new TransformStream(
{
@@ -1241,6 +1273,7 @@ export function createSSEStream(options: StreamOptions = {}) {
let injectedUsage = false;
let clientPayload: unknown = null;
let failurePayload: StreamFailurePayload | null = null;
let publicFailureMessage: string | null = null;
if (skipPassthroughEvent) {
if (!trimmed) {
@@ -1328,6 +1361,14 @@ export function createSSEStream(options: StreamOptions = {}) {
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
let parsed = parsedPassthroughData ?? JSON.parse(trimmed.slice(5).trim());
const projectedFailure = projectStreamFailureEvent(parsed);
if (projectedFailure) {
parsed = projectedFailure.publicPayload;
failurePayload = projectedFailure.internalFailure;
publicFailureMessage = projectedFailure.publicMessage;
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
}
// Some upstream Responses-compatible providers leak an initial Chat Completions
// bootstrap chunk (assistant role + empty content) before emitting proper
@@ -1484,9 +1525,6 @@ export function createSSEStream(options: StreamOptions = {}) {
);
}
}
if (parsed.type === "response.failed") {
failurePayload = normalizeStreamFailurePayload(parsed);
}
if (
parsed.type === "response.reasoning_summary_text.delta" ||
parsed.type === "response.reasoning_summary_text.done" ||
@@ -1810,20 +1848,22 @@ export function createSSEStream(options: StreamOptions = {}) {
const rawDelta = parsed.choices?.[0]?.delta;
const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta);
parsed = sanitizeStreamingChunk(parsed);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
(parsed as Record<string, unknown>)[OMIT_STREAMING_CHUNK_MARKER] === true
) {
continue;
if (!projectedFailure) {
parsed = sanitizeStreamingChunk(parsed);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
(parsed as Record<string, unknown>)[OMIT_STREAMING_CHUNK_MARKER] === true
) {
continue;
}
}
const restoredOpenAIToolName = restoreOpenAIToolNames(parsed, toolNameMap);
const idFixed = hadNonStringTopLevelId ? false : fixInvalidId(parsed);
if (!hasValuableContent(parsed, FORMATS.OPENAI)) {
if (!projectedFailure && !hasValuableContent(parsed, FORMATS.OPENAI)) {
continue;
}
@@ -2052,20 +2092,10 @@ export function createSSEStream(options: StreamOptions = {}) {
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
if (failurePayload) {
let failureHandled = false;
if (onFailure) {
try {
failureHandled = onFailure(failurePayload) === true;
} catch (e) {
console.debug(`[STREAM] onFailure callback error:`, e);
}
}
clearIdleTimer();
if (!failureHandled) {
clearPendingRequestFromStream();
}
controller.error(
markPendingRequestCleared(new Error(failurePayload.message || "Upstream failure"))
abortStreamFailure(
controller,
failurePayload,
publicFailureMessage || "Upstream failure"
);
return;
}
@@ -2087,14 +2117,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (upstreamErrorForwarded) continue;
if (parsed.error) {
const output = formatTranslatedStreamError(parsed, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
upstreamErrorForwarded = true;
doneSent = true;
continue;
}
if (emitTranslatedFailureAndAbort(controller, parsed)) return;
// #5786 — drop replayed Responses-API events (identical/lower sequence_number
// re-sent on an upstream reconnect) so their deltas are not glued twice into
@@ -2356,6 +2379,8 @@ export function createSSEStream(options: StreamOptions = {}) {
]) as JsonRecord,
restoreOpenAIToolNames: (parsed: JsonRecord) =>
restoreOpenAIToolNames(parsed, toolNameMap),
abortFailure: (failure: StreamFailurePayload, publicMessage: string) =>
abortStreamFailure(controller, failure, publicMessage),
};
for (const line of normalizedTailLines) {
@@ -2369,12 +2394,18 @@ export function createSSEStream(options: StreamOptions = {}) {
clearPendingPassthroughEvent();
} else if (buffer) {
let output = buffer;
let bufferedProjectedFailure: ReturnType<typeof projectStreamFailureEvent> = null;
if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) {
output = "data: " + buffer.slice(5);
}
const bufferedPayload = parseSSELine(bufferedLine);
let bufferedPayload = parseSSELine(bufferedLine);
if (bufferedPayload) {
providerPayloadCollector.push(bufferedPayload);
bufferedProjectedFailure = projectStreamFailureEvent(bufferedPayload);
if (bufferedProjectedFailure) {
bufferedPayload = bufferedProjectedFailure.publicPayload;
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
}
if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat))
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
if (
@@ -2423,6 +2454,14 @@ export function createSSEStream(options: StreamOptions = {}) {
}
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
if (bufferedProjectedFailure) {
abortStreamFailure(
controller,
bufferedProjectedFailure.internalFailure,
bufferedProjectedFailure.publicMessage
);
return;
}
}
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
@@ -2673,6 +2712,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
if (emitTranslatedFailureAndAbort(controller, parsed)) return;
providerPayloadCollector.push(parsed);
// Extract usage from remaining buffer — if the usage-bearing event
// (e.g. response.completed) is the last SSE line, it ends up here
@@ -2737,58 +2777,9 @@ export function createSSEStream(options: StreamOptions = {}) {
// terminal signal for the client.
}
let failureHandled = false;
if (onFailure) {
try {
timing.markInterrupted();
failureHandled =
onFailure({
status: err.status,
message: err.message,
code: err.code,
type: err.type,
}) === true;
} catch (e) {
console.debug(`[STREAM] onFailure callback error (${model || "unknown"}):`, e);
}
}
const errorBody = buildErrorBody(err.status, err.message);
if (onComplete) {
try {
onComplete({
status: err.status,
usage: state?.usage,
responseBody: errorBody,
ttft: timing.ttftMs(),
itlMs: timing.avgItlMs(),
interrupted: timing.interrupted,
error: err.message,
errorCode: err.code,
providerPayload: providerPayloadCollector.build(
providerPayloadCollector.getSummary(),
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(errorBody, {
includeEvents: false,
}),
});
failureHandled = true;
} catch (e) {
console.debug(
`[STREAM] onComplete callback error in error path (${model || "unknown"}):`,
e
);
}
}
clearIdleTimer();
if (!failureHandled) {
clearPendingRequestFromStream();
}
controller.error(
markPendingRequestCleared(new Error(err.message || "Upstream failure"))
);
const publicErrorMessage = errorBody.error.message;
abortStreamFailure(controller, err, publicErrorMessage, { notifyComplete: true });
return;
}
@@ -2996,8 +2987,8 @@ export function createSSEStream(options: StreamOptions = {}) {
clearIdleTimer();
},
},
{ highWaterMark },
{ highWaterMark }
{ highWaterMark: 16384 },
{ highWaterMark: 16384 }
);
}
@@ -3019,8 +3010,7 @@ export function createSSETransformStreamWithLogger(
copilotCompatibleReasoning = false,
suppressThinkClose = false,
customToolNames: ReadonlySet<string> = new Set(),
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null,
highWaterMark?: number
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
@@ -3039,7 +3029,6 @@ export function createSSETransformStreamWithLogger(
suppressThinkClose,
customToolNames,
requestToolIdentityMap,
highWaterMark,
});
}
@@ -3054,8 +3043,7 @@ export function createPassthroughStreamWithLogger(
apiKeyInfo: unknown = null,
onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null = null,
clientResponseFormat: string | null = null,
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null,
highWaterMark?: number
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
@@ -3070,7 +3058,6 @@ export function createPassthroughStreamWithLogger(
onFailure,
clientResponseFormat,
requestToolIdentityMap,
highWaterMark,
});
}

View File

@@ -1,5 +1,6 @@
import { FORMATS } from "../translator/formats.ts";
import { buildErrorBody } from "./error.ts";
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
import { projectResponsesFailureOutput } from "./responsesFailureOutput.ts";
/**
* Upstream stream-failure normalization + client-format error framing.
@@ -17,10 +18,125 @@ export type StreamFailurePayload = {
type?: string;
};
export type ProjectedStreamFailureEvent = {
internalFailure: StreamFailurePayload;
publicMessage: string;
publicPayload: JsonRecord;
};
export type PreparedTranslatedStreamFailure = {
record: JsonRecord;
providerPayload: JsonRecord;
internalFailure: StreamFailurePayload;
publicMessage: string;
};
export function projectCompletedStreamError(
failure: StreamFailurePayload | null | undefined
): JsonRecord | null {
if (!failure) return null;
const status = Number.isInteger(failure.status) ? failure.status : 502;
return buildErrorBody(status, failure.message, undefined, {
type: failure.type ?? "server_error",
code: String(failure.status ?? 502),
}).error;
}
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
const RESPONSES_FAILURE_SCALAR_FIELDS = [
"id",
"object",
"created_at",
"completed_at",
"background",
"model",
"max_output_tokens",
"max_tool_calls",
"parallel_tool_calls",
"previous_response_id",
"service_tier",
"store",
"temperature",
"top_p",
"truncation",
] as const;
const ABSOLUTE_PATH_SEGMENT =
/(?:^|[\\/])(?:Users|app|etc|home|opt|private|root|srv|tmp|usr|var|workspace)[\\/]/i;
function projectResponsesFailureString(key: string, value: string): string {
const sanitized = sanitizeErrorMessage(value);
if (sanitized !== value || ABSOLUTE_PATH_SEGMENT.test(value)) return "[REDACTED]";
if (
(key === "id" || key === "previous_response_id") &&
!/^[A-Za-z0-9][\w.:-]{0,511}$/.test(value)
) {
return "[REDACTED]";
}
if (key === "model" && !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) {
return "[REDACTED]";
}
return sanitized;
}
function projectResponsesFailureUsage(value: unknown): JsonRecord | null {
const usage = asRecord(value);
const projected: JsonRecord = {};
for (const key of ["input_tokens", "output_tokens", "total_tokens"] as const) {
if (typeof usage[key] === "number" && Number.isFinite(usage[key])) {
projected[key] = usage[key];
}
}
const allowedDetailFields = {
input_tokens_details: new Set(["cached_tokens"]),
output_tokens_details: new Set([
"reasoning_tokens",
"accepted_prediction_tokens",
"rejected_prediction_tokens",
]),
} as const;
for (const key of ["input_tokens_details", "output_tokens_details"] as const) {
const details = asRecord(usage[key]);
const projectedDetails = Object.fromEntries(
Object.entries(details).filter(
([detailKey, detail]) =>
allowedDetailFields[key].has(detailKey) &&
typeof detail === "number" &&
Number.isFinite(detail)
)
);
if (Object.keys(projectedDetails).length > 0) projected[key] = projectedDetails;
}
return Object.keys(projected).length > 0 ? projected : null;
}
function projectResponsesFailureObject(response: JsonRecord, publicError: JsonRecord): JsonRecord {
const projected: JsonRecord = { status: "failed", error: publicError };
// A failed Responses event is an error boundary, so copy only documented protocol
// fields with their scalar shapes. Spreading the upstream object would also publish
// provider-only siblings such as diagnostics, settings, raw messages, or stack traces.
for (const key of RESPONSES_FAILURE_SCALAR_FIELDS) {
const value = response[key];
if (typeof value === "string") projected[key] = projectResponsesFailureString(key, value);
else if (value === null || typeof value === "number" || typeof value === "boolean")
projected[key] = value;
}
if (Array.isArray(response.output)) {
projected.output = projectResponsesFailureOutput(
response.output,
projectResponsesFailureString
);
}
const usage = projectResponsesFailureUsage(response.usage);
if (usage) projected.usage = usage;
if ("last_error" in response) projected.last_error = publicError;
return projected;
}
function toStreamFailureStatus(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599) {
return value;
@@ -48,19 +164,30 @@ function looksLikeStreamRateLimit(code: string, type: string, message: string):
export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePayload | null {
const record = payload && typeof payload === "object" ? (payload as JsonRecord) : {};
const response = asRecord(record.response);
const error = Object.keys(asRecord(response.error)).length
? asRecord(response.error)
: Object.keys(asRecord(record.error)).length
? asRecord(record.error)
: record;
const responseError = response.error;
const responseLastError = response.last_error;
const rootError = record.error;
const error = Object.keys(asRecord(responseError)).length
? asRecord(responseError)
: Object.keys(asRecord(responseLastError)).length
? asRecord(responseLastError)
: Object.keys(asRecord(rootError)).length
? asRecord(rootError)
: record;
const code = typeof error.code === "string" ? error.code : "upstream_error";
const type = typeof error.type === "string" ? error.type : undefined;
const message =
typeof error.message === "string" && error.message.trim()
? error.message
: typeof record.message === "string" && record.message.trim()
? record.message
: "Upstream failure";
: typeof responseError === "string" && responseError.trim()
? responseError
: typeof responseLastError === "string" && responseLastError.trim()
? responseLastError
: typeof rootError === "string" && rootError.trim()
? rootError
: typeof record.message === "string" && record.message.trim()
? record.message
: "Upstream failure";
const status =
toStreamFailureStatus(error.status_code) ??
toStreamFailureStatus(error.status) ??
@@ -78,6 +205,80 @@ export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePa
};
}
export function prepareTranslatedStreamFailure(
payload: unknown
): PreparedTranslatedStreamFailure | null {
const record = asRecord(payload);
const projected = projectStreamFailureEvent(record);
if (!projected && !record.error) return null;
return {
record,
providerPayload: projected?.publicPayload ?? record,
internalFailure: projected?.internalFailure ??
normalizeStreamFailurePayload(record) ?? {
status: 502,
message: "Upstream failure",
code: "stream_error",
type: "server_error",
},
publicMessage: projected?.publicMessage || "Upstream failure",
};
}
/**
* Project same-format upstream failure events before they cross the client/log boundary.
*
* `internalFailure` intentionally retains the raw provider wording: account fallback uses it
* to classify quota/reset hints before the persistence seam sanitizes the stored message.
* `publicPayload` is a separate protocol-preserving object whose failure subtrees are rebuilt by
* the canonical public boundary. Callers must never forward the raw payload for these events.
*/
export function projectStreamFailureEvent(payload: unknown): ProjectedStreamFailureEvent | null {
const record = asRecord(payload);
const response = asRecord(record.response);
const hasRootError =
Object.keys(asRecord(record.error)).length > 0 ||
(typeof record.error === "string" && record.error.trim().length > 0);
const isResponsesFailure =
record.type === "response.failed" ||
(record.type === "response.completed" && response.status === "failed");
const isClaudeFailure = record.type === "error";
if (!isResponsesFailure && !isClaudeFailure && !hasRootError) return null;
const internalFailure = normalizeStreamFailurePayload(record);
if (!internalFailure) return null;
const publicError = buildErrorBody(internalFailure.status, internalFailure.message, undefined, {
type: internalFailure.type ?? "server_error",
code: internalFailure.code ?? "stream_error",
}).error;
let publicPayload: JsonRecord;
if (isResponsesFailure) {
// Preserve protocol metadata and partial `output[].content[]` without passing output
// through a bounded-depth details sanitizer, while excluding arbitrary diagnostic siblings.
const publicResponse = projectResponsesFailureObject(response, publicError);
publicPayload = {
type: record.type,
response: publicResponse,
...(typeof record.sequence_number === "number"
? { sequence_number: record.sequence_number }
: {}),
};
} else if (isClaudeFailure) {
publicPayload = { type: "error", error: publicError };
} else {
// OpenAI-compatible HTTP-200 streams commonly emit a bare `{ error: ... }` frame.
// Rebuild the complete public envelope so provider-only fields cannot cross the wire.
publicPayload = { error: publicError };
}
return {
internalFailure,
publicMessage: publicError.message,
publicPayload,
};
}
export function formatTranslatedStreamError(payload: unknown, sourceFormat?: string): string {
const failure = normalizeStreamFailurePayload(payload) ?? {
status: 502,

View File

@@ -0,0 +1,76 @@
import { buildErrorBody } from "./error.ts";
import type { StreamFailurePayload } from "./streamErrorFormat.ts";
import type { StreamTiming } from "./streamTiming.ts";
type CompletePayload = {
status: number;
usage: unknown;
responseBody: unknown;
providerPayload: unknown;
clientPayload: unknown;
error: string;
errorCode?: string;
ttft: number | null;
itlMs: number | null;
interrupted: boolean;
};
type AborterContext = {
onFailure?: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null;
onComplete?: ((payload: CompletePayload) => void) | null;
getUsage: () => unknown;
timing: StreamTiming;
buildProviderPayload: () => unknown;
buildClientPayload: (body: unknown) => unknown;
clearIdleTimer: () => void;
clearPendingRequest: () => void;
markPendingRequestCleared: (error: Error) => Error;
model?: string | null;
};
export function createStreamFailureAborter(context: AborterContext) {
return (
controller: TransformStreamDefaultController<Uint8Array>,
failure: StreamFailurePayload,
publicMessage: string,
options: { notifyComplete?: boolean } = {}
): void => {
let handled = false;
context.timing.markInterrupted();
if (context.onFailure) {
try {
handled = context.onFailure(failure) === true;
} catch (error) {
console.debug("[STREAM] onFailure callback error:", error);
}
}
let safeMessage = publicMessage || "Upstream failure";
if (options.notifyComplete && context.onComplete) {
const body = buildErrorBody(failure.status, failure.message);
safeMessage = body.error.message;
try {
context.onComplete({
status: failure.status,
usage: context.getUsage(),
responseBody: body,
ttft: context.timing.ttftMs(),
itlMs: context.timing.avgItlMs(),
interrupted: context.timing.interrupted,
error: safeMessage,
errorCode: failure.code,
providerPayload: context.buildProviderPayload(),
clientPayload: context.buildClientPayload(body),
});
handled = true;
} catch (error) {
console.debug(
`[STREAM] onComplete callback error in error path (${context.model || "unknown"}):`,
error
);
}
}
context.clearIdleTimer();
if (!handled) context.clearPendingRequest();
controller.error(context.markPendingRequestCleared(new Error(safeMessage)));
};
}

View File

@@ -5,6 +5,7 @@ import {
import { HTTP_STATUS } from "../config/constants.ts";
import { buildErrorBody } from "./error.ts";
import { sanitizeErrorMessage } from "./errorSanitization.ts";
export type StreamCompletionPayload = {
status: number;
@@ -129,9 +130,7 @@ export function finalizeStreamRequestLog({
} else {
console.warn(
"finalizeMostRecentPendingRequest failed:",
error && typeof error === "object" && "message" in error
? (error as { message?: unknown }).message
: error
sanitizeErrorMessage(error) || "Stream request finalization failed"
);
}
} catch {}
@@ -158,12 +157,12 @@ export function createStreamFailureFinalizers({
const status = failure.status || HTTP_STATUS.BAD_GATEWAY;
const message = failure.message || "Upstream stream error";
const code = failure.code || failure.type || String(status);
const classification =
failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined;
const errorBody = buildErrorBody(status, message, undefined, classification);
const projectedCode = errorBody.error.code || String(status);
if (!isFailureCompletionRecorded()) {
const errorBody = buildErrorBody(status, message, undefined, classification);
onStreamComplete({
status,
usage: null,
@@ -171,12 +170,12 @@ export function createStreamFailureFinalizers({
providerPayload: errorBody,
clientPayload: errorBody,
error: message,
errorCode: code,
errorCode: projectedCode,
ttft: 0,
});
}
persistFailureUsage(status, code);
persistFailureUsage(status, projectedCode);
try {
onStreamFailure?.(failure);
} catch {

View File

@@ -1,6 +1,7 @@
import { trackPendingRequest } from "@/lib/usageDb";
import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts";
import { FORMATS } from "../translator/formats.ts";
import { buildErrorBody } from "./error.ts";
import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts";
import { createCompletedResponsesToolHandoffWatcher } from "./responsesToolHandoff.ts";
import { createStreamContentWatcher, type StreamContentWatcher } from "./streamReadiness.ts";
@@ -187,6 +188,10 @@ function getErrorStatusCode(error: unknown): number {
return 502;
}
function getPublicErrorMessage(errorMsg: string, statusCode: number): string {
return buildErrorBody(statusCode, errorMsg).error.message;
}
function isDeadlineAbortReason(reason: unknown): reason is Error {
return (
reason instanceof Error &&
@@ -406,7 +411,7 @@ export function createStreamController({
}
if (error instanceof Error) {
logStream(`error: ${error.message}`);
logStream(`error: ${getPublicErrorMessage(error.message, getErrorStatusCode(error))}`);
return;
}
logStream("error: unknown");
@@ -452,6 +457,7 @@ export function buildStreamErrorChunks(
clientResponseFormat?: string | null
) {
const statusMapping = getStreamErrorStatusMapping(statusCode);
const publicErrorMessage = getPublicErrorMessage(errorMsg, statusCode);
if (isResponsesClientFormat(clientResponseFormat)) {
const errorEvent = {
@@ -460,7 +466,7 @@ export function buildStreamErrorChunks(
id: null,
status: "failed",
error: {
message: errorMsg,
message: publicErrorMessage,
type: statusMapping.responses.type,
code: statusMapping.responses.code,
},
@@ -475,7 +481,7 @@ export function buildStreamErrorChunks(
type: "error",
error: {
type: statusMapping.claude.type,
message: errorMsg,
message: publicErrorMessage,
},
};
@@ -498,7 +504,7 @@ export function buildStreamErrorChunks(
},
],
error: {
message: errorMsg,
message: publicErrorMessage,
type: statusMapping.responses.type,
code: statusMapping.responses.code,
},

View File

@@ -421,29 +421,67 @@ function prependBufferedChunks(
chunks: Uint8Array[],
reader: ReadableStreamDefaultReader<Uint8Array>
): ReadableStream<Uint8Array> {
let bufferedIndex = 0;
let readInFlight = false;
let cancelRequested = false;
let readerReleased = false;
const releaseReader = () => {
if (readerReleased) return;
readerReleased = true;
reader.releaseLock();
};
const cancelReader = (reason: unknown) => {
if (cancelRequested) return;
cancelRequested = true;
try {
// The provider controls this promise and may never settle. Cancellation
// of the replay stream must remain bounded, so cleanup is deliberately
// fire-and-forget while the in-flight read releases the lock in `pull`.
void reader.cancel(reason).catch(() => {});
} catch {
// A synchronous cancellation failure is cleanup-only; the downstream
// stream has already been cancelled by its consumer.
}
if (!readInFlight) releaseReader();
};
return new ReadableStream<Uint8Array>({
async start(controller) {
async pull(controller) {
if (cancelRequested) return;
// Replay exactly one readiness chunk per demand. Reading the source
// eagerly here would let a subsequent source error clear this queue
// before the consumer has observed the buffered prefix.
if (bufferedIndex < chunks.length) {
controller.enqueue(chunks[bufferedIndex]);
bufferedIndex += 1;
return;
}
readInFlight = true;
try {
for (const chunk of chunks) {
controller.enqueue(chunk);
const { done, value } = await reader.read();
if (cancelRequested) return;
if (done) {
releaseReader();
controller.close();
} else if (value) {
controller.enqueue(value);
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) controller.enqueue(value);
}
controller.close();
} catch (error) {
controller.error(error);
releaseReader();
if (!cancelRequested) controller.error(error);
} finally {
reader.releaseLock();
readInFlight = false;
if (cancelRequested) releaseReader();
}
},
async cancel(reason) {
await reader.cancel(reason).catch(() => {});
reader.releaseLock();
cancel(reason) {
cancelReader(reason);
},
});
}

View File

@@ -1,12 +1,16 @@
import {
containsSensitiveErrorCredential,
sanitizePassthroughUpstreamDetails,
} from "./errorSanitization.ts";
/**
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
*
* Claude Code matches the upstream error WORDING to auto-disable capabilities
* (thinking / output_config) for the rest of the conversation. Wrapping the body
* via buildErrorBody() truncates the message and breaks that recovery. For
* upstream-originated 4xx errors the body is the provider's public API message —
* not our internals — so it is safe and required to relay it verbatim.
* OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12).
* Claude Code matches upstream error wording to auto-disable capabilities
* (thinking / output_config) for the rest of the conversation. This path keeps
* the wording and JSON shape required for that recovery after applying the
* canonical recursive sanitizer. OmniRoute-generated errors MUST keep using
* buildErrorBody() (Hard Rule #12).
*/
const PASSTHROUGH_MIN = 400;
const PASSTHROUGH_MAX = 499;
@@ -17,13 +21,10 @@ const EXCLUDED_STATUSES = new Set([401, 403, 407]);
const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
// #10898-sec / secret-in-error hardening: some providers echo the offending
// request (including an Authorization header or api key) inside a 400/422/429
// validation body. Passthrough relays the body VERBATIM (the Claude Code
// capability-recovery contract needs the exact wording), so we cannot key-drop
// via sanitizeUpstreamDetails without breaking that contract. Instead, if the
// body actually carries a credential pattern, REFUSE passthrough and let the
// caller fall back to the sanitized buildErrorBody path. Bodies without a
// secret (the overwhelming majority, carrying capability/quota wording) still
// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts.
// validation body. If the body carries a credential pattern, REFUSE passthrough
// before the recursive sanitizer so the caller falls back to buildErrorBody.
// Eligible JSON retains its safe shape and capability/quota wording after the
// recursive projection. Mirrors redactSensitiveErrorText in errorSanitization.ts.
const CREDENTIAL_LEAK_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
@@ -31,10 +32,17 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody:
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
if (EXCLUDED_STATUSES.has(statusCode)) return false;
if (!upstreamBody || typeof upstreamBody !== "object") return false;
const text = JSON.stringify(upstreamBody);
let text: string | undefined;
try {
text = JSON.stringify(upstreamBody);
} catch {
// Relay only JSON-stable objects; cyclic/BigInt/hostile toJSON bodies fail closed.
return false;
}
if (typeof text !== "string") return false;
if (INTERNAL_LEAK_RE.test(text)) return false;
// Refuse passthrough when the provider echoed a credential back to us.
if (CREDENTIAL_LEAK_RE.test(text)) return false;
if (CREDENTIAL_LEAK_RE.test(text) || containsSensitiveErrorCredential(text)) return false;
return true;
}
@@ -44,8 +52,18 @@ export function buildPassthroughErrorResponse(
headers?: Record<string, string>
): Response | null {
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
return new Response(JSON.stringify(upstreamBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
try {
const sanitizedBody = sanitizePassthroughUpstreamDetails(upstreamBody);
const publicBody =
sanitizedBody && typeof sanitizedBody === "object"
? sanitizedBody
: { error: { message: "Upstream error" } };
return new Response(JSON.stringify(publicBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
} catch {
// A proxy/getter may behave differently between eligibility and projection.
return null;
}
}

View File

@@ -0,0 +1,46 @@
import { buildErrorBody, sanitizeUpstreamDetails } from "./error.ts";
interface SanitizedUpstreamErrorResponseOptions {
status: number;
rawBody: string;
fallbackMessage: string;
headers?: Record<string, string>;
}
/**
* Preserve a provider's JSON error shape while applying the canonical recursive sanitizer.
* Providers sometimes label plain text as JSON; those bodies use OmniRoute's canonical error
* envelope so the advertised content type always matches the response bytes.
*/
export function buildSanitizedUpstreamErrorResponse({
status,
rawBody,
fallbackMessage,
headers,
}: SanitizedUpstreamErrorResponseOptions): Response {
const trimmedBody = rawBody.trim();
if (trimmedBody) {
try {
const parsedBody: unknown = JSON.parse(trimmedBody);
const serializedBody = JSON.stringify(sanitizeUpstreamDetails(parsedBody));
if (serializedBody !== undefined) {
return new Response(serializedBody, {
status,
headers: { ...headers, "Content-Type": "application/json" },
});
}
} catch {
// Upstreams commonly return text or HTML despite an application/json response header.
// Treat it as an opaque message and use the canonical JSON envelope below.
}
}
// Non-JSON is an opaque upstream body. Do not echo even sanitized fragments:
// provider HTML/plaintext can contain credentials or implementation details
// outside the patterns the canonical sanitizer knows about.
return new Response(JSON.stringify(buildErrorBody(status, fallbackMessage)), {
status,
headers: { ...headers, "Content-Type": "application/json" },
});
}

View File

@@ -45,3 +45,36 @@ export function filterUpstreamResponseHeaderEntries(
}
export const STRIP_UPSTREAM_HEADER_NAMES: ReadonlySet<string> = STRIP_HEADER_NAMES;
/**
* Response headers that must never be relayed back to a client.
*
* A relay sends its own credential upstream (the bifrost route sends
* `Authorization: Bearer ${BIFROST_API_KEY}` to the sidecar). If that upstream
* echoes the header back — or sets its own session cookie — copying the response
* headers wholesale hands it to whoever holds the relay token
* (GHSA-9m72-44hg-w32g). `set-cookie` matters as much as `authorization`: it is
* a session, and the browser would store it against OUR origin.
*/
const SENSITIVE_RESPONSE_HEADER_NAMES: ReadonlyArray<string> = [
"authorization",
"proxy-authorization",
"x-api-key",
"x-goog-api-key",
"api-key",
"cookie",
"set-cookie",
];
/**
* New Headers with the stale framing set AND any echoed credential/session
* header removed. Use this instead of `new Headers(upstream.headers)` on every
* path that relays an upstream response to a client. Does not mutate the input.
*/
export function stripSensitiveResponseHeaders(input: Headers): Headers {
return new Headers(
filterUpstreamResponseHeaderEntries(input.entries(), SENSITIVE_RESPONSE_HEADER_NAMES)
);
}
export { SENSITIVE_RESPONSE_HEADER_NAMES };

View File

@@ -5,6 +5,7 @@ import type {
CodexProviderContinuationState,
CodexUsage,
} from "./types";
import { projectCodexPublicError } from "../../utils/codexPublicError";
import { adapterFailureFromMessage, classifyError, type CodexErrorPayload } from "./lib/errors";
import { encodeCompactionSummary } from "./responses/compaction";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
@@ -46,7 +47,8 @@ function responsesUsage(usage: CodexUsage | undefined): Record<string, unknown>
}
function responseError(status: number, type: string, message: string): CodexErrorPayload {
return classifyError(status, type, message);
const classified = classifyError(status, type, message);
return projectCodexPublicError({ status, type: classified.type, code: classified.code });
}
function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>): {
@@ -54,14 +56,25 @@ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>
error: CodexErrorPayload;
} {
if (event.status === undefined && event.errorType === undefined && event.code === undefined) {
return adapterFailureFromMessage(event.message);
const fallback = adapterFailureFromMessage(event.message);
return {
httpStatus: fallback.httpStatus,
error: projectCodexPublicError({
status: fallback.httpStatus,
type: fallback.error.type,
code: fallback.error.code,
}),
};
}
const fallback = adapterFailureFromMessage(event.message);
const httpStatus = event.status ?? fallback.httpStatus;
const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
if (event.errorType !== undefined) error.type = event.errorType;
if (event.code !== undefined) error.code = event.code;
return { httpStatus, error };
return {
httpStatus,
error: projectCodexPublicError({ status: httpStatus, type: error.type, code: error.code }),
};
}
export { adapterFailureFromMessage } from "./lib/errors";
@@ -1314,7 +1327,7 @@ export function buildResponseJSON(
}
export function formatErrorResponse(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ error: classifyError(status, type, message) }), {
return new Response(JSON.stringify({ error: responseError(status, type, message) }), {
status,
headers: { "Content-Type": "application/json" },
});

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorFramesFromLogChunks } from "@/lib/logPayloads";
import { getCallLogById } from "@/lib/usageDb";
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
import {
@@ -18,6 +20,29 @@ import {
// before it's parsed.
const CHUNK_LOG_TIMESTAMP_PREFIX = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
type ManagementStreamChunks = {
provider?: string[];
openai?: string[];
client?: string[];
};
function projectManagementStreamChunks(
streamChunks: ManagementStreamChunks | null | undefined
): ManagementStreamChunks | null {
if (!streamChunks) return null;
return {
...(streamChunks.provider
? { provider: sanitizeErrorFramesFromLogChunks(streamChunks.provider) }
: {}),
...(streamChunks.openai
? { openai: sanitizeErrorFramesFromLogChunks(streamChunks.openai) }
: {}),
...(streamChunks.client
? { client: sanitizeErrorFramesFromLogChunks(streamChunks.client) }
: {}),
};
}
// Best-effort parse of the accumulated SSE `data:` lines captured live for an
// in-flight request (open-sse/utils/requestLogger.ts's appendConvertedChunk
// mutates these arrays in place as chunks arrive, so this reflects "the reply
@@ -77,12 +102,13 @@ export async function GET(
try {
const pendingRequestDetail = getPendingById().get(id);
if (pendingRequestDetail) {
const safeStreamChunks = projectManagementStreamChunks(pendingRequestDetail.streamChunks);
const pipelinePayloads: any = {
clientRequest: pendingRequestDetail.clientRequest ?? null,
providerRequest: pendingRequestDetail.providerRequest ?? null,
providerResponse: pendingRequestDetail.providerResponse ?? null,
clientResponse: pendingRequestDetail.clientResponse ?? null,
streamChunks: pendingRequestDetail.streamChunks ?? null,
streamChunks: safeStreamChunks,
};
const activeEntry = {
@@ -102,7 +128,7 @@ export async function GET(
// The still-generating reply so far — the request's own context
// panel renders this alongside its (already-complete) requestBody
// instead of waiting for the stream to finish.
partialAssistantText: extractPartialAssistantText(pendingRequestDetail.streamChunks),
partialAssistantText: extractPartialAssistantText(safeStreamChunks),
};
return NextResponse.json(activeEntry);
@@ -123,12 +149,13 @@ export async function GET(
const completed = getCompletedDetails();
const inMem = completed.get(id);
if (inMem) {
const safeStreamChunks = projectManagementStreamChunks(inMem.streamChunks);
const pipelinePayloads: any = {
clientRequest: inMem.clientRequest ?? null,
providerRequest: inMem.providerRequest ?? null,
providerResponse: inMem.providerResponse ?? null,
clientResponse: inMem.clientResponse ?? null,
streamChunks: inMem.streamChunks ?? null,
streamChunks: safeStreamChunks,
};
const minimal = {
@@ -142,7 +169,7 @@ export async function GET(
duration: Date.now() - inMem.startedAt,
detailState: "in-memory",
active: false,
error: inMem.error || null,
error: sanitizeErrorMessage(inMem.error) || null,
pipelinePayloads,
hasPipelineDetails: true,
};

View File

@@ -40,9 +40,7 @@ export function buildStaleEncryptionKeyResponse(
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
// buildErrorBody sanitizes the message (Rule #12); override the type so the
// client can key off the specific stale-encryption cause.
const body = buildErrorBody(424, message);
body.error.type = "storage_encryption_stale";
// buildErrorBody sanitizes the message and projects the client-visible classification.
const body = buildErrorBody(424, message, undefined, { type: "storage_encryption_stale" });
return NextResponse.json(body, { status: 424 });
}

View File

@@ -0,0 +1,155 @@
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { makeDiagnosis } from "./codexAppServerHealth";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
export function toSafeMessage(value: unknown, fallback = "Unknown error"): string {
const safeMessage = sanitizeErrorMessage(value).trim();
return safeMessage || fallback;
}
/**
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
* account is deactivated, in which case the API returns 401 — mislabeling that as
* "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the
* account-fallback classifier already trusts.
*/
export function isAccountDeactivatedMessage(text: string): boolean {
const normalized = (text || "").toLowerCase();
return (
normalized.includes("account_deactivated") ||
(normalized.includes("deactivat") && normalized.includes("account"))
);
}
export function classifyFailure({
error,
statusCode = null,
refreshFailed = false,
unsupported = false,
provider,
}: ClassifyFailureArgs) {
const message = toSafeMessage(error, "Connection test failed");
const normalized = message.toLowerCase();
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
if (unsupported) {
return makeDiagnosis("unsupported", "validation", message, "unsupported");
}
if (refreshFailed || normalized.includes("refresh failed")) {
return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed");
}
// #1444: a deactivated account is distinct from a revoked/expired token — surface it
// as account_deactivated (which the dashboard renders as "Account Deactivated") before
// the generic 401/403 branch below would mark it "upstream_auth_error".
if (isAccountDeactivatedMessage(normalized)) {
return makeDiagnosis("account_deactivated", "account", message, "account_deactivated");
}
if (numericStatus === 401 || numericStatus === 403) {
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
}
if (numericStatus === 429) {
return makeDiagnosis("upstream_rate_limited", "upstream", message, "429");
}
if (numericStatus && numericStatus >= 500) {
return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus));
}
if (normalized.includes("token expired") || normalized.includes("expired")) {
return makeDiagnosis("token_expired", "oauth", message, "token_expired");
}
if (
normalized.includes("invalid api key") ||
normalized.includes("token invalid") ||
normalized.includes("revoked") ||
normalized.includes("access denied") ||
normalized.includes("unauthorized") ||
normalized.includes("forbidden")
) {
return makeDiagnosis(
"upstream_auth_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "auth_failed"
);
}
if (
normalized.includes("rate limit") ||
normalized.includes("quota") ||
normalized.includes("too many requests")
) {
return makeDiagnosis(
"upstream_rate_limited",
"upstream",
message,
numericStatus ? String(numericStatus) : "rate_limited"
);
}
if (
normalized.includes("fetch failed") ||
normalized.includes("network") ||
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("econn") ||
normalized.includes("enotfound") ||
normalized.includes("socket")
) {
return makeDiagnosis("network_error", "upstream", message, "network_error");
}
return makeDiagnosis(
"upstream_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "upstream_error"
);
}
/** Allowlist the CLI health fields safe to expose outside the local runtime boundary. */
export function projectProviderRuntimeForPublicResponse(
runtime: unknown
): Record<string, unknown> | null {
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return null;
const record = runtime as Record<string, unknown>;
const projected: Record<string, unknown> = {};
for (const field of ["installed", "runnable", "requiresBinary"] as const) {
if (typeof record[field] === "boolean") projected[field] = record[field];
}
for (const field of ["reason", "runtimeMode", "version", "command"] as const) {
if (typeof record[field] !== "string") continue;
const safeValue = sanitizeErrorMessage(record[field]).trim();
if (safeValue) projected[field] = safeValue.slice(0, 512);
}
return projected;
}
/** Sanitize every connection-test result before health writes, logs, and HTTP responses. */
export function projectConnectionTestResultForPublicResponse<
T extends { error?: unknown; warning?: unknown; diagnosis?: unknown },
>(result: T) {
const projected = projectProviderValidationResultForPublicResponse(result);
if (!projected.diagnosis || typeof projected.diagnosis !== "object") return projected;
const diagnosis = projected.diagnosis as Record<string, unknown>;
return {
...projected,
diagnosis: {
...diagnosis,
message:
diagnosis.message === null || diagnosis.message === undefined
? null
: toSafeMessage(diagnosis.message, "Connection test failed"),
},
};
}

View File

@@ -7,6 +7,7 @@ import { isCloudEnabled, resolveProxyForConnection } from "@/lib/db/settings";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { validateProviderApiKey } from "@/lib/providers/validation";
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
import { getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { buildQoderCliNotFoundHint } from "@omniroute/open-sse/services/qoderCliResolve.ts";
// Use the shared open-sse token refresh with built-in dedup/race-condition cache
@@ -29,11 +30,19 @@ import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHea
import { recoverKeyHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult";
import { classifyOAuthProbeInconclusive, OAUTH_TEST_CONFIG } from "./oauthTestConfig";
import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts";
import * as retirement from "@/lib/providers/chatgptWebRetirementResponse";
import {
classifyFailure,
isAccountDeactivatedMessage,
projectConnectionTestResultForPublicResponse,
projectProviderRuntimeForPublicResponse,
toSafeMessage,
} from "./publicErrorBoundary";
export { classifyFailure, projectProviderRuntimeForPublicResponse } from "./publicErrorBoundary";
// Match the API-key path's 30s timeout so a hung OAuth upstream cannot block the test queue.
const OAUTH_TEST_TIMEOUT_MS = 30_000;
@@ -45,115 +54,6 @@ const providerConnectionTestBodySchema = z.object({
validationModelId: z.string().max(500).optional(),
});
function toSafeMessage(value: any, fallback = "Unknown error"): string {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed || fallback;
}
/**
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
* account is deactivated, in which case the API returns 401 — mislabeling that as
* "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the
* account-fallback classifier already trusts.
*/
function isAccountDeactivatedMessage(text: string): boolean {
const n = (text || "").toLowerCase();
return n.includes("account_deactivated") || (n.includes("deactivat") && n.includes("account"));
}
export function classifyFailure({
error,
statusCode = null,
refreshFailed = false,
unsupported = false,
provider,
}: ClassifyFailureArgs) {
const message = toSafeMessage(error, "Connection test failed");
const normalized = message.toLowerCase();
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
if (unsupported) {
return makeDiagnosis("unsupported", "validation", message, "unsupported");
}
if (refreshFailed || normalized.includes("refresh failed")) {
return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed");
}
// #1444: a deactivated account is distinct from a revoked/expired token — surface it
// as account_deactivated (which the dashboard renders as "Account Deactivated") before
// the generic 401/403 branch below would mark it "upstream_auth_error".
if (isAccountDeactivatedMessage(normalized)) {
return makeDiagnosis("account_deactivated", "account", message, "account_deactivated");
}
if (numericStatus === 401 || numericStatus === 403) {
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
}
if (numericStatus === 429) {
return makeDiagnosis("upstream_rate_limited", "upstream", message, "429");
}
if (numericStatus && numericStatus >= 500) {
return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus));
}
if (normalized.includes("token expired") || normalized.includes("expired")) {
return makeDiagnosis("token_expired", "oauth", message, "token_expired");
}
if (
normalized.includes("invalid api key") ||
normalized.includes("token invalid") ||
normalized.includes("revoked") ||
normalized.includes("access denied") ||
normalized.includes("unauthorized") ||
normalized.includes("forbidden")
) {
return makeDiagnosis(
"upstream_auth_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "auth_failed"
);
}
if (
normalized.includes("rate limit") ||
normalized.includes("quota") ||
normalized.includes("too many requests")
) {
return makeDiagnosis(
"upstream_rate_limited",
"upstream",
message,
numericStatus ? String(numericStatus) : "rate_limited"
);
}
if (
normalized.includes("fetch failed") ||
normalized.includes("network") ||
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("econn") ||
normalized.includes("enotfound") ||
normalized.includes("socket")
) {
return makeDiagnosis("network_error", "upstream", message, "network_error");
}
return makeDiagnosis(
"upstream_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "upstream_error"
);
}
function hasQoderToken(connection: any): boolean {
if (typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0) return true;
const psd = connection?.providerSpecificData;
@@ -218,7 +118,10 @@ async function getProviderRuntimeStatus(connection: any) {
error: runtimeMessage,
};
} catch (error) {
const runtimeMessage = `Failed to check local CLI runtime: ${(error as any)?.message || "runtime_check_failed"}`;
const runtimeMessage = `Failed to check local CLI runtime: ${toSafeMessage(
error,
"runtime_check_failed"
)}`;
return {
installed: false,
runnable: false,
@@ -302,7 +205,10 @@ async function refreshOAuthToken(connection: any) {
});
return result; // { accessToken, expiresIn, refreshToken } or null
} catch (err) {
console.error(`Error refreshing ${provider} token:`, (err as any).message);
console.error(
`Error refreshing ${provider} token:`,
toSafeMessage(err, "Token refresh failed")
);
return null;
}
}
@@ -376,7 +282,10 @@ async function syncToCloudIfEnabled() {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud after token refresh:", error);
console.log(
"Error syncing to cloud after token refresh:",
toSafeMessage(error, "Cloud sync failed")
);
}
}
@@ -934,11 +843,13 @@ async function testApiKeyConnection(connection: any) {
};
}
const result = await validateProviderApiKey({
provider: connection.provider,
apiKey: connection.apiKey,
providerSpecificData: connection.providerSpecificData,
});
const result = projectProviderValidationResultForPublicResponse(
await validateProviderApiKey({
provider: connection.provider,
apiKey: connection.apiKey,
providerSpecificData: connection.providerSpecificData,
})
);
if (result.unsupported) {
const error = "Provider test not supported";
@@ -1001,8 +912,11 @@ export async function testSingleConnection(connectionId: string, validationModel
let proxyInfo: any = null;
try {
proxyInfo = await resolveProxyForConnection(connectionId);
} catch (proxyErr: any) {
console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message);
} catch (proxyErr: unknown) {
console.log(
`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`,
toSafeMessage(proxyErr, "Proxy resolution failed")
);
}
let result;
@@ -1046,6 +960,12 @@ export async function testSingleConnection(connectionId: string, validationModel
);
}
// Every runtime path converges here before any health-state write, diagnosis,
// persistent log, or public response. API-key validation is projected at its
// own seam above as well so future refactors cannot move it past this boundary.
result = projectConnectionTestResultForPublicResponse(result);
const publicRuntime = projectProviderRuntimeForPublicResponse(runtime);
const latencyMs = Date.now() - startTime;
// Unsupported validation capability is neutral: the probe established that
@@ -1063,14 +983,14 @@ export async function testSingleConnection(connectionId: string, validationModel
} catch (activateError) {
console.log(
`[ConnectionTest] Failed to activate unverifiable connection ${connectionId}:`,
(activateError as any)?.message || activateError
toSafeMessage(activateError, "Connection activation failed")
);
}
}
return {
...result,
latencyMs,
runtime: runtime || null,
runtime: publicRuntime,
testedAt: null,
};
}
@@ -1214,7 +1134,7 @@ export async function testSingleConnection(connectionId: string, validationModel
diagnosis,
latencyMs,
statusCode: result.statusCode || null,
runtime: runtime || null,
runtime: publicRuntime,
testedAt: now,
};
}
@@ -1245,7 +1165,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
} catch (error) {
const retired = retirement.responseForError(error);
if (retired) return retired;
console.log("Error testing connection:", error);
console.log("Error testing connection:", toSafeMessage(error, "Connection test failed"));
return NextResponse.json({ error: "Test failed" }, { status: 500 });
}
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderNodeById } from "@/models";
@@ -8,6 +9,7 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { validateProviderApiKey } from "@/lib/providers/validation";
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
import { getProxyForLevel } from "@/lib/db/settings";
import { resolveProxyForProvider } from "@/lib/db/proxies";
import { validateProviderApiKeySchema } from "@/shared/validation/schemas";
@@ -123,12 +125,14 @@ export async function POST(request) {
proxyToUse = providerProxy || globalProxy || null;
}
const result = await runWithProxyContextOrDirect(proxyToUse || null, () =>
validateProviderApiKey({
provider,
apiKey,
providerSpecificData,
})
const result = projectProviderValidationResultForPublicResponse(
await runWithProxyContextOrDirect(proxyToUse || null, () =>
validateProviderApiKey({
provider,
apiKey,
providerSpecificData,
})
)
);
if (result.unsupported) {
@@ -174,7 +178,7 @@ export async function POST(request) {
providerSpecificData: result.providerSpecificData || null,
});
} catch (error) {
console.log("Error validating API key:", error);
console.log("Error validating API key:", sanitizeErrorMessage(error) || "Validation failed");
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
}
}

View File

@@ -58,8 +58,12 @@ export async function GET(request: NextRequest) {
const flatSettings = await getSettings();
const config: Record<string, unknown> = {};
for (const key of CACHE_CONFIG_KEYS) {
if (key === "idempotencyWindowMs") {
config[key] = flatSettings.idempotencyWindowMs ?? DEFAULTS[key];
if (key === "idempotencyWindowMs" || key === "alwaysPreserveClientCache") {
// These live in the flat general settings (src/lib/db/settings.ts):
// idempotencyLayer and getCacheControlSettings() both read from there,
// so reporting the databaseSettings "cache" copy would show a value the
// runtime never uses.
config[key] = flatSettings[key] ?? DEFAULTS[key];
} else {
config[key] = (cache as Record<string, unknown>)[key] ?? DEFAULTS[key];
}
@@ -106,9 +110,6 @@ export async function PUT(request: NextRequest) {
if (body.promptCacheStrategy !== undefined) {
updates.promptCacheStrategy = body.promptCacheStrategy;
}
if (body.alwaysPreserveClientCache !== undefined) {
updates.alwaysPreserveClientCache = body.alwaysPreserveClientCache;
}
if (body.modelCatalogCacheTtlMs !== undefined) {
updates.modelCatalogCacheTtlMs = body.modelCatalogCacheTtlMs;
}
@@ -116,12 +117,23 @@ export async function PUT(request: NextRequest) {
// updateDatabaseSettings() calls invalidateDbCache("settings") internally,
// which bumps the model-catalog cache version so in-flight responses pick
// up the fresh TTL — no separate version bump needed here.
updateDatabaseSettings({ cache: updates });
if (Object.keys(updates).length > 0) {
updateDatabaseSettings({ cache: updates });
}
// idempotencyWindowMs is not part of the databaseSettings "cache" section —
// persist it through the flat general settings module instead (see GET).
// idempotencyWindowMs and alwaysPreserveClientCache are read from the flat
// general settings (see GET) — persisting them into the databaseSettings
// "cache" section would be a silent no-op for the runtime, which is what
// made this endpoint's alwaysPreserveClientCache writes ineffective before.
const flatUpdates: Record<string, unknown> = {};
if (body.idempotencyWindowMs !== undefined) {
await updateSettings({ idempotencyWindowMs: body.idempotencyWindowMs });
flatUpdates.idempotencyWindowMs = body.idempotencyWindowMs;
}
if (body.alwaysPreserveClientCache !== undefined) {
flatUpdates.alwaysPreserveClientCache = body.alwaysPreserveClientCache;
}
if (Object.keys(flatUpdates).length > 0) {
await updateSettings(flatUpdates);
}
return NextResponse.json({ ok: true });

View File

@@ -29,9 +29,14 @@
*/
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { stripSensitiveResponseHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import {
buildErrorBody,
parseUpstreamError,
sanitizeErrorMessage,
} from "@omniroute/open-sse/utils/error";
import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts";
import { z } from "zod";
import {
@@ -299,7 +304,11 @@ export async function POST(request: Request) {
});
};
const newHeaders = new Headers(upstream.headers);
// Never copy the upstream headers wholesale: the sidecar receives our
// `Authorization: Bearer ${BIFROST_API_KEY}` and anything it echoes back —
// that header, its own set-cookie — would reach the relay-token holder
// (GHSA-9m72-44hg-w32g).
const newHeaders = stripSensitiveResponseHeaders(upstream.headers);
newHeaders.set("X-Routed-By", "bifrost");
newHeaders.set("X-Relay-Token", token.tokenPrefix + "...");
if (!wantsStream) {
@@ -321,6 +330,28 @@ export async function POST(request: Request) {
}
clearTimeout(tid);
// Normalize non-2xx through parseUpstreamError + buildErrorBody instead of
// relaying the sidecar body verbatim — parity with the TS sibling route and
// Hard Rule #12 (GHSA-9m72-44hg-w32g).
if (!upstream.ok) {
const parsed = await parseUpstreamError(upstream, null);
const errorBody = buildErrorBody(
parsed.statusCode,
sanitizeErrorMessage(parsed.message),
parsed.responseBody
);
newHeaders.set("Content-Type", "application/json");
if (parsed.retryAfterMs && parsed.retryAfterMs > 0) {
newHeaders.set("Retry-After", String(Math.ceil(parsed.retryAfterMs / 1000)));
}
recordUsage("error", parsed.statusCode);
return new Response(JSON.stringify(errorBody), {
status: parsed.statusCode,
headers: newHeaders,
});
}
recordUsage(upstream.status < 500 ? "success" : "error", upstream.status);
return new Response(upstream.body, {

View File

@@ -7,6 +7,7 @@
*/
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { stripSensitiveResponseHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders";
import { handleChat } from "@/sse/handlers/chat";
import { withChatAdmission } from "@/shared/middleware/withChatAdmission";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
@@ -109,7 +110,9 @@ async function forwardToBifrost(
signal: ac.signal,
});
const headers = new Headers(upstream.headers);
// Same strip as the bifrost sibling: an echoed upstream credential or
// set-cookie must not reach the relay-token holder (GHSA-9m72-44hg-w32g).
const headers = stripSensitiveResponseHeaders(upstream.headers);
headers.set("X-Routed-By", "bifrost");
headers.set("X-Routing-Backend", "bifrost");
headers.set("X-Relay-Token", token.tokenPrefix + "...");

View File

@@ -6139,7 +6139,7 @@
"bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.",
"byteplus": "Connect BytePlus ModelArk with an API key.",
"bytez": "$1 free credits, refreshes every 4 weeks",
"cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
"cerebras": "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.",
"charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
"chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.",
"clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.",

View File

@@ -6136,7 +6136,7 @@
"bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.",
"byteplus": "Connect BytePlus ModelArk with an API key.",
"bytez": "$1 free credits, refreshes every 4 weeks",
"cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
"cerebras": "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.",
"charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
"chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.",
"clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.",

View File

@@ -101,30 +101,70 @@ export function isTestContext(): boolean {
);
}
/**
* `node --eval` / `node -e` (and their print variants) are common shapes used by
* one-off import probes.
* Such a process has no application entry point from which to establish storage intent,
* so defaulting it to the operator's durable database is unsafe. A deliberate production
* inspection can still opt in with an explicit DATA_DIR (preferred) or
* OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1.
*/
function isEvalProbeContext(): boolean {
return process.execArgv.some(
(arg) =>
arg === "--eval" ||
arg === "-e" ||
arg === "-pe" ||
arg === "-ep" ||
arg.startsWith("--eval=") ||
arg === "--print" ||
arg === "-p" ||
arg.startsWith("--print=")
);
}
/** Process-wide redirect target, so repeated calls share one DB instead of one per call. */
let testContextDataDir: string | null = null;
let testContextCleanupRegistered = false;
export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean } = {}): string {
const resolved = resolveDataDir({ isCloud });
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
// Cloud/serverless never owns a writable home dir; leave its sentinel alone.
if (isCloud) return resolved;
// #10428: a test/ad-hoc run that never chose a DATA_DIR would otherwise open the
// #10428: a test/eval-probe run that never chose a DATA_DIR would otherwise open the
// OPERATOR'S REAL database (~/.omniroute/storage.sqlite — live provider credentials).
// Redirect to a throwaway dir instead of throwing: the documented single-file command
// (`node --import tsx/esm --test tests/unit/x.test.ts`) does not load the isolation
// setup, and a hard failure there would only teach people to disable the guard.
// `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1` opts back in, so the intent is recorded.
if (
!process.env.DATA_DIR &&
isTestContext() &&
!configured &&
(isTestContext() || isEvalProbeContext()) &&
process.env.OMNIROUTE_ALLOW_DEFAULT_DATA_DIR !== "1"
) {
if (!testContextDataDir) {
testContextDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `${APP_NAME}-testctx-`));
if (!testContextCleanupRegistered) {
testContextCleanupRegistered = true;
process.once("exit", () => {
if (!testContextDataDir) return;
try {
fs.rmSync(testContextDataDir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 25,
});
} catch {
// An unclean exit is left to the operating system's temp-directory policy.
}
});
}
console.warn(
`[DATA_DIR] test context without DATA_DIR → using '${testContextDataDir}' instead of ` +
`[DATA_DIR] test/eval context without DATA_DIR → using '${testContextDataDir}' instead of ` +
`'${resolved}'. Set DATA_DIR explicitly (or load tests/_setup/isolateDataDir.ts) to silence this.`
);
}
@@ -132,7 +172,6 @@ export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean
}
// No explicit override → already the default user dir; nothing to fall back to.
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
if (!configured) return resolved;
try {

View File

@@ -101,6 +101,24 @@ function getBackupDir() {
return DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
}
function listBackupFilesNewestFirst(backupDir: string) {
return fs
.readdirSync(backupDir)
.filter((filename) => filename.startsWith("db_") && filename.endsWith(".sqlite"))
.flatMap((filename) => {
try {
return [{ filename, stat: fs.statSync(path.join(backupDir, filename)) }];
} catch {
// A concurrent retention pass may remove an entry after readdir.
return [];
}
})
.sort(
(left, right) =>
right.stat.mtimeMs - left.stat.mtimeMs || right.filename.localeCompare(left.filename)
);
}
export function cleanupDbBackups(options?: {
maxFiles?: number;
retentionDays?: number;
@@ -272,16 +290,26 @@ export function backupDbFile(reason = "auto") {
if (reason !== "manual" && reason !== "pre-restore") {
// Shrink detection is useful for automatic safety backups, but it should
// never block an explicit operator action like manual backup or pre-restore.
// Only timestamp-named automatic/manual backups are shrink baselines. The
// content-addressed migration snapshots are restore points, not periodic size
// samples; excluding them also keeps this lookup to names only with a single stat
// even in legacy directories containing tens of thousands of timestamp backups.
const existingBackups = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("db_") && f.endsWith(".sqlite"))
.filter((filename) => /^db_\d{4}-.*\.sqlite$/.test(filename))
.sort();
if (existingBackups.length > 0) {
const latestBackup = existingBackups[existingBackups.length - 1];
const latestStat = fs.statSync(path.join(backupDir, latestBackup));
if (latestStat.size > 4096 && stat.size < latestStat.size * 0.5) {
console.warn(`[DB] Backup SKIPPED — DB shrank from ${latestStat.size}B to ${stat.size}B`);
return null;
const latestBackup = existingBackups.at(-1)!;
try {
const latestStat = fs.statSync(path.join(backupDir, latestBackup));
if (latestStat.size > 4096 && stat.size < latestStat.size * 0.5) {
console.warn(
`[DB] Backup SKIPPED — DB shrank from ${latestStat.size}B to ${stat.size}B`
);
return null;
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException | null)?.code !== "ENOENT") throw error;
}
}
}
@@ -316,16 +344,11 @@ export async function listDbBackups() {
try {
if (!fs.existsSync(backupDir)) return [];
const entries = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("db_") && f.endsWith(".sqlite"))
.sort()
.reverse();
const entries = listBackupFilesNewestFirst(backupDir);
const { tryOpenSync } = await import("@/lib/db/adapters/driverFactory");
return entries.map((filename) => {
return entries.map(({ filename, stat }) => {
const filePath = path.join(backupDir, filename);
const stat = fs.statSync(filePath);
const match = filename.match(/^db_(.+?)_([^.]+)\.sqlite$/);
const reason = match ? match[2] : "unknown";

View File

@@ -1,17 +1,12 @@
/**
* Backup retention primitives — pure filesystem work, no `core.ts` dependency.
*
* This module exists so BOTH backup call sites can share one retention policy:
*
* - `backup.ts` (manual/API/auto backups) — resolves the operator's settings from the
* database and delegates here.
* - `migrationRunner.ts` (pre-migration snapshots) — cannot import `backup.ts`, because
* `core.ts` already imports `migrationRunner.ts` and `backup.ts` imports `core.ts`;
* that edge would close a cycle. Keeping the policy here, free of `core`, lets the
* migration path prune without one.
*
* Before #10421 the migration path had no retention at all and `db_backups/` grew
* without bound (observed: 48.999 files / 204 GB against a 5,3 MB live database).
* `backup.ts` (manual/API/auto backups) resolves the operator's settings from the
* database and delegates pure family pruning here. The migration runner deliberately
* does not prune during its concurrent safety window: its snapshots are content-addressed
* and reused for an identical DB state, while manual/scheduled cleanup remains the single
* retention boundary. Before #10421, repeated failed starts created distinct timestamped
* snapshots and `db_backups/` grew without bound (observed: 48,999 files / 204 GB).
*/
import fs from "fs";

View File

@@ -1118,10 +1118,10 @@ export function getDbInstance(): SqliteDatabase {
// This is needed so the migration runner skips the mass-migration safety abort
// that would otherwise trigger because heuristic seeding marks some migrations
// as applied, making the fresh DB look like a wiped existing DB (#1328).
// #9934: also classify as fresh a file that `omniroute setup` created with
// only the clipped skeleton schema (see the probe below) — even though the
// file exists, it has never had migrations run.
let isNewDb = !fs.existsSync(sqliteFile);
// #9934: also classify a setup-created skeleton as logically fresh for the mass guard,
// while tracking its pre-existing file independently for mandatory snapshot safety.
const databaseExistedBeforeInitialization = fs.existsSync(sqliteFile);
let isNewDb = !databaseExistedBeforeInitialization;
// Detect and handle old schema format — preserve data when possible (#146)
// Uses a single probe connection that becomes the real connection when possible.
@@ -1310,7 +1310,7 @@ export function getDbInstance(): SqliteDatabase {
VALUES ('001', 'initial_schema');
`);
runMigrations(db, { isNewDb });
runMigrations(db, { isNewDb, databaseExistedBeforeInitialization });
// Fresh installs need the same post-migration index guarantee as upgraded
// databases, including recovery from an interrupted migration 127 attempt.
ensureUsageHistoryAccountIndex(db);

View File

@@ -21,37 +21,29 @@ import type { SqliteAdapter } from "./adapters/types";
import { DEFAULT_DATABASE_SETTINGS } from "@/types/databaseSettings";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
import {
RENAMED_MIGRATION_COMPATIBILITY,
LEGACY_VERSION_SLOT_MIGRATIONS,
SUPERSEDED_DUPLICATE_MIGRATIONS,
PHYSICAL_SCHEMA_SENTINELS,
INITIAL_SCHEMA_SENTINELS,
OPTIONAL_FTS5_MIGRATION_VERSIONS,
RENAMED_MIGRATION_COMPATIBILITY,
SUPERSEDED_DUPLICATE_MIGRATIONS,
} from "./migrationRunner/constants";
import { getExtraMigrationFiles } from "./migrationRunner/extraDirs";
// Retention primitives live in their own `core`-free module: `core.ts` imports this file,
// so importing `backup.ts` (which imports `core.ts`) here would close a dependency cycle.
import { migrationConsole as console } from "./migrationRunner/logger";
import {
MAX_DB_BACKUPS,
DEFAULT_DB_BACKUP_RETENTION_DAYS,
parsePositiveInt,
parseNonNegativeInt,
pruneBackupDirectory,
} from "./backupRetention";
const isNodeTestRunnerChild = typeof process.env.NODE_TEST_CONTEXT === "string";
const console = {
log: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.log(...args);
},
warn: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.warn(...args);
},
error: (...args: unknown[]) => {
globalThis.console.error(...args);
},
};
createPreMigrationBackup,
hashFileSync,
type PreMigrationBackupReceipt,
} from "./migrationRunner/preMigrationBackup";
import {
detectNameMismatches,
getPlausiblePendingCount,
hasColumn,
hasLedgerRepairCandidates,
hasPhysicalTable,
hasTable,
inferPhysicalSchemaBaseline,
reconcileRenumberedMigrations,
rehomeLegacyVersionSlotMigrations,
} from "./migrationRunner/schemaState";
/**
* Resolve the migrations directory path safely across platforms.
@@ -336,16 +328,96 @@ function getAppliedRecords(db: SqliteAdapter): Array<{ version: string; name: st
}>;
}
function hasTable(db: SqliteAdapter, tableName: string): boolean {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?")
.get(tableName) as { name?: string } | undefined;
return Boolean(row?.name);
/**
* Reopen a narrowly selected migration when the table it creates is physically absent.
*
* Historical databases can carry `074_discovery_results` or the rehomed
* `081_inspector_custom_hosts` in the ledger without the table itself (for example after a
* version-slot collision or an incomplete manual recovery). Treating either marker as
* authoritative leaves an incomplete schema. A same-named view does not count as the table;
* replaying the owning migration fails closed instead of silently advancing.
*
* This intentionally detects table absence only. It is not a general schema-healing layer:
* column/rebuild migrations continue to use targeted idempotency checks elsewhere.
*/
const REQUIRED_PHYSICAL_MIGRATIONS = [
{ version: "074", name: "discovery_results", tableName: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts", tableName: "inspector_custom_hosts" },
] as const;
function validateRequiredPhysicalMigrationProvenance(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): void {
for (const required of REQUIRED_PHYSICAL_MIGRATIONS) {
if (hasPhysicalTable(db, required.tableName)) continue;
const migrationExists = files.some(
(file) => file.version === required.version && file.name === required.name
);
if (!migrationExists) continue;
const occupied = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(required.version) as { version: string; name: string } | undefined;
if (!occupied || occupied.name === required.name) continue;
const knownRenumberedCollision = RENAMED_MIGRATION_COMPATIBILITY.some(
(compatibility) =>
compatibility.fromVersion === occupied.version &&
compatibility.fromName === occupied.name &&
files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
) &&
files.some(
(file) =>
file.version === compatibility.fromVersion && file.name !== compatibility.fromName
)
);
const knownLegacySlotCollision = LEGACY_VERSION_SLOT_MIGRATIONS.some(
(legacy) =>
legacy.version === occupied.version &&
legacy.name === occupied.name &&
files.some((file) => file.version === legacy.version && file.name !== legacy.name)
);
const knownRepairableCollision = knownRenumberedCollision || knownLegacySlotCollision;
if (knownRepairableCollision) continue;
throw new Error(
`[Migration] Required table "${required.tableName}" is missing, but version ` +
`${required.version} is recorded as unknown migration "${occupied.name}" instead of ` +
`"${required.name}". Refusing to treat this database as current.`
);
}
}
function hasColumn(db: SqliteAdapter, tableName: string, columnName: string): boolean {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return columns.some((column) => column.name === columnName);
function findAtomicPhysicalReplays(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): Set<string> {
const replayVersions = new Set<string>();
for (const required of REQUIRED_PHYSICAL_MIGRATIONS) {
if (hasPhysicalTable(db, required.tableName)) continue;
const migrationExists = files.some(
(file) => file.version === required.version && file.name === required.name
);
if (!migrationExists) continue;
const applied = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(required.version, required.name) as { version: string; name: string } | undefined;
if (!applied) continue;
replayVersions.add(required.version);
console.warn(
`[Migration] Will atomically replay ${required.version}_${required.name}: ledger recorded ` +
`"${applied.name}" but required table "${required.tableName}" is missing.`
);
}
return replayVersions;
}
function ensureColumn(db: SqliteAdapter, tableName: string, columnName: string, ddl: string): void {
@@ -651,276 +723,31 @@ function applyCompressionCombosMigration(db: SqliteAdapter, migrationPath: strin
`);
}
function inferPhysicalSchemaBaseline(db: SqliteAdapter): {
version: string;
description: string;
} | null {
for (const sentinel of PHYSICAL_SCHEMA_SENTINELS) {
if (hasTable(db, sentinel.tableName)) {
return {
version: sentinel.version,
description: sentinel.description,
};
}
}
const hasInitialSchema = INITIAL_SCHEMA_SENTINELS.every((tableName) => hasTable(db, tableName));
if (hasInitialSchema) {
return {
version: "001",
description: "initial schema tables",
};
}
return null;
}
function getPlausiblePendingCount(
files: Array<{ version: string; name: string; path: string }>,
baselineVersion: string
): number {
const baseline = Number.parseInt(baselineVersion, 10);
return files.filter((file) => Number.parseInt(file.version, 10) > baseline).length;
}
/**
* Detect migration name mismatches — when a migration version number
* has been reused/renumbered with a different name. This is a strong signal
* that the migration tracking is corrupted or migrations were renumbered.
*/
function detectNameMismatches(
appliedRecords: Array<{ version: string; name: string }>,
files: Array<{ version: string; name: string; path: string }>
): Array<{ version: string; appliedName: string; diskName: string }> {
const appliedByName = new Map(appliedRecords.map((r) => [r.version, r.name]));
const mismatches: Array<{ version: string; appliedName: string; diskName: string }> = [];
for (const file of files) {
const appliedName = appliedByName.get(file.version);
if (appliedName && appliedName !== file.name) {
mismatches.push({
version: file.version,
appliedName,
diskName: file.name,
});
}
}
return mismatches;
}
function reconcileRenumberedMigrations(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): boolean {
let repaired = false;
for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) {
const hasTargetFile = files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
);
const hasSourceFile = files.some(
(file) => file.version === compatibility.fromVersion && file.name !== compatibility.fromName
);
if (!hasTargetFile || !hasSourceFile) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(compatibility.fromVersion, compatibility.fromName) as
{ version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const targetRow = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.toVersion) as { version: string } | undefined;
const applyRepair = db.transaction(() => {
if (targetRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
compatibility.fromVersion,
compatibility.fromName
);
} else {
db.prepare(
"UPDATE _omniroute_migrations SET version = ?, name = ? WHERE version = ? AND name = ?"
).run(
compatibility.toVersion,
compatibility.toName,
compatibility.fromVersion,
compatibility.fromName
);
}
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Reconciled renamed migration ${compatibility.fromVersion}_${compatibility.fromName} ` +
`to ${compatibility.toVersion}_${compatibility.toName} to preserve pending migrations.`
);
// After the compat rewrite, verify the old version slot is now free.
// A residual row (from a failed prior run, manual intervention, or edge-case
// UPDATE conflict) at the old version would shadow a NEW migration file
// placed at that version number — e.g. 028_create_files_and_batches.sql
// would be skipped because getAppliedVersions() still sees version "028".
const residualRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.fromVersion) as { version: string; name: string } | undefined;
if (residualRow) {
console.warn(
`[Migration] ⚠️ Residual row at version ${compatibility.fromVersion} ` +
`(name: "${residualRow.name}") still present after compat rewrite — ` +
`removing to unblock new migration at this version slot.`
);
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ?").run(
compatibility.fromVersion
);
}
}
return repaired;
}
function rehomeLegacyVersionSlotMigrations(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): boolean {
let repaired = false;
const diskNamesByVersion = new Map(files.map((file) => [file.version, file.name]));
for (const legacy of LEGACY_VERSION_SLOT_MIGRATIONS) {
const diskName = diskNamesByVersion.get(legacy.version);
if (!diskName || diskName === legacy.name) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(legacy.version, legacy.name) as { version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const legacyVersion = `legacy-${legacy.version}-${legacy.name}`;
const applyRepair = db.transaction(() => {
const existingLegacyRow = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?")
.get(legacyVersion) as { version: string } | undefined;
if (existingLegacyRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
legacy.version,
legacy.name
);
return;
}
db.prepare("UPDATE _omniroute_migrations SET version = ? WHERE version = ? AND name = ?").run(
legacyVersion,
legacy.version,
legacy.name
);
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Rehomed legacy migration ${legacy.version}_${legacy.name} ` +
`to ${legacyVersion} so current ${legacy.version}_${diskName} can apply.`
);
}
return repaired;
}
/**
* Read a persisted `dbBackup` retention setting through the adapter that is ALREADY open
* for this migration run.
* Run a callback while holding SQLite's IMMEDIATE writer transaction.
*
* `backup.ts`'s equivalent goes through `getDbInstance()`, which is unsafe here: this
* code runs from inside database initialization, so asking for the singleton would
* re-enter it. Reading off `db` keeps the same stored values without that risk. A DB too
* old to have `key_value` yet simply falls back to the default.
* Production adapters expose `immediate()` directly. A small number of long-standing
* migration tests and external callers still pass a raw better-sqlite3 Database, whose
* transaction wrapper exposes `.immediate()` instead. Supporting both shapes here keeps
* the safety transaction real: this must never degrade to a plain callback invocation.
*/
function readStoredBackupSetting(db: SqliteAdapter, key: string, min: number): number | undefined {
try {
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get("dbBackup", key) as { value?: string } | undefined;
if (!row?.value) return undefined;
const parsed = JSON.parse(row.value);
return Number.isInteger(parsed) && parsed >= min ? parsed : undefined;
} catch {
return undefined;
function runImmediateTransaction<T>(db: SqliteAdapter, fn: () => T): T {
const adapterImmediate = (db as Partial<SqliteAdapter>).immediate;
if (typeof adapterImmediate === "function") {
let result!: T;
adapterImmediate.call(db, () => {
result = fn();
});
return result;
}
}
/**
* Enforce the backup retention budget after a pre-migration snapshot (#10421).
*
* Precedence matches `backup.ts`: env override → persisted operator setting → default.
* Never throws: a migration must not fail because housekeeping did.
*/
function pruneMigrationBackups(db: SqliteAdapter, backupDir: string): void {
try {
const maxFiles = process.env.DB_BACKUP_MAX_FILES
? parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS)
: (readStoredBackupSetting(db, "maxFiles", 1) ?? MAX_DB_BACKUPS);
const retentionDays = process.env.DB_BACKUP_RETENTION_DAYS
? parseNonNegativeInt(process.env.DB_BACKUP_RETENTION_DAYS, DEFAULT_DB_BACKUP_RETENTION_DAYS)
: (readStoredBackupSetting(db, "retentionDays", 0) ?? DEFAULT_DB_BACKUP_RETENTION_DAYS);
const result = pruneBackupDirectory({ backupDir, maxFiles, retentionDays });
if (result.deletedFiles > 0) {
console.log(
`[Migration] Pruned ${result.deletedFiles} old backup file(s) ` +
`(${result.keptBackupFamilies} kept, maxFiles=${maxFiles}, retentionDays=${retentionDays}).`
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[Migration] Failed to prune old backups: ${message}`);
}
}
/**
* Create a pre-migration backup of the SQLite database using VACUUM INTO.
* Returns the backup path on success, null on failure.
*/
function createPreMigrationBackup(db: SqliteAdapter): string | null {
try {
const sqliteFile = db.name;
if (!sqliteFile || sqliteFile === ":memory:") return null;
const backupDir = path.join(path.dirname(sqliteFile), "db_backups");
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(backupDir, `db_${timestamp}_pre-migration.sqlite`);
const escapedBackupPath = backupPath.replace(/'/g, "''");
db.exec(`VACUUM INTO '${escapedBackupPath}'`);
console.log(`[Migration] Pre-migration backup created: ${backupPath}`);
// #10421: apply the operator's retention budget right here. Without this the
// migration path was the one backup producer that never pruned, so every process
// start with a pending migration added ~5 MB forever (observed: 49k files / 204 GB).
pruneMigrationBackups(db, backupDir);
return backupPath;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[Migration] Failed to create pre-migration backup: ${message}`);
return null;
const rawTransaction = db.transaction(fn) as ReturnType<SqliteAdapter["transaction"]> & {
immediate?: () => T;
};
if (typeof rawTransaction.immediate !== "function") {
throw new Error("[Migration] Database adapter does not support IMMEDIATE transactions.");
}
return rawTransaction.immediate();
}
/**
@@ -932,15 +759,243 @@ function createPreMigrationBackup(db: SqliteAdapter): string | null {
* 2. Aborts if too many pending migrations on an existing DB (likely wipe)
* 3. Creates automatic backup before running any migrations
*/
export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }): number {
export function runMigrations(
db: SqliteAdapter,
options?: { isNewDb?: boolean; databaseExistedBeforeInitialization?: boolean }
): number {
const isNewDb = options?.isNewDb === true;
// `isNewDb` also covers a setup-created skeleton so it can bypass the mass-migration
// false positive. Snapshot eligibility must use the independent physical-file fact:
// that skeleton can already contain provider credentials and other operator state.
const databaseExistedBeforeInitialization =
options?.databaseExistedBeforeInitialization ?? !isNewDb;
ensureMigrationsTable(db);
const files = filterSupersededDuplicateMigrations(getMigrationFiles());
rehomeLegacyVersionSlotMigrations(db, files);
reconcileRenumberedMigrations(db, files);
const applied = getAppliedVersions(db);
const appliedRecords = getAppliedRecords(db);
validateRequiredPhysicalMigrationProvenance(db, files);
let preMigrationBackup: PreMigrationBackupReceipt | null = null;
let plan!: {
atomicPhysicalReplays: Set<string>;
appliedRecords: Array<{ version: string; name: string }>;
pending: typeof files;
deferredUnsupported: typeof files;
highestAppliedBeforeMigrations: number;
};
let count = 0;
const preliminaryApplied = getAppliedVersions(db);
const preliminaryAtomicReplays = findAtomicPhysicalReplays(db, files);
const preliminaryPending = files.filter(
(file) => !preliminaryApplied.has(file.version) || preliminaryAtomicReplays.has(file.version)
);
const preliminaryDeferred = preliminaryPending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const preliminaryActionable = preliminaryPending.filter(
(migration) => !preliminaryDeferred.some((deferred) => deferred.version === migration.version)
);
const preliminaryHasRepairCandidates = hasLedgerRepairCandidates(db, files);
// Preserve the historical read-only/no-op path. Merely checking an already-current
// database must not acquire a writer lock (or fail SQLITE_BUSY because another supported
// host currently owns one). Safety state is recomputed under IMMEDIATE whenever work exists.
if (preliminaryActionable.length === 0 && !preliminaryHasRepairCandidates) {
const numericApplied = Array.from(preliminaryApplied)
.map((version) => Number.parseInt(version, 10))
.filter((version) => !Number.isNaN(version));
plan = {
atomicPhysicalReplays: preliminaryAtomicReplays,
appliedRecords: getAppliedRecords(db),
pending: preliminaryPending,
deferredUnsupported: preliminaryDeferred,
highestAppliedBeforeMigrations: numericApplied.length > 0 ? Math.max(...numericApplied) : 0,
};
}
// sql.js export() finalizes its active SAVEPOINT, so exporting from inside
// `db.immediate()` would make a later safety throw unable to roll repairs back.
// Its adapter is synchronous and in-memory, so no JavaScript writer can interleave
// between this preflight/export and the immediately following savepoint.
if (
!plan &&
db.driver === "sql.js" &&
(preliminaryActionable.length > 0 || preliminaryHasRepairCandidates)
) {
const needsSnapshot =
(preliminaryActionable.length > 0 || preliminaryHasRepairCandidates) &&
db.name !== ":memory:" &&
databaseExistedBeforeInitialization;
if (needsSnapshot) {
preMigrationBackup = createPreMigrationBackup(db);
if (!preMigrationBackup) {
throw new Error(
"[Migration] Refusing to migrate an existing database without a durable snapshot. " +
"The DATA_DIR filesystem must support atomic hard-link publication."
);
}
}
}
// Hold SQLite's native writer lock through snapshot selection, compatibility repairs,
// and the mass-safety decision. Native adapters open a separate read-only connection
// for VACUUM INTO while competing writers remain blocked. The outer transaction then
// commits before migrations so the repository's one-transaction-per-file contract stays
// intact: an earlier successful migration remains committed if a later file fails.
if (!plan)
runImmediateTransaction(db, () => {
const appliedBeforeRepair = getAppliedVersions(db);
const hadAppliedBeforeRepair = appliedBeforeRepair.size > 0;
const preliminaryAtomicReplays = findAtomicPhysicalReplays(db, files);
const preliminaryPending = files.filter(
(file) =>
!appliedBeforeRepair.has(file.version) || preliminaryAtomicReplays.has(file.version)
);
const preliminaryActionable = preliminaryPending.filter(
(migration) => !isDeferredUnsupportedMigration(db, migration)
);
const mayWriteExistingDatabase =
preliminaryActionable.length > 0 || hasLedgerRepairCandidates(db, files);
const needsSnapshot =
mayWriteExistingDatabase && db.name !== ":memory:" && databaseExistedBeforeInitialization;
if (needsSnapshot && !preMigrationBackup) {
if (db.driver === "sql.js") {
throw new Error(
"[Migration] sql.js safety state changed after its pre-transaction snapshot preflight; " +
"refusing to export from inside the rollback savepoint."
);
}
preMigrationBackup = createPreMigrationBackup(db);
if (!preMigrationBackup) {
throw new Error(
"[Migration] Refusing to migrate an existing database without a durable snapshot. " +
"The DATA_DIR filesystem must support atomic hard-link publication."
);
}
}
rehomeLegacyVersionSlotMigrations(db, files);
reconcileRenumberedMigrations(db, files);
const atomicPhysicalReplays = findAtomicPhysicalReplays(db, files);
const applied = getAppliedVersions(db);
const appliedRecords = getAppliedRecords(db);
const pending = files.filter(
(file) => !applied.has(file.version) || atomicPhysicalReplays.has(file.version)
);
const deferredUnsupported = pending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const actionablePending = pending.filter(
(migration) =>
!deferredUnsupported.some((deferred) => deferred.version === migration.version)
);
const isFreshSeedOnly =
applied.size === 1 &&
applied.has("001") &&
inferPhysicalSchemaBaseline(db) === null &&
hasTable(db, "provider_connections");
const requiresDurableBackup =
actionablePending.length > 0 &&
db.name !== ":memory:" &&
databaseExistedBeforeInitialization;
// Recompute under the same writer transaction as repairs and fail before any
// ledger mutation can commit if the durable-snapshot requirement is not met.
if (requiresDurableBackup && !preMigrationBackup) {
throw new Error(
"[Migration] Refusing to migrate an existing database without a durable snapshot. " +
"The DATA_DIR filesystem must support atomic hard-link publication."
);
}
const isTestEnvironment = isAutomatedTestProcess();
const maxPendingMigrations = resolveMaxPendingMigrations();
if (
actionablePending.length > 0 &&
!isTestEnvironment &&
!isNewDb &&
!isFreshSeedOnly &&
maxPendingMigrations > 0 &&
(applied.size > 0 || hadAppliedBeforeRepair) &&
actionablePending.length > maxPendingMigrations
) {
const physicalBaseline = inferPhysicalSchemaBaseline(db);
const plausiblePendingCount = physicalBaseline
? getPlausiblePendingCount(files, physicalBaseline.version)
: null;
if (plausiblePendingCount !== null && actionablePending.length <= plausiblePendingCount) {
console.warn(
`[Migration] Allowing ${actionablePending.length} pending migrations on an existing database ` +
`because the physical schema only proves ${physicalBaseline?.version} ` +
`(${physicalBaseline?.description}).`
);
} else {
const schemaHint =
physicalBaseline && plausiblePendingCount !== null
? ` Physical schema already shows ${physicalBaseline.version} ` +
`(${physicalBaseline.description}), so at most ${plausiblePendingCount} pending ` +
`migration(s) are expected from a legitimate upgrade.`
: "";
const bypassHint =
` To bypass this check (e.g. after restoring a backup where the migration ` +
`tracking table was wiped), set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 in your ` +
`server.env or DATA_DIR/.env and restart.`;
const msg =
`[Migration] 🛑 ABORT: Detected ${actionablePending.length} pending migrations on an existing database ` +
`(threshold is ${maxPendingMigrations}). ` +
`This usually means the migration tracking table was accidentally wiped. ` +
`Running all migrations from scratch will cause data loss or schema errors.` +
schemaHint +
bypassHint;
if (memoizedSafetyAbort && memoizedSafetyAbort.message === msg) {
console.error(
`[Migration] 🛑 ABORT (repeat — see earlier detail): ` +
`${actionablePending.length} pending > threshold ${maxPendingMigrations}. ` +
`Set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 to bypass.`
);
throw memoizedSafetyAbort;
}
console.error(msg);
memoizedSafetyAbort = new MigrationSafetyAbortError(msg);
throw memoizedSafetyAbort;
}
}
if (
preMigrationBackup &&
hashFileSync(preMigrationBackup.path) !== preMigrationBackup.sha256
) {
throw new Error(
"[Migration] Refusing to migrate because the pre-migration snapshot changed before use."
);
}
const numericApplied = Array.from(applied)
.map((version) => Number.parseInt(version, 10))
.filter((version) => !Number.isNaN(version));
const highestAppliedBeforeMigrations =
numericApplied.length > 0 ? Math.max(...numericApplied) : 0;
plan = {
atomicPhysicalReplays,
appliedRecords,
pending,
deferredUnsupported,
highestAppliedBeforeMigrations,
};
});
const {
atomicPhysicalReplays,
appliedRecords,
pending,
deferredUnsupported,
highestAppliedBeforeMigrations,
} = plan;
// ── Safety Check 1: Detect migration name mismatches (renumbering) ──
const mismatches = detectNameMismatches(appliedRecords, files);
@@ -963,34 +1018,15 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
);
}
// ── Gap Reconciliation: Identify non-contiguous missing migrations ──
// Do not rely on any highest-version-applied heuristic. We must explicitly
// iterate through all missing files on disk and apply them if they are missing
// from the _omniroute_migrations table.
const numericApplied = Array.from(applied)
.map((v) => Number.parseInt(v, 10))
.filter((n) => !Number.isNaN(n));
const highestApplied = numericApplied.length > 0 ? Math.max(...numericApplied) : 0;
const pending = files.filter((f) => {
const isMissing = !applied.has(f.version);
if (isMissing && Number(f.version) < highestApplied) {
for (const migration of pending) {
if (Number(migration.version) < highestAppliedBeforeMigrations) {
console.warn(
`[Migration] 🔄 RECONCILIATION: Found missing intermediate migration ` +
`${f.version}_${f.name} (highest applied is ${highestApplied}). ` +
`${migration.version}_${migration.name} ` +
`(highest applied is ${highestAppliedBeforeMigrations}). ` +
`This gap will be back-filled to ensure schema integrity.`
);
}
return isMissing;
});
const deferredUnsupported = pending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const actionablePending = pending.filter(
(migration) => !deferredUnsupported.some((deferred) => deferred.version === migration.version)
);
if (pending.length === 0) {
return 0; // Nothing to do
}
if (deferredUnsupported.length > 0) {
@@ -1003,101 +1039,28 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
);
}
// ── Safety Check 2: Mass-migration detection (abort if existing DB + many migrations) ──
// Skip in test environments where fresh DBs legitimately have many pending migrations.
const isTestEnvironment = isAutomatedTestProcess();
// #3416: resolve the threshold at call time so OMNIROUTE_MAX_PENDING_MIGRATIONS
// can override the default (0 disables the check). The abort message below
// interpolates this resolved value, so it auto-reflects any override.
const maxPendingMigrations = resolveMaxPendingMigrations();
// #9934: `omniroute setup`'s openOmniRouteDb writes a partial skeleton file
// (provider_connections + key_value) that has never had migrations run. When
// the first `serve` opens it and auto-seeds only the 001 marker, the applied
// set is exactly {001} — which would otherwise look like a wiped existing DB
// and trip this abort on a brand-new install. This is distinct from a real
// wiped/backup-restored database: that case has a non-trivial physical schema
// (baseline inference is non-null) and full data tables, so it still aborts.
// The 001-marker-only state on a provider_connections skeleton is the fresh
// auto-seed — let it through. A genuinely empty table is already exempt via
// `applied.size > 0`, and an upgraded DB has a non-trivial applied set.
const isFreshSeedOnly =
applied.size === 1 &&
applied.has("001") &&
inferPhysicalSchemaBaseline(db) === null &&
hasTable(db, "provider_connections");
if (
!isTestEnvironment &&
!isNewDb &&
!isFreshSeedOnly &&
process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" &&
maxPendingMigrations > 0 &&
applied.size > 0 &&
actionablePending.length > maxPendingMigrations
) {
const physicalBaseline = inferPhysicalSchemaBaseline(db);
const plausiblePendingCount = physicalBaseline
? getPlausiblePendingCount(files, physicalBaseline.version)
: null;
if (plausiblePendingCount !== null && actionablePending.length <= plausiblePendingCount) {
console.warn(
`[Migration] Allowing ${actionablePending.length} pending migrations on an existing database ` +
`because the physical schema only proves ${physicalBaseline?.version} ` +
`(${physicalBaseline?.description}).`
);
} else {
const schemaHint =
physicalBaseline && plausiblePendingCount !== null
? ` Physical schema already shows ${physicalBaseline.version} ` +
`(${physicalBaseline.description}), so at most ${plausiblePendingCount} pending ` +
`migration(s) are expected from a legitimate upgrade.`
: "";
const bypassHint =
` To bypass this check (e.g. after restoring a backup where the migration ` +
`tracking table was wiped), set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 in your ` +
`server.env or DATA_DIR/.env and restart.`;
const msg =
`[Migration] 🛑 ABORT: Detected ${actionablePending.length} pending migrations on an existing database ` +
`(threshold is ${maxPendingMigrations}). ` +
`This usually means the migration tracking table was accidentally wiped. ` +
`Running all migrations from scratch will cause data loss or schema errors.` +
schemaHint +
bypassHint;
// #6260: memoize so the cascade of downstream ensureDbInitialized() calls
// that re-open the DB throw the SAME instance and only log once.
if (memoizedSafetyAbort && memoizedSafetyAbort.message === msg) {
console.error(
`[Migration] 🛑 ABORT (repeat — see earlier detail): ` +
`${actionablePending.length} pending > threshold ${maxPendingMigrations}. ` +
`Set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 to bypass.`
);
throw memoizedSafetyAbort;
}
console.error(msg);
memoizedSafetyAbort = new MigrationSafetyAbortError(msg);
throw memoizedSafetyAbort;
}
if (preMigrationBackup && hashFileSync(preMigrationBackup.path) !== preMigrationBackup.sha256) {
throw new Error(
"[Migration] Refusing to migrate because the pre-migration snapshot changed before use."
);
}
// ── Safety Check 3: Pre-migration backup ──
// Skip backup if it's a completely fresh database (0 applied and all pending)
// or if running in tests (where AUTO_BACKUP might be disabled)
if (applied.size > 0 && process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true") {
createPreMigrationBackup(db);
}
let count = 0;
for (const migration of pending) {
if (isDeferredUnsupportedMigration(db, migration)) {
continue;
}
if (isDeferredUnsupportedMigration(db, migration)) continue;
const applyMigration = db.transaction(() => {
if (atomicPhysicalReplays.has(migration.version)) {
const removed = db
.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?")
.run(migration.version, migration.name);
if (removed.changes !== 1) {
throw new Error(
`[Migration] Atomic replay lost its expected ledger marker for ` +
`${migration.version}_${migration.name}.`
);
}
}
if (isSchemaAlreadyApplied(db, migration)) {
console.warn(
`[Migration] Skipped executing ${migration.version}_${migration.name} as schema changes are already present (Idempotency check).`
@@ -1120,29 +1083,36 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
try {
applyMigration();
count++;
count += 1;
console.log(`[Migration] Applied: ${migration.version}_${migration.name}`);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
// "duplicate column name" means the column already exists — end state achieved, mark applied.
if (message.includes("duplicate column name")) {
if (
message.includes("duplicate column name") &&
!atomicPhysicalReplays.has(migration.version)
) {
const applyMarkerOnly = db.transaction(() => {
db.prepare(
"INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)"
).run(migration.version, migration.name);
});
applyMarkerOnly();
count++;
count += 1;
console.log(
`[Migration] Applied (column pre-exists): ${migration.version}_${migration.name}`
);
} else {
console.error(`[Migration] FAILED: ${migration.version}_${migration.name}${message}`);
throw err; // Re-throw to prevent DB from starting in inconsistent state
throw err;
}
}
}
// Retention intentionally does not run inside the migration window. Another process
// may still be using a different snapshot as its in-flight restore point. Manual and
// scheduled backup paths continue to enforce the operator's retention policy; retries
// here are bounded by the deterministic content address instead of destructive pruning.
if (count > 0) {
console.log(`[Migration] ${count} migration(s) applied successfully.`);
}
@@ -1175,7 +1145,7 @@ function insertDefaultDatabaseSettings(db: SqliteAdapter) {
// Run in an immediate transaction to avoid nested transactions
try {
db.immediate(() => {
runImmediateTransaction(db, () => {
tx();
});
} catch (error) {

View File

@@ -158,6 +158,14 @@ export const RENAMED_MIGRATION_COMPATIBILITY = [
toVersion: "151",
toName: "windsurf_to_devin_desktop",
},
{
// inspector_custom_hosts was once published in slot 074, now occupied by
// discovery_results. Its canonical idempotent migration lives at 081.
fromVersion: "074",
fromName: "inspector_custom_hosts",
toVersion: "081",
toName: "inspector_custom_hosts",
},
{
fromVersion: "134",
fromName: "ccr_blocks",

View File

@@ -0,0 +1,13 @@
const isNodeTestRunnerChild = typeof process.env.NODE_TEST_CONTEXT === "string";
export const migrationConsole = {
log: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.log(...args);
},
warn: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.warn(...args);
},
error: (...args: unknown[]) => {
globalThis.console.error(...args);
},
};

View File

@@ -0,0 +1,293 @@
import { createHash } from "crypto";
import fs from "fs";
import path from "path";
import type { SqliteAdapter } from "../adapters/types";
import { tryOpenSync } from "../adapters/driverFactory";
import { migrationConsole as console } from "./logger";
export type PreMigrationBackupReceipt = {
path: string;
sha256: string;
};
function fsyncDirectoryEntry(directory: string): void {
let fd: number | null = null;
try {
fd = fs.openSync(directory, "r");
fs.fsyncSync(fd);
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | null)?.code;
const windowsDirectoryHandleUnsupported =
process.platform === "win32" &&
(code === "EACCES" || code === "EPERM" || code === "EISDIR" || code === "EINVAL");
if (!windowsDirectoryHandleUnsupported) throw error;
} finally {
if (fd !== null) fs.closeSync(fd);
}
}
export function hashFileSync(filePath: string): string {
const hash = createHash("sha256");
const fd = fs.openSync(filePath, "r");
const buffer = Buffer.allocUnsafe(1024 * 1024);
let position = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, position);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
} finally {
fs.closeSync(fd);
}
return hash.digest("hex");
}
function getReusablePreMigrationBackup(
candidatePath: string,
expectedSha256: string
): PreMigrationBackupReceipt | null {
if (!fs.existsSync(candidatePath)) return null;
const before = fs.lstatSync(candidatePath);
if (!before.isFile() || hashFileSync(candidatePath) !== expectedSha256) {
throw new Error(
`[Migration] Content-addressed snapshot path exists with unexpected content: ${candidatePath}`
);
}
const after = fs.lstatSync(candidatePath);
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs
) {
throw new Error(
`[Migration] Content-addressed snapshot changed while it was being validated: ${candidatePath}`
);
}
return { path: candidatePath, sha256: expectedSha256 };
}
function publishSnapshotWithoutOverwrite(tempPath: string, destination: string): void {
// link() publishes a complete same-filesystem image atomically and, unlike rename(),
// fails with EEXIST instead of overwriting a path created by another process. There is
// deliberately no copy/rename fallback: filesystems without this primitive fail closed
// instead of exposing a partial canonical `.sqlite` file after a crash.
fs.linkSync(tempPath, destination);
const publishedFd = fs.openSync(destination, "r+");
try {
// Flush through the published name as well as the already-fsynced temp handle.
// On Windows this maps to FlushFileBuffers and is the strongest file-level
// durability proof available when directory handles are unsupported by Node.
fs.fsyncSync(publishedFd);
} finally {
fs.closeSync(publishedFd);
}
fsyncDirectoryEntry(path.dirname(destination));
}
function fsyncReusableSnapshot(snapshotPath: string): void {
const fd = fs.openSync(snapshotPath, "r+");
try {
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
type SqlJsSnapshotClone = {
run(sql: string): void;
export(): Uint8Array;
close(): void;
};
const SQLITE_HEADER_MIN_BYTES = 100;
const SQLITE_HEADER_MAGIC = "SQLite format 3\0";
const SQLITE_CHANGE_COUNTER_OFFSET = 24;
const SQLITE_VERSION_VALID_FOR_OFFSET = 92;
const SQLITE_STANDALONE_CHANGE_COUNTER = 1;
function exportCanonicalSqlJsSnapshot(raw: { export: () => Uint8Array }): Buffer {
const RawDatabase = (
raw as unknown as { constructor: new (data: Uint8Array) => SqlJsSnapshotClone }
).constructor;
let clone: SqlJsSnapshotClone | null = null;
try {
// A rolled-back sql.js SAVEPOINT can leave SQLite's physical change counter advanced
// even though every logical row/schema change was undone. Canonicalize only a detached
// clone: VACUUM removes rollback-only page artifacts without touching the live database.
clone = new RawDatabase(raw.export());
clone.run("VACUUM");
const canonical = Buffer.from(clone.export());
if (
canonical.length < SQLITE_HEADER_MIN_BYTES ||
canonical.subarray(0, SQLITE_HEADER_MAGIC.length).toString("binary") !== SQLITE_HEADER_MAGIC
) {
throw new Error("sql.js export did not produce a valid SQLite file header");
}
// SQLite file-header offsets 24 and 92 are the change counter and
// version-valid-for number. VACUUM keeps the two equal, but seeds them from the
// source image, so an otherwise identical rolled-back retry still gets a different
// byte hash. A standalone snapshot has no open readers to invalidate; assigning the
// same stable value to both fields preserves a valid/restorable header while making
// the complete canonical image deterministic.
canonical.writeUInt32BE(SQLITE_STANDALONE_CHANGE_COUNTER, SQLITE_CHANGE_COUNTER_OFFSET);
canonical.writeUInt32BE(SQLITE_STANDALONE_CHANGE_COUNTER, SQLITE_VERSION_VALID_FOR_OFFSET);
return canonical;
} finally {
clone?.close();
}
}
function writeSqlJsSnapshot(raw: { export: () => Uint8Array }, tempPath: string): void {
let fd: number | null = null;
try {
fd = fs.openSync(tempPath, "wx");
fs.writeFileSync(fd, exportCanonicalSqlJsSnapshot(raw));
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = null;
} catch (error: unknown) {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// The original snapshot error remains authoritative.
}
}
throw error;
}
}
function cleanupOwnedSnapshotTemp(tempDir: string | null, tempPath: string | null): void {
if (!tempDir || !fs.existsSync(tempDir)) return;
try {
// `tempDir` comes only from mkdtempSync below. Removing that exact owned directory
// lets Node retry Windows/AV EBUSY and EPERM failures without touching canonical backups.
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[Migration] Failed to remove owned snapshot temp directory` +
`${tempPath ? ` (${tempPath})` : ""}: ${message}`
);
}
}
/**
* Create a synchronous pre-migration snapshot.
*
* Native SQLite drivers use VACUUM INTO. sql.js has an in-memory VFS, so a host
* path passed to VACUUM INTO is not writable; export its current database image
* directly instead. The SHA-256 content address lives in the first portion of the
* canonical `db_<snapshot-id>_<reason>.sqlite` shape, preserving reason parsing while
* making unchanged retries an O(1) lookup even with tens of thousands of old backups.
* Work happens inside an exclusively-created
* temp directory, so failure cleanup has exact ownership. Publication uses an atomic,
* no-overwrite hard link. If the filesystem cannot provide that primitive, the caller
* fails closed instead of exposing a partial canonical `.sqlite` file. A content hash
* reuses an identical prior snapshot, so repeated zero-progress startups retain one
* restore point for that database state without ever deleting a published backup.
*/
export function createPreMigrationBackup(db: SqliteAdapter): PreMigrationBackupReceipt | null {
let backupPath: string | null = null;
let tempPath: string | null = null;
let tempDir: string | null = null;
try {
const sqliteFile = db.name;
if (!sqliteFile || sqliteFile === ":memory:") return null;
const backupDir = path.join(path.dirname(sqliteFile), "db_backups");
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
fsyncDirectoryEntry(path.dirname(backupDir));
}
tempDir = fs.mkdtempSync(path.join(backupDir, ".migration-snapshot-"));
tempPath = path.join(tempDir, "snapshot.sqlite");
if (db.driver === "sql.js") {
const raw = db.raw as { export?: () => Uint8Array } | null;
if (!raw || typeof raw.export !== "function") {
throw new Error("sql.js adapter does not expose database export()");
}
writeSqlJsSnapshot(raw as { export: () => Uint8Array }, tempPath);
} else {
const escapedTempPath = tempPath.replace(/'/g, "''");
const snapshotDb = tryOpenSync(sqliteFile, { readonly: true, fileMustExist: true });
if (!snapshotDb) {
throw new Error("no synchronous read-only SQLite driver is available for snapshotting");
}
try {
snapshotDb.exec(`VACUUM INTO '${escapedTempPath}'`);
} finally {
snapshotDb.close();
}
const fd = fs.openSync(tempPath, "r+");
try {
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
const sha256 = hashFileSync(tempPath);
backupPath = path.join(backupDir, `db_state-${sha256}_pre-migration.sqlite`);
const reusable = getReusablePreMigrationBackup(backupPath, sha256);
if (reusable) {
fsyncReusableSnapshot(reusable.path);
fsyncDirectoryEntry(backupDir);
cleanupOwnedSnapshotTemp(tempDir, tempPath);
tempDir = null;
tempPath = null;
console.log(`[Migration] Reusing identical pre-migration backup: ${reusable.path}`);
return reusable;
}
try {
publishSnapshotWithoutOverwrite(tempPath, backupPath);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException | null)?.code !== "EEXIST") throw error;
const racedReusable = getReusablePreMigrationBackup(backupPath, sha256);
if (!racedReusable) throw error;
fsyncReusableSnapshot(racedReusable.path);
fsyncDirectoryEntry(backupDir);
cleanupOwnedSnapshotTemp(tempDir, tempPath);
tempDir = null;
tempPath = null;
console.log(`[Migration] Reusing concurrently published backup: ${racedReusable.path}`);
return racedReusable;
}
cleanupOwnedSnapshotTemp(tempDir, tempPath);
tempDir = null;
tempPath = null;
console.log(`[Migration] Pre-migration backup created: ${backupPath}`);
return { path: backupPath, sha256 };
} catch (error: unknown) {
// Never unlink a canonical backup here: publication may have failed because another
// actor created it first. The exclusive temp directory is the only cleanup authority.
cleanupOwnedSnapshotTemp(tempDir, tempPath);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[Migration] Failed to create pre-migration backup: ${message}`);
throw new Error(
`[Migration] Refusing to migrate an existing database without a durable snapshot. ` +
`Snapshot creation failed: ${message}. The DATA_DIR filesystem must support atomic ` +
`no-overwrite hard links, durable file synchronization, and directory synchronization ` +
`where the platform exposes it.`,
{ cause: error instanceof Error ? error : undefined }
);
}
}

View File

@@ -0,0 +1,248 @@
import type { SqliteAdapter } from "../adapters/types";
import {
INITIAL_SCHEMA_SENTINELS,
LEGACY_VERSION_SLOT_MIGRATIONS,
PHYSICAL_SCHEMA_SENTINELS,
RENAMED_MIGRATION_COMPATIBILITY,
} from "./constants";
import { migrationConsole as console } from "./logger";
type MigrationFile = { version: string; name: string; path: string };
export function hasTable(db: SqliteAdapter, tableName: string): boolean {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?")
.get(tableName) as { name?: string } | undefined;
return Boolean(row?.name);
}
export function hasPhysicalTable(db: SqliteAdapter, tableName: string): boolean {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { name?: string } | undefined;
return Boolean(row?.name);
}
export function hasColumn(db: SqliteAdapter, tableName: string, columnName: string): boolean {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return columns.some((column) => column.name === columnName);
}
export function inferPhysicalSchemaBaseline(db: SqliteAdapter): {
version: string;
description: string;
} | null {
for (const sentinel of PHYSICAL_SCHEMA_SENTINELS) {
if (hasTable(db, sentinel.tableName)) {
return {
version: sentinel.version,
description: sentinel.description,
};
}
}
const hasInitialSchema = INITIAL_SCHEMA_SENTINELS.every((tableName) => hasTable(db, tableName));
if (hasInitialSchema) {
return {
version: "001",
description: "initial schema tables",
};
}
return null;
}
export function getPlausiblePendingCount(files: MigrationFile[], baselineVersion: string): number {
const baseline = Number.parseInt(baselineVersion, 10);
return files.filter((file) => Number.parseInt(file.version, 10) > baseline).length;
}
/**
* Detect migration name mismatches — when a migration version number
* has been reused/renumbered with a different name. This is a strong signal
* that the migration tracking is corrupted or migrations were renumbered.
*/
export function detectNameMismatches(
appliedRecords: Array<{ version: string; name: string }>,
files: MigrationFile[]
): Array<{ version: string; appliedName: string; diskName: string }> {
const appliedByName = new Map(appliedRecords.map((record) => [record.version, record.name]));
const mismatches: Array<{ version: string; appliedName: string; diskName: string }> = [];
for (const file of files) {
const appliedName = appliedByName.get(file.version);
if (appliedName && appliedName !== file.name) {
mismatches.push({
version: file.version,
appliedName,
diskName: file.name,
});
}
}
return mismatches;
}
export function reconcileRenumberedMigrations(db: SqliteAdapter, files: MigrationFile[]): boolean {
let repaired = false;
for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) {
const hasTargetFile = files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
);
const hasSourceFile = files.some(
(file) => file.version === compatibility.fromVersion && file.name !== compatibility.fromName
);
if (!hasTargetFile || !hasSourceFile) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(compatibility.fromVersion, compatibility.fromName) as
{ version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const targetRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.toVersion) as { version: string; name: string } | undefined;
const isSameSlotReplacement = compatibility.fromVersion === compatibility.toVersion;
if (targetRow && !isSameSlotReplacement && targetRow.name !== compatibility.toName) {
throw new Error(
`[Migration] Cannot reconcile ${compatibility.fromVersion}_${compatibility.fromName}: ` +
`target version ${compatibility.toVersion} is occupied by unknown migration ` +
`"${targetRow.name}" (expected "${compatibility.toName}").`
);
}
const applyRepair = db.transaction(() => {
if (targetRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
compatibility.fromVersion,
compatibility.fromName
);
} else {
db.prepare(
"UPDATE _omniroute_migrations SET version = ?, name = ? WHERE version = ? AND name = ?"
).run(
compatibility.toVersion,
compatibility.toName,
compatibility.fromVersion,
compatibility.fromName
);
}
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Reconciled renamed migration ${compatibility.fromVersion}_${compatibility.fromName} ` +
`to ${compatibility.toVersion}_${compatibility.toName} to preserve pending migrations.`
);
// After the compat rewrite, verify the old version slot is now free.
// A residual row (from a failed prior run, manual intervention, or edge-case
// UPDATE conflict) at the old version would shadow a NEW migration file
// placed at that version number — e.g. 028_create_files_and_batches.sql
// would be skipped because getAppliedVersions() still sees version "028".
const residualRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.fromVersion) as { version: string; name: string } | undefined;
if (residualRow) {
console.warn(
`[Migration] ⚠️ Residual row at version ${compatibility.fromVersion} ` +
`(name: "${residualRow.name}") still present after compat rewrite — ` +
`removing to unblock new migration at this version slot.`
);
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ?").run(
compatibility.fromVersion
);
}
}
return repaired;
}
export function rehomeLegacyVersionSlotMigrations(
db: SqliteAdapter,
files: MigrationFile[]
): boolean {
let repaired = false;
const diskNamesByVersion = new Map(files.map((file) => [file.version, file.name]));
for (const legacy of LEGACY_VERSION_SLOT_MIGRATIONS) {
const diskName = diskNamesByVersion.get(legacy.version);
if (!diskName || diskName === legacy.name) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(legacy.version, legacy.name) as { version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const legacyVersion = `legacy-${legacy.version}-${legacy.name}`;
const applyRepair = db.transaction(() => {
const existingLegacyRow = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?")
.get(legacyVersion) as { version: string } | undefined;
if (existingLegacyRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
legacy.version,
legacy.name
);
return;
}
db.prepare("UPDATE _omniroute_migrations SET version = ? WHERE version = ? AND name = ?").run(
legacyVersion,
legacy.version,
legacy.name
);
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Rehomed legacy migration ${legacy.version}_${legacy.name} ` +
`to ${legacyVersion} so current ${legacy.version}_${diskName} can apply.`
);
}
return repaired;
}
export function hasLedgerRepairCandidates(db: SqliteAdapter, files: MigrationFile[]): boolean {
const diskNamesByVersion = new Map(files.map((file) => [file.version, file.name]));
for (const legacy of LEGACY_VERSION_SLOT_MIGRATIONS) {
const diskName = diskNamesByVersion.get(legacy.version);
if (!diskName || diskName === legacy.name) continue;
const row = db
.prepare("SELECT 1 FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(legacy.version, legacy.name);
if (row) return true;
}
for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) {
const hasTargetFile = files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
);
const hasSourceFile = files.some(
(file) => file.version === compatibility.fromVersion && file.name !== compatibility.fromName
);
if (!hasTargetFile || !hasSourceFile) continue;
const row = db
.prepare("SELECT 1 FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(compatibility.fromVersion, compatibility.fromName);
if (row) return true;
}
return false;
}

View File

@@ -1,5 +1,9 @@
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
import { CREDENTIAL_PATTERNS } from "@omniroute/open-sse/utils/credentialPatterns.ts";
import { getSettings } from "@/lib/db/settings";
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
export { CREDENTIAL_PATTERNS };
export type { CredentialPattern } from "@omniroute/open-sse/utils/credentialPatterns.ts";
/**
* CredentialMaskerGuardrail — redacts well-known API-key / secret-token patterns
@@ -11,88 +15,6 @@ import { getSettings } from "@/lib/db/settings";
* Future: per-pipeline / per-provider scoping via GuardrailContext.
*/
export interface CredentialPattern {
name: string;
regex: RegExp;
replacement: string;
}
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
// ── LLM provider keys ──────────────────────────────────────────────────
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
{
name: "anthropic",
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{
name: "anthropic_alt",
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{ name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" },
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
// ── VCS / SaaS tokens ──────────────────────────────────────────────────
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
{ name: "postman", regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g, replacement: "[REDACTED:postman]" },
{
name: "discord",
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
replacement: "[REDACTED:discord]",
},
// ── Payments ───────────────────────────────────────────────────────────
{
name: "stripe",
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
replacement: "[REDACTED:stripe]",
},
{
name: "square",
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
replacement: "[REDACTED:square]",
},
// ── Cloud / infra ──────────────────────────────────────────────────────
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
{
name: "sendgrid",
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
replacement: "[REDACTED:sendgrid]",
},
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
// ── Crypto / identity ──────────────────────────────────────────────────
{
name: "private_key",
regex:
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
replacement: "[REDACTED:private_key]",
},
{
name: "jwt",
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
replacement: "[REDACTED:jwt]",
},
// ── Connection strings (creds embedded in URI) ─────────────────────────
{
name: "connection_string",
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
replacement: "[REDACTED:connection_string]",
},
// ── Header-style secrets ───────────────────────────────────────────────
{
name: "auth_header",
regex:
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
replacement: "$1[REDACTED:auth_header]",
},
];
export interface CredentialRedactionResult {
text: string;
detections: Array<{ type: string; count: number }>;

View File

@@ -1,3 +1,8 @@
import {
sanitizeErrorMessage,
sanitizeUpstreamDetails,
} from "@omniroute/open-sse/utils/errorSanitization.ts";
import { projectResponsesFailureOutput } from "@omniroute/open-sse/utils/responsesFailureOutput.ts";
import { sanitizePII } from "./piiSanitizer";
const SENSITIVE_KEYS = new Set([
@@ -35,6 +40,21 @@ const SENSITIVE_KEYS = new Set([
"runtimeKey",
]);
const SENSITIVE_CHALLENGE_KEYS = new Set([
"recaptchav3token",
"recaptchatoken",
"turnstiletoken",
"prooftoken",
"resumetoken",
"preparetoken",
]);
function isSensitivePayloadKey(key: string): boolean {
if (SENSITIVE_KEYS.has(key)) return true;
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
return SENSITIVE_CHALLENGE_KEYS.has(normalizedKey);
}
type JsonRecord = Record<string, unknown>;
const ENCRYPTED_REASONING_KEY = "encrypted_content";
@@ -60,6 +80,283 @@ export function omitEncryptedReasoningFromLogChunks(chunks: string[]): string[]
return found ? [omitted] : chunks;
}
const ERROR_SUBTREE_KEYS = new Set([
"error",
"errors",
"warning",
"warnings",
"errormessage",
"warningmessage",
"errordescription",
"warningdescription",
"lasterror",
]);
function isErrorSubtreeKey(key: string): boolean {
return ERROR_SUBTREE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase());
}
function sanitizeErrorSubtreeValue(value: unknown): unknown {
if (typeof value === "string") return sanitizeErrorMessage(value);
try {
if (value instanceof Error) {
return {
name: sanitizeErrorMessage(value.name) || "Error",
message: sanitizeErrorMessage(value.message),
};
}
return sanitizeUpstreamDetails(value);
} catch {
return "[REDACTED]";
}
}
type ErrorSubtreeProjection = { value: unknown; found: boolean };
function projectErrorSubtreesForLog(
value: unknown,
seen = new WeakSet<object>(),
forceResponsesFailure = false,
protocolResponseObject = false
): ErrorSubtreeProjection {
if (forceResponsesFailure && typeof value === "string") {
return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true };
}
if (typeof value === "string") {
const trimmed = value.trim();
if (
(trimmed.startsWith("{") || trimmed.startsWith("[")) &&
STREAM_ERROR_ENVELOPE_RE.test(trimmed)
) {
try {
const parsed: unknown = JSON.parse(trimmed);
const projected = isDiscriminatedStreamError(parsed)
? { value: sanitizeErrorSubtreeValue(parsed), found: true }
: projectErrorSubtreesForLog(parsed, seen);
if (projected.found) {
const serialized = JSON.stringify(projected.value);
if (typeof serialized === "string") return { value: serialized, found: true };
}
} catch {
return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true };
}
}
return { value, found: false };
}
if (value === null || value === undefined || typeof value !== "object") {
return { value, found: false };
}
if (isOpaqueBinary(value)) return { value, found: false };
if (isDiscriminatedStreamError(value)) {
return { value: sanitizeErrorSubtreeValue(value), found: true };
}
const declaresResponsesFailure = isResponsesFailureEvent(value);
const responsesFailure = forceResponsesFailure || declaresResponsesFailure;
if (seen.has(value)) return { value: "[circular]", found: false };
seen.add(value);
if (Array.isArray(value)) {
try {
let found = false;
const projected = value.map((entry) => {
const result = projectErrorSubtreesForLog(entry, seen, responsesFailure, false);
found ||= result.found;
return result.value;
});
return { value: projected, found };
} finally {
seen.delete(value);
}
}
try {
let found = responsesFailure;
const projected: JsonRecord = {};
for (const [key, entryValue] of Object.entries(value)) {
if (isErrorSubtreeKey(key) || (responsesFailure && isResponseFailureMessageKey(key))) {
projected[key] = sanitizeErrorSubtreeValue(entryValue);
found = true;
continue;
}
// Responses failures may attach diagnostics under neutral key names. Keep
// projecting through that envelope, while preserving partial model output
// as content rather than treating it as an error message.
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
const preservePartialOutput =
responsesFailure &&
normalizedKey === "output" &&
(protocolResponseObject || declaresResponsesFailure);
if (preservePartialOutput) {
projected[key] = projectResponsesFailureOutput(
entryValue,
(_field, stringValue) => sanitizeErrorMessage(stringValue) || "[REDACTED]"
);
found = true;
continue;
}
const childIsProtocolResponse =
normalizedKey === "response" &&
(declaresResponsesFailure || (forceResponsesFailure && !protocolResponseObject));
const result = projectErrorSubtreesForLog(
entryValue,
seen,
responsesFailure,
childIsProtocolResponse
);
projected[key] = result.value;
found ||= result.found;
}
return { value: projected, found };
} catch {
return { value: "[REDACTED]", found: false };
} finally {
seen.delete(value);
}
}
const STREAM_ERROR_DISCRIMINATOR_KEYS = ["type", "event", "kind", "status"] as const;
const STREAM_ERROR_DISCRIMINATORS = new Set(["error", "warning"]);
const RESPONSES_FAILURE_DISCRIMINATORS = new Set(["response.failed"]);
const RESPONSE_FAILURE_MESSAGE_KEYS = new Set(["message", "detail", "details", "description"]);
const STREAM_ERROR_ENVELOPE_RE =
/["'](?:error|errors|warning|warnings|last_error|lastError|errorMessage|warningMessage)["']\s*:|["'](?:type|event|kind)["']\s*:\s*["'](?:error|warning|response\.(?:failed|completed))["']|["']status["']\s*:\s*["']failed["']/i;
function isResponseFailureMessageKey(key: string): boolean {
return RESPONSE_FAILURE_MESSAGE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase());
}
function isResponsesFailureEvent(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
try {
const record = value as JsonRecord;
const directFailure = STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => {
const discriminator = record[key];
return (
typeof discriminator === "string" &&
RESPONSES_FAILURE_DISCRIMINATORS.has(discriminator.trim().toLowerCase())
);
});
if (directFailure) return true;
const status = record.status;
if (typeof status === "string" && status.trim().toLowerCase() === "failed") return true;
const nestedResponse = record.response;
if (!nestedResponse || typeof nestedResponse !== "object" || Array.isArray(nestedResponse)) {
return false;
}
const nestedStatus = (nestedResponse as JsonRecord).status;
return typeof nestedStatus === "string" && nestedStatus.trim().toLowerCase() === "failed";
} catch {
return true;
}
}
function isDiscriminatedStreamError(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
try {
const record = value as JsonRecord;
return STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => {
const discriminator = record[key];
return (
typeof discriminator === "string" &&
STREAM_ERROR_DISCRIMINATORS.has(discriminator.trim().toLowerCase())
);
});
} catch {
return true;
}
}
function sanitizeStreamErrorPayload(
rawPayload: string,
forceError: boolean,
forceResponsesFailure = false
): { found: boolean; value: string } {
try {
const parsed: unknown = JSON.parse(rawPayload);
if (forceError || isDiscriminatedStreamError(parsed)) {
const projected = sanitizeErrorSubtreeValue(parsed);
const serialized = JSON.stringify(projected);
return {
found: true,
value: typeof serialized === "string" ? serialized : "[REDACTED]",
};
}
const projected = projectErrorSubtreesForLog(
parsed,
new WeakSet<object>(),
forceResponsesFailure
);
if (!projected.found) return { found: false, value: rawPayload };
return { found: true, value: JSON.stringify(projected.value) };
} catch {
if (!forceError && !forceResponsesFailure && !STREAM_ERROR_ENVELOPE_RE.test(rawPayload)) {
return { found: false, value: rawPayload };
}
return {
found: true,
value: sanitizeErrorMessage(rawPayload) || "[REDACTED]",
};
}
}
/**
* Sanitize error/warning records captured as fragmented SSE or NDJSON text.
* Prefixes are matched at the start of a line so unrelated `metadata:` fields
* cannot be mistaken for SSE `data:` frames.
*/
export function sanitizeErrorFramesFromLogChunks(chunks: string[]): string[] {
const combined = chunks.map((chunk) => chunk.replace(STREAM_CHUNK_TIMESTAMP_RE, "")).join("");
let found = false;
let errorEventActive = false;
let responsesFailureEventActive = false;
const projectedLines = combined.split("\n").map((line) => {
if (line.trim().length === 0) {
errorEventActive = false;
responsesFailureEventActive = false;
return line;
}
const eventMatch = line.match(/^\s*event:\s*([^\s]+)\s*$/i);
if (eventMatch) {
const eventName = eventMatch[1].toLowerCase();
errorEventActive = STREAM_ERROR_DISCRIMINATORS.has(eventName);
responsesFailureEventActive = RESPONSES_FAILURE_DISCRIMINATORS.has(eventName);
return line;
}
const dataMatch = line.match(/^(\s*data:)([ \t]?)(.*)$/);
if (dataMatch) {
const rawPayload = dataMatch[3].trim();
if (!rawPayload || rawPayload === "[DONE]") return line;
const projected = sanitizeStreamErrorPayload(
rawPayload,
errorEventActive,
responsesFailureEventActive
);
if (!projected.found) return line;
found = true;
return `${dataMatch[1]}${dataMatch[2]}${projected.value}`;
}
if (errorEventActive || responsesFailureEventActive) {
found = true;
return sanitizeErrorMessage(line) || "[REDACTED]";
}
const trimmed = line.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return line;
const projected = sanitizeStreamErrorPayload(trimmed, false);
if (!projected.found) return line;
found = true;
return `${line.slice(0, line.length - line.trimStart().length)}${projected.value}`;
});
return found ? [projectedLines.join("\n")] : chunks;
}
/**
* True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other
* typed arrays). `Array.isArray()` returns false for these, so callers that
@@ -125,7 +422,7 @@ export function redactPayload(payload: unknown): unknown {
const redacted: JsonRecord = {};
for (const [key, value] of Object.entries(payload)) {
if (SENSITIVE_KEYS.has(key)) {
if (isSensitivePayloadKey(key)) {
redacted[key] = "[REDACTED]";
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
redacted[key] = "Bearer [REDACTED]";
@@ -162,7 +459,19 @@ export function sanitizePayloadPII(payload: unknown): unknown {
export function protectPayloadForLog(payload: unknown): unknown {
if (payload === null || payload === undefined) return null;
const normalized = normalizePayloadForLog(payload);
const reasoningOmitted = omitEncryptedReasoningForLog(normalized);
const errorProjected = projectErrorSubtreesForLog(normalized).value;
const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected);
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
return redactPayload(piiSanitized);
}
/** Project every string leaf because the payload is known to represent a failed response. */
export function protectErrorPayloadForLog(payload: unknown): unknown {
if (payload === null || payload === undefined) return null;
const normalized = normalizePayloadForLog(payload);
if (isOpaqueBinary(normalized)) return describeOpaqueBinary(normalized);
const errorProjected = sanitizeErrorSubtreeValue(normalized);
const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected);
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
return redactPayload(piiSanitized);
}

View File

@@ -1,6 +1,7 @@
// Outbound fetch wrappers for provider validation: proxy-fallback, SSRF-aware proxy targeting, and
// error→result mapping. Extracted from validation.ts (god-file decomposition). Behavior is
// byte-identical to the original inline defs.
// error→result mapping. Extracted from validation.ts (god-file decomposition) and kept as the
// common boundary for sanitizing validation failures.
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import {
SAFE_OUTBOUND_FETCH_PRESETS,
SafeOutboundFetchError,
@@ -11,6 +12,28 @@ import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
export type ProjectedProviderValidationResult<T> = {
[K in keyof T]: K extends "error" | "warning" ? string | null : T[K];
} & {
error?: string | null;
warning?: string | null;
};
export function projectProviderValidationResultForPublicResponse<
T extends { error?: unknown; warning?: unknown },
>(result: T): ProjectedProviderValidationResult<T>;
export function projectProviderValidationResultForPublicResponse(
result: Record<string, unknown>
): Record<string, unknown> {
const projected: Record<string, unknown> = { ...result };
for (const field of ["error", "warning"] as const) {
if (!Object.prototype.hasOwnProperty.call(result, field)) continue;
const value = result[field];
projected[field] = value === null || value === undefined ? null : sanitizeErrorMessage(value);
}
return projected;
}
/**
* Wrapped fetch call that auto-retries with a proxy when the direct connection
* fails. This happens transparently so individual validators don't need to
@@ -156,17 +179,30 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow
}
export function toValidationErrorResult(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "Validation failed");
const statusCode = getSafeOutboundFetchErrorStatus(error);
let rawMessage: unknown = error || "Validation failed";
try {
if (error instanceof Error) rawMessage = error.message;
} catch {
rawMessage = "Validation failed";
}
const message = sanitizeErrorMessage(rawMessage);
let statusCode: number | null = null;
let timeout = false;
let securityBlocked = false;
try {
statusCode = getSafeOutboundFetchErrorStatus(error);
timeout = error instanceof SafeOutboundFetchError && error.code === "TIMEOUT";
securityBlocked = isSecurityBlockError(error);
} catch {
// Classification is advisory; hostile accessors must not escape the safe error boundary.
}
return {
valid: false,
error: message || "Validation failed",
unsupported: false as const,
...(statusCode ? { statusCode } : {}),
...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
? { timeout: true }
: {}),
...(isSecurityBlockError(error) ? { securityBlocked: true } : {}),
...(timeout ? { timeout: true } : {}),
...(securityBlocked ? { securityBlocked: true } : {}),
};
}

Some files were not shown because too many files have changed in this diff Show More