diff --git a/AGENTS.md b/AGENTS.md
index 6e7ad18f2f..1c0a979789 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
-| Database | `src/lib/db/` | SQLite domain modules (169 migrations) |
+| Database | `src/lib/db/` | SQLite domain modules (171 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
diff --git a/README.md b/README.md
index b64f3dc543..8753569c47 100644
--- a/README.md
+++ b/README.md
@@ -1244,7 +1244,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
Runtime Node.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27
Language TypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0)
Framework Next.js 16 + React 19 + Tailwind CSS 4
- Database better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 migrations
+ Database better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 171 migrations
Memory SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay
Schemas Zod 4 — MCP tool I/O validation + API contracts
Protocols MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)
diff --git a/changelog.d/fixes/12787-saturation-singleflight.md b/changelog.d/fixes/12787-saturation-singleflight.md
new file mode 100644
index 0000000000..51ee5c7ac4
--- /dev/null
+++ b/changelog.d/fixes/12787-saturation-singleflight.md
@@ -0,0 +1 @@
+- **fix(quota):** share one upstream quota read when identical saturation checks arrive at the same time, so traffic bursts don't multiply provider API calls ([#12787](https://github.com/diegosouzapw/OmniRoute/pull/12787)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/12788-ratelimit-write-guard.md b/changelog.d/fixes/12788-ratelimit-write-guard.md
new file mode 100644
index 0000000000..1662233abf
--- /dev/null
+++ b/changelog.d/fixes/12788-ratelimit-write-guard.md
@@ -0,0 +1 @@
+- **fix(db):** ignore expired or invalid rate-limit cooldown writes so a stale timestamp can't lock a connection that should be usable — clearing still works as before ([#12788](https://github.com/diegosouzapw/OmniRoute/pull/12788)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/12832-call-logs-provider-stats.md b/changelog.d/fixes/12832-call-logs-provider-stats.md
new file mode 100644
index 0000000000..9e4ef7c433
--- /dev/null
+++ b/changelog.d/fixes/12832-call-logs-provider-stats.md
@@ -0,0 +1 @@
+- **fix(db):** provider stats stay truthful on empty and legacy databases: fallback counts default to `0` instead of `null`, latency averages read `null` (not `0`) when no durations were recorded, failures logged before the error-type column existed group under `pre_migration` instead of `unclassified`, and per-provider queries use two new composite indexes ([#12832](https://github.com/diegosouzapw/OmniRoute/pull/12832)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/12853-wal-maintenance-extraction.md b/changelog.d/fixes/12853-wal-maintenance-extraction.md
new file mode 100644
index 0000000000..eabeb66866
--- /dev/null
+++ b/changelog.d/fixes/12853-wal-maintenance-extraction.md
@@ -0,0 +1 @@
+- **fix(db):** WAL maintenance lives in its own module: a `TRUNCATE` checkpoint that hits a busy database now warns with its streak and retries once via `PASSIVE` instead of logging success, and closing-time checkpoints no longer report success on builds without a database file, with the busy totals visible in the authenticated monitoring health payload ([#12853](https://github.com/diegosouzapw/OmniRoute/pull/12853))
diff --git a/changelog.d/fixes/12857-empty-quota-unknown.md b/changelog.d/fixes/12857-empty-quota-unknown.md
new file mode 100644
index 0000000000..4cbace07ff
--- /dev/null
+++ b/changelog.d/fixes/12857-empty-quota-unknown.md
@@ -0,0 +1 @@
+- **fix(health):** quota with no snapshots reads empty instead of a contradicting 0% ([#12857](https://github.com/diegosouzapw/OmniRoute/pull/12857)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/12951-codex-scope-cooldown-clear.md b/changelog.d/fixes/12951-codex-scope-cooldown-clear.md
new file mode 100644
index 0000000000..edc1d4a64c
--- /dev/null
+++ b/changelog.d/fixes/12951-codex-scope-cooldown-clear.md
@@ -0,0 +1 @@
+- **fix(codex):** dashboard "clear cooldown" and the CAS recovery path now drop nested `codexScopeRateLimitedUntil` maps in the same write that nulls `rate_limited_until`, and fresh quota snapshots with headroom lift fallback-sourced scope cooldowns parked by quota preflight ([#12817](https://github.com/diegosouzapw/OmniRoute/issues/12817), [#12860](https://github.com/diegosouzapw/OmniRoute/issues/12860), [#12951](https://github.com/diegosouzapw/OmniRoute/pull/12951)) The dashboard reset-credit button (`consumeCodexResetCredit`) now exercises that same snapshot path after a successful redeem, so a filled quota bar is enough to unpark a leftover fallback Codex child without another manual clear.
diff --git a/changelog.d/fixes/13094-deepseek-pow-slot-leak.md b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md
new file mode 100644
index 0000000000..ab26d28c6f
--- /dev/null
+++ b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md
@@ -0,0 +1 @@
+- Fix a concurrency-slot leak in the DeepSeek PoW solver: a worker that failed to spawn (for example a missing worker script) never released its slot, so `MAX_CONCURRENT_WORKERS` failures disabled the solver until restart.
diff --git a/changelog.d/fixes/chatcore-legresult-narrowing.md b/changelog.d/fixes/chatcore-legresult-narrowing.md
new file mode 100644
index 0000000000..ae8c7f084d
--- /dev/null
+++ b/changelog.d/fixes/chatcore-legresult-narrowing.md
@@ -0,0 +1 @@
+- Restore the API-route typecheck gate: the non-streaming leg result lost its discriminated-union narrowing after the server-owned tool loop reassignment, producing 13 new TS2339 diagnostics in `chatCore.ts`.
diff --git a/changelog.d/fixes/chatcore-nonstreaming-regressions.md b/changelog.d/fixes/chatcore-nonstreaming-regressions.md
new file mode 100644
index 0000000000..b11fdc89e8
--- /dev/null
+++ b/changelog.d/fixes/chatcore-nonstreaming-regressions.md
@@ -0,0 +1 @@
+- Restore four non-streaming behaviours the server-owned tool loop refactor dropped: client aborts map to 499 with the fixed `Request aborted` message, an aborted request no longer logs a synthetic `clientResponse`, Claude prompt-cache telemetry is recorded again, and a body that cannot be canonicalized no longer throws when the tool loop is off.
diff --git a/changelog.d/fixes/google-key-redaction-length.md b/changelog.d/fixes/google-key-redaction-length.md
new file mode 100644
index 0000000000..e212604119
--- /dev/null
+++ b/changelog.d/fixes/google-key-redaction-length.md
@@ -0,0 +1 @@
+- Redact Google API keys of any length in error bodies: the pattern required exactly 39 characters, so shorter or longer `AIza…` credentials passed through unredacted.
diff --git a/changelog.d/fixes/volcengine-quota-binding.md b/changelog.d/fixes/volcengine-quota-binding.md
new file mode 100644
index 0000000000..edd9e7484e
--- /dev/null
+++ b/changelog.d/fixes/volcengine-quota-binding.md
@@ -0,0 +1 @@
+- fix(volcengine): resolve quota window mapping gaps, safe multi-connection console binding, and response cookie sanitization
diff --git a/changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md b/changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md
new file mode 100644
index 0000000000..d5a857d295
--- /dev/null
+++ b/changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md
@@ -0,0 +1 @@
+- Bring the documented counts back in line with the code: 171 migrations (was 169) and 20 routing strategies (was 19), plus the regenerated `cli-tunnel` skill reference.
diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json
index e10d1f6551..2074040010 100644
--- a/config/quality/eslint-suppressions.json
+++ b/config/quality/eslint-suppressions.json
@@ -215,11 +215,6 @@
"count": 1
}
},
- "open-sse/handlers/chatCore.ts": {
- "@typescript-eslint/no-unused-vars": {
- "count": 26
- }
- },
"open-sse/handlers/chatCore/executorHelpers.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json
index 769a6552f0..49c5f74724 100644
--- a/config/quality/file-size-baseline.json
+++ b/config/quality/file-size-baseline.json
@@ -424,14 +424,13 @@
"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,
+ "open-sse/handlers/chatCore.ts": 6021,
"open-sse/handlers/imageGeneration.ts": 3259,
"open-sse/handlers/search.ts": 1789,
"open-sse/mcp-server/schemas/tools.ts": 1621,
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2467,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
- "open-sse/services/combo.ts": 4084,
"open-sse/services/combo.ts": 4080,
"open-sse/services/combo/executeTargetAttempt.ts": 1205,
"open-sse/translator/response/openai-responses.ts": 1466,
@@ -652,5 +651,6 @@
"_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).",
"_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).",
"_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).",
- "_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped."
+ "_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped.",
+ "_rebaseline_2026_09_07_chatcore_nonstreaming_regression_fixes": "Own growth: open-sse/handlers/chatCore.ts 5984->6021 (+37). Two of my own PRs on top of #12867: #12963 pins the ok variant of the non-streaming leg result in its own binding (the discriminated-union narrowing was lost across the tool-loop reassignment, 13 TS2339 under tsconfig.typecheck-api.json), and #12990 restores four behaviours the same refactor dropped — abort classification through isLocalStreamLifecycleError, the omitted synthetic clientResponse, the claudePromptCacheLogMeta rebuild on the leg path, and the lazy fail-closed fence identity. Irreducible at the existing chokepoints: each edit sits where chatCore already owns the decision, and the helpers themselves (nonStreamingProviderLeg.ts, serverOwnedToolLoopWire.ts) are under cap. Covered by tests/unit/chatcore-translation-paths.test.ts (72/74; the 2 open are issue #13043)."
}
diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg
index 92fe11718c..ede29cd43a 100644
--- a/docs/diagrams/comparison-table.svg
+++ b/docs/diagrams/comparison-table.svg
@@ -1,4 +1,4 @@
-
+
Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses.
diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg
index 6f56f7edfc..ba64577494 100644
--- a/docs/diagrams/readme-hero.svg
+++ b/docs/diagrams/readme-hero.svg
@@ -1,4 +1,4 @@
-
+
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.
diff --git a/docs/diagrams/tier-cascade.svg b/docs/diagrams/tier-cascade.svg
index 5dfe2314fd..d2d7f93f62 100644
--- a/docs/diagrams/tier-cascade.svg
+++ b/docs/diagrams/tier-cascade.svg
@@ -90,7 +90,7 @@
OmniRoute — Smart Router
- RTK + Caveman compression · 19 routing strategies
+ RTK + Caveman compression · 20 routing strategies
Circuit breakers · TLS stealth · MCP · A2A · Guardrails
diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt
index 9f405eda91..f6add5b77d 100644
--- a/docs/i18n/ar/llm.txt
+++ b/docs/i18n/ar/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt
index 74a600ba34..9dab70a26b 100644
--- a/docs/i18n/az/llm.txt
+++ b/docs/i18n/az/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt
index 0db6d8a4ce..ee99596179 100644
--- a/docs/i18n/bg/llm.txt
+++ b/docs/i18n/bg/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt
index a7417f642e..4db6e639b5 100644
--- a/docs/i18n/bn/llm.txt
+++ b/docs/i18n/bn/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt
index c8b5c31922..e073c3be2c 100644
--- a/docs/i18n/cs/llm.txt
+++ b/docs/i18n/cs/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt
index 3d9663c28f..a234875f15 100644
--- a/docs/i18n/da/llm.txt
+++ b/docs/i18n/da/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt
index 0c8572e4f4..ddf7e03f64 100644
--- a/docs/i18n/de/llm.txt
+++ b/docs/i18n/de/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt
index 75a6488e37..1039568356 100644
--- a/docs/i18n/es/llm.txt
+++ b/docs/i18n/es/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt
index cdcccf2fc8..d9808e8436 100644
--- a/docs/i18n/fa/llm.txt
+++ b/docs/i18n/fa/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt
index 66d62309f4..e4f7a201f3 100644
--- a/docs/i18n/fi/llm.txt
+++ b/docs/i18n/fi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt
index dabd29e87f..93d66e6451 100644
--- a/docs/i18n/fr/llm.txt
+++ b/docs/i18n/fr/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt
index 21dad6d8f9..5dc2b7929e 100644
--- a/docs/i18n/gu/llm.txt
+++ b/docs/i18n/gu/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt
index 4174e2aa0f..56bc6752f3 100644
--- a/docs/i18n/he/llm.txt
+++ b/docs/i18n/he/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt
index b08baf5189..89c09b2fbb 100644
--- a/docs/i18n/hi/llm.txt
+++ b/docs/i18n/hi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt
index 13109da6fc..d0ec559160 100644
--- a/docs/i18n/hu/llm.txt
+++ b/docs/i18n/hu/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt
index 77df7b4c16..0b42b77310 100644
--- a/docs/i18n/id/llm.txt
+++ b/docs/i18n/id/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt
index b479c853cd..4185cf36f1 100644
--- a/docs/i18n/it/llm.txt
+++ b/docs/i18n/it/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt
index a1528c646b..d2e76a9303 100644
--- a/docs/i18n/ja/llm.txt
+++ b/docs/i18n/ja/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt
index 4d8508fd12..b1a4061328 100644
--- a/docs/i18n/ko/llm.txt
+++ b/docs/i18n/ko/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt
index 8ba71d98f5..3a2aae0929 100644
--- a/docs/i18n/mr/llm.txt
+++ b/docs/i18n/mr/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt
index 3903f94cf1..6c6dd28642 100644
--- a/docs/i18n/ms/llm.txt
+++ b/docs/i18n/ms/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt
index 8db8eb985b..03fd86a100 100644
--- a/docs/i18n/nl/llm.txt
+++ b/docs/i18n/nl/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt
index e635301c86..65760eca18 100644
--- a/docs/i18n/no/llm.txt
+++ b/docs/i18n/no/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt
index b760a56857..49f5903147 100644
--- a/docs/i18n/phi/llm.txt
+++ b/docs/i18n/phi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt
index e683cdb76c..502816502c 100644
--- a/docs/i18n/pl/llm.txt
+++ b/docs/i18n/pl/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt
index 49a62961e7..0b35be9e16 100644
--- a/docs/i18n/pt-BR/llm.txt
+++ b/docs/i18n/pt-BR/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt
index 9362022f86..a9199b8f7a 100644
--- a/docs/i18n/pt/llm.txt
+++ b/docs/i18n/pt/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt
index fd21c2042b..00511f6036 100644
--- a/docs/i18n/ro/llm.txt
+++ b/docs/i18n/ro/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt
index d68314887e..fd44211ffc 100644
--- a/docs/i18n/ru/llm.txt
+++ b/docs/i18n/ru/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt
index 666d1a570e..236aa18200 100644
--- a/docs/i18n/sk/llm.txt
+++ b/docs/i18n/sk/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt
index 74238247ae..fd167e45ef 100644
--- a/docs/i18n/sv/llm.txt
+++ b/docs/i18n/sv/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt
index 5f129a8263..e6cc4f9e5b 100644
--- a/docs/i18n/sw/llm.txt
+++ b/docs/i18n/sw/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt
index d42fa346a0..8b3c4567b2 100644
--- a/docs/i18n/ta/llm.txt
+++ b/docs/i18n/ta/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt
index 6ca040018f..bbe2b4fd6d 100644
--- a/docs/i18n/te/llm.txt
+++ b/docs/i18n/te/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt
index 9884ab3b21..90c2ab1626 100644
--- a/docs/i18n/th/llm.txt
+++ b/docs/i18n/th/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt
index 77b9eadb1b..c432dc480f 100644
--- a/docs/i18n/tr/llm.txt
+++ b/docs/i18n/tr/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt
index 3a7bc01f36..9d6b0422ca 100644
--- a/docs/i18n/uk-UA/llm.txt
+++ b/docs/i18n/uk-UA/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt
index 1cdd0e6bb5..6d6c81eb2d 100644
--- a/docs/i18n/ur/llm.txt
+++ b/docs/i18n/ur/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt
index 3e28435b0a..1e3e334685 100644
--- a/docs/i18n/vi/llm.txt
+++ b/docs/i18n/vi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt
index d11163a83a..decf982bcf 100644
--- a/docs/i18n/zh-CN/llm.txt
+++ b/docs/i18n/zh-CN/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt
index 56256a53da..58ce9f5917 100644
--- a/docs/i18n/zh-TW/llm.txt
+++ b/docs/i18n/zh-TW/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md
index da053f1db3..f357f73584 100644
--- a/docs/reference/ENVIRONMENT.md
+++ b/docs/reference/ENVIRONMENT.md
@@ -101,7 +101,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `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`. |
-| `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` | `21600000` (6h) | `src/lib/db/core.ts` | Override the periodic `wal_checkpoint(TRUNCATE)` interval (ms). Auto-checkpoint never shrinks the WAL file itself, and a long-running server never closes its DB. `0` disables. |
+| `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` | `21600000` (6h) | `src/lib/db/walMaintenance.ts` | Override the periodic `wal_checkpoint(TRUNCATE)` interval (ms). Auto-checkpoint never shrinks the WAL file itself, and a long-running server never closes its DB. `0` disables. |
| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | Set to `1` to skip the DB healthcheck entirely on startup. Useful for short-lived tasks and integration tests. |
| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | Set to `1` to force the DB healthcheck loop on, even when it would normally be skipped (e.g., short-lived tasks). |
| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | Set to `1` to skip the native-runtime warm-up during `npm install`. Useful in CI/headless installs where sqlite is already built. |
diff --git a/llm.txt b/llm.txt
index 3748b08432..2ebe387b79 100644
--- a/llm.txt
+++ b/llm.txt
@@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
-│ │ │ └── migrations/ # 169 versioned SQL migration files
+│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
@@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.
-9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations.
+9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.
10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.
@@ -433,7 +433,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 63395b47ad..446cb84a9f 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -111,7 +111,7 @@ import {
import { recoverAnthropicThinkingSignature } from "./chatCore/thinkingSignatureRecovery.ts";
import { runProviderExecutionPipeline } from "./chatCore/providerExecutionPipeline.ts";
import { runNonStreamingProviderLeg } from "./chatCore/nonStreamingProviderLeg.ts";
-import type { ChatCoreErrorResult } from "@/lib/skills/toolLoopTypes.ts";
+import type { NonStreamingProviderLegResult } from "@/lib/skills/toolLoopTypes.ts";
import {
applyServerOwnedToolLoopIfNeeded,
derivePostInjectionRequestIdentity,
@@ -258,10 +258,7 @@ import {
resolveResilienceSettings,
isStreamRecoveryExplicitlyConfigured,
} from "@/lib/resilience/settings";
-import {
- classifyProviderError,
- PROVIDER_ERROR_TYPES,
-} from "../services/errorClassifier.ts";
+import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
import { updateProviderConnection, getProviderConnectionById } from "@/lib/db/providers";
import { wasRefreshTokenRotated } from "@omniroute/open-sse/services/refreshSerializer.ts";
import { connectionHasExtraKeys } from "../services/apiKeyRotator.ts";
@@ -3626,778 +3623,666 @@ export async function handleChatCore({
let pipelineRecovered = false;
if (stream) {
- try {
- const pipelineOutcome = await runProviderExecutionPipeline({
- policy: {
- allowAccountRotation: !managedLease && comboStrategy !== "context-relay",
- allowModelFallback: true,
- expectedConnectionId: managedLease
- ? String(getCurrentConnectionId() || connectionId || "") || undefined
- : undefined,
- },
- target: {
- provider,
- requestedModel: effectiveModel,
- sourceFormat,
- targetFormat,
- stream,
- },
- connection: {
- initialConnectionId: String(getCurrentConnectionId() || connectionId || ""),
- getCurrentConnectionId: () => getCurrentConnectionId() || undefined,
- getCredentials: () => (credentials || {}) as Record,
- replaceCredentials: (next) => {
- Object.assign(credentials, next);
- },
- onCredentialsRefreshed: async () => {},
- assertManagedLeaseFence: (id) => {
- assertManagedLeaseFence(id);
- },
- getProviderCredentials,
- },
- wire: {
- body: translatedBody as Record,
- currentModel,
- triedModels,
- setBodyAndModel: (body, model) => {
- translatedBody = body as typeof translatedBody;
- currentModel = model;
- triedModels.add(model);
- },
- },
- state: {
- updatePendingStage: (stage, data) => {
- updatePendingScope(pendingScope, { stage, ...(data || {}) });
- },
- recordRateLimitHeaders: updateFromHeaders,
- recordRateLimitBody: updateFromResponseBody,
- writeTerminalStatus,
- persistConnectionPatch: updateProviderConnection,
- setConnectionRateLimitedUntil: async (id, untilMs) => {
- const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
- setConnectionRateLimitUntil(id, untilMs);
- },
- lockModel,
- recordAntigravityQuotaState: recordCoreOwnedAntigravityQuotaState,
- markAccountSemaphoreBlocked: (key) => {
- markAccountSemaphoreBlocked(key, Date.now() + 60_000);
- },
- isolateProbeFailures: () => shouldIsolateProbeFailures(),
- onCodexScopeRateLimited: async (params) => {
- await markCodexScopeRateLimited({
- failedConnectionId: params.failedConnectionId,
- model: params.model,
- rateLimitedUntil: params.rateLimitedUntil,
- credentials: (params.credentials || credentials) as {
- connectionId?: string | null;
- providerSpecificData?: unknown;
- },
- });
- },
- onClearSessionAffinity: () => {
- const key =
- sessionAffinityKey ||
- extractSessionAffinityKey(body, clientRawRequest?.headers) ||
- null;
- if (!key) return;
- try {
- deleteSessionAccountAffinity(key, "codex");
- } catch {
- // best-effort
- }
- },
- onAuditAccountRotation: (params) => {
- logAuditEvent({
- action: params.action,
- actor: apiKeyInfo?.name || "system",
- target: params.newConnectionId,
- details: {
- failed_connection_id: params.failedConnectionId,
- new_connection_id: params.newConnectionId,
- attempt: params.attempt,
- retry_after_ms: params.retryAfterMs,
- },
- });
- },
- },
- sendProviderAttempt: (modelToCall, allowDedup) => executeProviderRequest(modelToCall, allowDedup),
- });
-
- pipelineRecovered = true;
- currentModel = pipelineOutcome.model;
- if (pipelineOutcome.kind === "error") {
- providerResponse = pipelineOutcome.result.response;
- providerUrl = "";
- providerHeaders = normalizeHeaders(pipelineOutcome.result.response.headers);
- finalBody = translatedBody;
- } else {
- const result = {
- response: pipelineOutcome.response,
- url: pipelineOutcome.url,
- headers: pipelineOutcome.headers,
- transformedBody: pipelineOutcome.transformedBody,
- };
- providerResponse = result.response;
- providerUrl = result.url;
- providerHeaders = result.headers;
- finalBody = providerRequestCapture.body(result.transformedBody);
- }
- const responseConnectionId = getCurrentConnectionId();
- effectiveServiceTier = resolveEffectiveServiceTier(finalBody);
- claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
- targetFormat,
- finalBody,
- providerHeaders,
- clientRawRequest?.headers
- );
-
- // Log target request (final request to provider)
- reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
- updatePendingScope(pendingScope, {
- providerRequest: finalBody,
- providerUrl,
- stage: "provider_response_started",
- });
- // Update rate limiter from response headers (learn limits dynamically)
- updateFromHeaders(
- provider,
- responseConnectionId,
- providerResponse.headers,
- providerResponse.status,
- model
- );
-
- // Store rate-limit headers for quota saturation signals
try {
- const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals");
- storeRateLimitHeaders(
- responseConnectionId,
- provider,
- providerResponse.headers as Record
+ const pipelineOutcome = await runProviderExecutionPipeline({
+ policy: {
+ allowAccountRotation: !managedLease && comboStrategy !== "context-relay",
+ allowModelFallback: true,
+ expectedConnectionId: managedLease
+ ? String(getCurrentConnectionId() || connectionId || "") || undefined
+ : undefined,
+ },
+ target: {
+ provider,
+ requestedModel: effectiveModel,
+ sourceFormat,
+ targetFormat,
+ stream,
+ },
+ connection: {
+ initialConnectionId: String(getCurrentConnectionId() || connectionId || ""),
+ getCurrentConnectionId: () => getCurrentConnectionId() || undefined,
+ getCredentials: () => (credentials || {}) as Record,
+ replaceCredentials: (next) => {
+ Object.assign(credentials, next);
+ },
+ onCredentialsRefreshed: async () => {},
+ assertManagedLeaseFence: (id) => {
+ assertManagedLeaseFence(id);
+ },
+ getProviderCredentials,
+ },
+ wire: {
+ body: translatedBody as Record,
+ currentModel,
+ triedModels,
+ setBodyAndModel: (body, model) => {
+ translatedBody = body as typeof translatedBody;
+ currentModel = model;
+ triedModels.add(model);
+ },
+ },
+ state: {
+ updatePendingStage: (stage, data) => {
+ updatePendingScope(pendingScope, { stage, ...(data || {}) });
+ },
+ recordRateLimitHeaders: updateFromHeaders,
+ recordRateLimitBody: updateFromResponseBody,
+ writeTerminalStatus,
+ persistConnectionPatch: updateProviderConnection,
+ setConnectionRateLimitedUntil: async (id, untilMs) => {
+ const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
+ setConnectionRateLimitUntil(id, untilMs);
+ },
+ lockModel,
+ recordAntigravityQuotaState: recordCoreOwnedAntigravityQuotaState,
+ markAccountSemaphoreBlocked: (key) => {
+ markAccountSemaphoreBlocked(key, Date.now() + 60_000);
+ },
+ isolateProbeFailures: () => shouldIsolateProbeFailures(),
+ onCodexScopeRateLimited: async (params) => {
+ await markCodexScopeRateLimited({
+ failedConnectionId: params.failedConnectionId,
+ model: params.model,
+ rateLimitedUntil: params.rateLimitedUntil,
+ credentials: (params.credentials || credentials) as {
+ connectionId?: string | null;
+ providerSpecificData?: unknown;
+ },
+ });
+ },
+ onClearSessionAffinity: () => {
+ const key =
+ sessionAffinityKey ||
+ extractSessionAffinityKey(body, clientRawRequest?.headers) ||
+ null;
+ if (!key) return;
+ try {
+ deleteSessionAccountAffinity(key, "codex");
+ } catch {
+ // best-effort
+ }
+ },
+ onAuditAccountRotation: (params) => {
+ logAuditEvent({
+ action: params.action,
+ actor: apiKeyInfo?.name || "system",
+ target: params.newConnectionId,
+ details: {
+ failed_connection_id: params.failedConnectionId,
+ new_connection_id: params.newConnectionId,
+ attempt: params.attempt,
+ retry_after_ms: params.retryAfterMs,
+ },
+ });
+ },
+ },
+ sendProviderAttempt: (modelToCall, allowDedup) =>
+ executeProviderRequest(modelToCall, allowDedup),
+ });
+
+ pipelineRecovered = true;
+ currentModel = pipelineOutcome.model;
+ if (pipelineOutcome.kind === "error") {
+ providerResponse = pipelineOutcome.result.response;
+ providerUrl = "";
+ providerHeaders = normalizeHeaders(pipelineOutcome.result.response.headers);
+ finalBody = translatedBody;
+ } else {
+ const result = {
+ response: pipelineOutcome.response,
+ url: pipelineOutcome.url,
+ headers: pipelineOutcome.headers,
+ transformedBody: pipelineOutcome.transformedBody,
+ };
+ providerResponse = result.response;
+ providerUrl = result.url;
+ providerHeaders = result.headers;
+ finalBody = providerRequestCapture.body(result.transformedBody);
+ }
+ const responseConnectionId = getCurrentConnectionId();
+ effectiveServiceTier = resolveEffectiveServiceTier(finalBody);
+ claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
+ targetFormat,
+ finalBody,
+ providerHeaders,
+ clientRawRequest?.headers
);
- } catch {
- // fail-open: saturation signal is best-effort
- }
- } catch (error) {
- trackPendingRequest(model, provider, connectionId, false);
- if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error);
- if (isSemaphoreCapacityError(error)) {
+
+ // Log target request (final request to provider)
+ reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
+ updatePendingScope(pendingScope, {
+ providerRequest: finalBody,
+ providerUrl,
+ stage: "provider_response_started",
+ });
+ // Update rate limiter from response headers (learn limits dynamically)
+ updateFromHeaders(
+ provider,
+ responseConnectionId,
+ providerResponse.headers,
+ providerResponse.status,
+ model
+ );
+
+ // Store rate-limit headers for quota saturation signals
+ try {
+ const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals");
+ storeRateLimitHeaders(
+ responseConnectionId,
+ provider,
+ providerResponse.headers as Record
+ );
+ } catch {
+ // fail-open: saturation signal is best-effort
+ }
+ } catch (error) {
+ trackPendingRequest(model, provider, connectionId, false);
+ if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error);
+ if (isSemaphoreCapacityError(error)) {
+ appendRequestLog({
+ model,
+ provider,
+ connectionId,
+ status: `FAILED ${error.code}`,
+ }).catch(() => {});
+ const failureMessage = error.message || "Semaphore timeout";
+ persistAttemptLogs({
+ status: HTTP_STATUS.RATE_LIMITED,
+ error: failureMessage,
+ providerRequest: finalBody || translatedBody,
+ clientResponse: buildErrorBody(HTTP_STATUS.RATE_LIMITED, failureMessage),
+ claudeCacheMeta: claudePromptCacheLogMeta,
+ cacheSource: "upstream",
+ });
+ persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code);
+ const result = stream
+ ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code)
+ : createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage);
+ return {
+ ...result,
+ errorType: "account_semaphore_capacity",
+ errorCode: error.code,
+ };
+ }
+ // abort(reason) can reject with a raw string lacking `name`/`status`; classify
+ // it through isLocalStreamLifecycleError so it maps to 499 rather than the
+ // 502 provider-failure default.
+ const isRequestAborted = isLocalStreamLifecycleError(error);
+ // #8376: proxyFetch tags unreachable transport failures so they remain
+ // distinguishable from ordinary provider 5xx responses.
+ const isProxyUnreachableFailure =
+ !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable";
+ const errorCode = getUpstreamErrorIdentifier(error);
+ const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error);
+ const failureStatus = isRequestAborted
+ ? 499
+ : isProxyUnreachableFailure
+ ? HTTP_STATUS.BAD_GATEWAY
+ : localRateLimitFailure
+ ? localRateLimitFailure.status
+ : error.name === "TimeoutError" || error.name === "BodyTimeoutError"
+ ? HTTP_STATUS.GATEWAY_TIMEOUT
+ : error.status && typeof error.status === "number"
+ ? error.status
+ : HTTP_STATUS.BAD_GATEWAY;
+ const failureMessage = isRequestAborted
+ ? "Request aborted"
+ : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus);
+ const upstreamErrorCode =
+ localRateLimitFailure?.code ??
+ (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode);
+ // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError,
+ // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a
+ // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already
+ // tags its pre-response timeout via the code below.)
+ const isOwnDeadlineTimeout =
+ failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT &&
+ (error.name === "TimeoutError" || error.name === "BodyTimeoutError");
+ const upstreamErrorType =
+ upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout
+ ? "upstream_timeout"
+ : failureStatus === 401
+ ? "authentication_error"
+ : undefined;
appendRequestLog({
model,
provider,
connectionId,
- status: `FAILED ${error.code}`,
+ status: `FAILED ${failureStatus}`,
}).catch(() => {});
- const failureMessage = error.message || "Semaphore timeout";
persistAttemptLogs({
- status: HTTP_STATUS.RATE_LIMITED,
+ status: failureStatus,
error: failureMessage,
providerRequest: finalBody || translatedBody,
- clientResponse: buildErrorBody(HTTP_STATUS.RATE_LIMITED, failureMessage),
+ // On a client-abort (AbortError), the client already disconnected before
+ // we ever got here — this body is what we WOULD have sent, not what was
+ // actually delivered. Logging it as `clientResponse` is misleading (the
+ // dashboard reads that field as "what the client received"), so omit it
+ // for this case; `error` above already records the failure reason.
+ clientResponse:
+ error.name === "AbortError" ? undefined : buildErrorBody(failureStatus, failureMessage),
claudeCacheMeta: claudePromptCacheLogMeta,
cacheSource: "upstream",
});
- persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code);
- const result = stream
- ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code)
- : createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage);
- return {
- ...result,
- errorType: "account_semaphore_capacity",
- errorCode: error.code,
- };
- }
- // abort(reason) can reject with a raw string lacking `name`/`status`; classify
- // it through isLocalStreamLifecycleError so it maps to 499 rather than the
- // 502 provider-failure default.
- const isRequestAborted = isLocalStreamLifecycleError(error);
- // #8376: proxyFetch tags unreachable transport failures so they remain
- // distinguishable from ordinary provider 5xx responses.
- const isProxyUnreachableFailure =
- !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable";
- const errorCode = getUpstreamErrorIdentifier(error);
- const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error);
- const failureStatus = isRequestAborted
- ? 499
- : isProxyUnreachableFailure
- ? HTTP_STATUS.BAD_GATEWAY
- : localRateLimitFailure
- ? localRateLimitFailure.status
- : error.name === "TimeoutError" || error.name === "BodyTimeoutError"
- ? HTTP_STATUS.GATEWAY_TIMEOUT
- : error.status && typeof error.status === "number"
- ? error.status
- : HTTP_STATUS.BAD_GATEWAY;
- const failureMessage = isRequestAborted
- ? "Request aborted"
- : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus);
- const upstreamErrorCode =
- localRateLimitFailure?.code ?? (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode);
- // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError,
- // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a
- // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already
- // tags its pre-response timeout via the code below.)
- const isOwnDeadlineTimeout =
- failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT &&
- (error.name === "TimeoutError" || error.name === "BodyTimeoutError");
- const upstreamErrorType =
- upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout
- ? "upstream_timeout"
- : failureStatus === 401
- ? "authentication_error"
- : undefined;
- appendRequestLog({
- model,
- provider,
- connectionId,
- status: `FAILED ${failureStatus}`,
- }).catch(() => {});
- persistAttemptLogs({
- status: failureStatus,
- error: failureMessage,
- providerRequest: finalBody || translatedBody,
- // On a client-abort (AbortError), the client already disconnected before
- // we ever got here — this body is what we WOULD have sent, not what was
- // actually delivered. Logging it as `clientResponse` is misleading (the
- // dashboard reads that field as "what the client received"), so omit it
- // for this case; `error` above already records the failure reason.
- clientResponse:
- error.name === "AbortError" ? undefined : buildErrorBody(failureStatus, failureMessage),
- claudeCacheMeta: claudePromptCacheLogMeta,
- cacheSource: "upstream",
- });
- if (isRequestAborted) {
- streamController.handleError(error);
- return createErrorResult(499, "Request aborted");
- }
- 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(
+ if (isRequestAborted) {
+ streamController.handleError(error);
+ return createErrorResult(499, "Request aborted");
+ }
+ 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(
+ failureStatus,
+ failureMessage,
+ upstreamErrorCode,
+ upstreamErrorType
+ );
+ localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error);
+ return {
+ ...result,
+ errorType: upstreamErrorType,
+ errorCode: upstreamErrorCode,
+ };
+ }
+ const result = createErrorResult(
failureStatus,
failureMessage,
+ null,
upstreamErrorCode,
upstreamErrorType
);
localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error);
- return {
- ...result,
- errorType: upstreamErrorType,
- errorCode: upstreamErrorCode,
- };
+ return result;
}
- const result = createErrorResult(
- failureStatus,
- failureMessage,
- null,
- upstreamErrorCode,
- upstreamErrorType
- );
- localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error);
- return result;
- }
- let upstreamErrorParsed = false;
- let parsedStatusCode = providerResponse.status;
- let parsedMessage = "";
- let parsedRetryAfterMs: number | null = null;
- let upstreamErrorBody: unknown = null;
+ let upstreamErrorParsed = false;
+ let parsedStatusCode = providerResponse.status;
+ let parsedMessage = "";
+ let parsedRetryAfterMs: number | null = null;
+ let upstreamErrorBody: unknown = null;
- // Track whether stream_options was present and stripped — if so, 401/403 after
- // that may be from the modification rather than a genuine auth failure, so we
- // skip the credential refresh attempt in that case.
- const hadStreamOptions =
- targetFormat === FORMATS.OPENAI_RESPONSES && "stream_options" in translatedBody;
- if (hadStreamOptions) {
- delete translatedBody.stream_options;
- }
+ // Track whether stream_options was present and stripped — if so, 401/403 after
+ // that may be from the modification rather than a genuine auth failure, so we
+ // skip the credential refresh attempt in that case.
+ const hadStreamOptions =
+ targetFormat === FORMATS.OPENAI_RESPONSES && "stream_options" in translatedBody;
+ if (hadStreamOptions) {
+ delete translatedBody.stream_options;
+ }
- // Handle 401/403 - try token refresh using executor
- // T-PROBE: probe-origin failures never attempt the refresh — a probe must
- // not consume a rotating refresh token nor persist an "expired"
- // deactivation on refresh failure (#9817). The 401/403 then flows into
- // the normal providerFailure classification (record-only in probe mode).
- if (
- (providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
- providerResponse.status === HTTP_STATUS.FORBIDDEN) &&
- !hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth
- !(await shouldIsolateProbeFailures())
- ) {
- // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback
- // executes INSIDE the per-connection mutex held by getAccessToken. This makes
- // [network refresh + DB write + outer-state mutation] one atomic step and
- // prevents concurrent requests from reading a stale refreshToken before the
- // DB has been updated (refresh_token_reused on Codex/OpenAI).
- //
- // Not every executor routes refresh through getAccessToken (e.g. github.ts
- // calls refreshCopilotToken directly). When the persistFn doesn't fire from
- // inside getAccessToken, we still need to do the credentials mutation + user
- // callback after refreshCredentials returns. The `persistFnRan` flag tracks
- // which path executed so we don't double-fire (race-prone) or skip (regression).
- // Front 3: remember the refresh_token we are about to present so that, if the
- // refresh fails as unrecoverable, we can tell a genuine death apart from a
- // stale-token reuse that a concurrent/sibling refresh already rotated past.
- const attemptedRefreshToken =
- typeof credentials?.refreshToken === "string" ? credentials.refreshToken : null;
- let persistFnRan = false;
- const persistFn = onCredentialsRefreshed
- ? async (refreshResult: Record) => {
- persistFnRan = true;
- // Mutate the shared credentials object so subsequent executor calls
- // in this request see the new tokens. Runs INSIDE the mutex.
- Object.assign(credentials, refreshResult);
- await onCredentialsRefreshed(refreshResult);
+ // Handle 401/403 - try token refresh using executor
+ // T-PROBE: probe-origin failures never attempt the refresh — a probe must
+ // not consume a rotating refresh token nor persist an "expired"
+ // deactivation on refresh failure (#9817). The 401/403 then flows into
+ // the normal providerFailure classification (record-only in probe mode).
+ if (
+ (providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
+ providerResponse.status === HTTP_STATUS.FORBIDDEN) &&
+ !hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth
+ !(await shouldIsolateProbeFailures())
+ ) {
+ // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback
+ // executes INSIDE the per-connection mutex held by getAccessToken. This makes
+ // [network refresh + DB write + outer-state mutation] one atomic step and
+ // prevents concurrent requests from reading a stale refreshToken before the
+ // DB has been updated (refresh_token_reused on Codex/OpenAI).
+ //
+ // Not every executor routes refresh through getAccessToken (e.g. github.ts
+ // calls refreshCopilotToken directly). When the persistFn doesn't fire from
+ // inside getAccessToken, we still need to do the credentials mutation + user
+ // callback after refreshCredentials returns. The `persistFnRan` flag tracks
+ // which path executed so we don't double-fire (race-prone) or skip (regression).
+ // Front 3: remember the refresh_token we are about to present so that, if the
+ // refresh fails as unrecoverable, we can tell a genuine death apart from a
+ // stale-token reuse that a concurrent/sibling refresh already rotated past.
+ const attemptedRefreshToken =
+ typeof credentials?.refreshToken === "string" ? credentials.refreshToken : null;
+ let persistFnRan = false;
+ const persistFn = onCredentialsRefreshed
+ ? async (refreshResult: Record) => {
+ persistFnRan = true;
+ // Mutate the shared credentials object so subsequent executor calls
+ // in this request see the new tokens. Runs INSIDE the mutex.
+ Object.assign(credentials, refreshResult);
+ await onCredentialsRefreshed(refreshResult);
+ }
+ : undefined;
+
+ // #4038: build a compare-and-swap reread so getAccessToken can skip the persist if a
+ // concurrent writer (sibling request / HealthCheck / replica) already rotated this
+ // connection's refresh_token past the one we presented — overwriting would revert it
+ // and revoke the token family. No connectionId ⇒ no guard (behavior unchanged).
+ const casConnectionId =
+ typeof credentials?.connectionId === "string" ? credentials.connectionId.trim() : "";
+ const casReread = casConnectionId
+ ? async () => {
+ const latest = await getProviderConnectionById(casConnectionId);
+ return typeof latest?.refreshToken === "string" ? latest.refreshToken : null;
+ }
+ : null;
+
+ const newCredentials = (await refreshWithRetry(
+ () =>
+ runWithCasGuard(
+ casReread ? { expectedRefreshToken: attemptedRefreshToken, reread: casReread } : null,
+ () => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log))
+ ),
+ 3,
+ log,
+ provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker
+ )) as null | {
+ accessToken?: string;
+ copilotToken?: string;
+ };
+
+ if (newCredentials?.accessToken || newCredentials?.copilotToken) {
+ log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`);
+
+ // Fall back to post-mutex mutation only for executors that don't route
+ // through getAccessToken (and therefore never fire onPersist). For
+ // executors that DO route through it (Codex, Claude, Gemini, etc.) the
+ // mutation already happened atomically inside the mutex.
+ if (!persistFnRan) {
+ Object.assign(credentials, newCredentials);
+ if (onCredentialsRefreshed) {
+ await onCredentialsRefreshed(newCredentials);
+ }
}
- : undefined;
- // #4038: build a compare-and-swap reread so getAccessToken can skip the persist if a
- // concurrent writer (sibling request / HealthCheck / replica) already rotated this
- // connection's refresh_token past the one we presented — overwriting would revert it
- // and revoke the token family. No connectionId ⇒ no guard (behavior unchanged).
- const casConnectionId =
- typeof credentials?.connectionId === "string" ? credentials.connectionId.trim() : "";
- const casReread = casConnectionId
- ? async () => {
- const latest = await getProviderConnectionById(casConnectionId);
- return typeof latest?.refreshToken === "string" ? latest.refreshToken : null;
+ // Retry with new credentials — model + extra headers follow translatedBody.model so they
+ // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback).
+ try {
+ const retryModelId = String(translatedBody.model || effectiveModel);
+ assertManagedLeaseFence(getExecutionConnectionId(getExecutionCredentials()));
+ const retryResult = normalizeExecutorResult(
+ await runWithCapture(providerRequestCapture, () =>
+ executor.execute({
+ model: retryModelId,
+ body: translatedBody,
+ stream: upstreamStream,
+ credentials: getExecutionCredentials(),
+ signal: streamController.signal,
+ log,
+ extendedContext,
+ upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
+ clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
+ clientResponseFormat,
+ onCredentialsRefreshed,
+ skipUpstreamRetry: isCombo,
+ contextEditing: { enabled: contextEditingEnabled },
+ })
+ )
+ );
+
+ if (retryResult.response.ok) {
+ providerResponse = retryResult.response;
+ providerUrl = retryResult.url;
+ providerHeaders = new Headers(retryResult.headers || {});
+ finalBody = providerRequestCapture.body(retryResult.transformedBody);
+ reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
+ updatePendingScope(pendingScope, {
+ providerRequest: finalBody,
+ providerUrl,
+ stage: "provider_response_started",
+ });
+ upstreamErrorParsed = false; // Reset since new response is OK
+ } else {
+ providerResponse = retryResult.response;
+ upstreamErrorParsed = false; // Let it be parsed downstream
+ }
+ } catch (retryErr) {
+ if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr);
+ // Refresh succeeded but the retry leg failed (network blip, AbortError,
+ // executor throw). Don't swallow — the operator-visible signal "the user
+ // saw 401 even though auth was actually fixed" is much more confusing
+ // than the original 401 alone. Surface at error level with sanitization.
+ log?.error?.(
+ "TOKEN",
+ `${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}`
+ );
}
- : null;
-
- const newCredentials = (await refreshWithRetry(
- () =>
- runWithCasGuard(
- casReread ? { expectedRefreshToken: attemptedRefreshToken, reread: casReread } : null,
- () => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log))
- ),
- 3,
- log,
- provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker
- )) as null | {
- accessToken?: string;
- copilotToken?: string;
- };
-
- if (newCredentials?.accessToken || newCredentials?.copilotToken) {
- log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`);
-
- // Fall back to post-mutex mutation only for executors that don't route
- // through getAccessToken (and therefore never fire onPersist). For
- // executors that DO route through it (Codex, Claude, Gemini, etc.) the
- // mutation already happened atomically inside the mutex.
- if (!persistFnRan) {
- Object.assign(credentials, newCredentials);
- if (onCredentialsRefreshed) {
- await onCredentialsRefreshed(newCredentials);
+ } else {
+ log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`);
+ if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) {
+ // Front 3 (reuse-race tolerance): before deactivating, re-read the DB.
+ // If a sibling/concurrent refresh already rotated this connection's
+ // refresh_token (common for Codex/OpenAI under one shared Auth0 client),
+ // the failure we saw was a stale-token reuse — the account is healthy
+ // with the newer token, so keep it active instead of killing it.
+ let alreadyRotated = false;
+ if (typeof connectionId === "string" && connectionId && attemptedRefreshToken) {
+ try {
+ const latest = await getProviderConnectionById(connectionId);
+ if (wasRefreshTokenRotated(attemptedRefreshToken, latest?.refreshToken)) {
+ alreadyRotated = true;
+ log?.warn?.(
+ "TOKEN",
+ `${provider.toUpperCase()} | refresh_token already rotated by a concurrent refresh — keeping connection active`
+ );
+ }
+ } catch {
+ // DB read failed — fall through to the safe default (deactivate).
+ }
+ }
+ if (!alreadyRotated) {
+ await onCredentialsRefreshed({ testStatus: "expired", isActive: false });
+ }
}
}
+ }
- // Retry with new credentials — model + extra headers follow translatedBody.model so they
- // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback).
- try {
- const retryModelId = String(translatedBody.model || effectiveModel);
- assertManagedLeaseFence(getExecutionConnectionId(getExecutionCredentials()));
- const retryResult = normalizeExecutorResult(
- await runWithCapture(providerRequestCapture, () =>
- executor.execute({
- model: retryModelId,
- body: translatedBody,
- stream: upstreamStream,
- credentials: getExecutionCredentials(),
- signal: streamController.signal,
- log,
- extendedContext,
- upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
- clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
- clientResponseFormat,
- onCredentialsRefreshed,
- skipUpstreamRetry: isCombo,
- contextEditing: { enabled: contextEditingEnabled },
- })
- )
+ // Check provider response - return error info for fallback handling
+ providerFailure: if (!providerResponse.ok) {
+ trackPendingRequest(model, provider, connectionId, false);
+
+ let statusCode = providerResponse.status;
+ let message = "";
+ let retryAfterMs: number | null = null;
+ let upstreamErrorCode: string | undefined;
+ let upstreamErrorType: string | undefined;
+
+ if (upstreamErrorParsed) {
+ statusCode = parsedStatusCode;
+ message = parsedMessage;
+ retryAfterMs = parsedRetryAfterMs;
+ } else {
+ const details = await parseUpstreamError(providerResponse, provider);
+ statusCode = details.statusCode;
+ message = details.message;
+ retryAfterMs = details.retryAfterMs;
+ upstreamErrorBody = details.responseBody;
+ upstreamErrorCode = details.errorCode as string | undefined;
+ upstreamErrorType = details.errorType as string | undefined;
+ }
+
+ // Gateways like agentrouter misstate temporary quota exhaustion as 403/400,
+ // which downstream classification treats as AUTH_ERROR and clients like
+ // Claude Code treat as permanent. Restate to 429 (+ synthetic Retry-After)
+ // BEFORE any classification so both the fallback engine and the surfaced
+ // client status see a retryable error. Registry-scoped per provider.
+ const restatement = applyStatusRestatement({
+ provider,
+ status: statusCode,
+ message,
+ body: upstreamErrorBody,
+ retryAfterMs,
+ });
+ if (restatement.ruleId) {
+ statusCode = restatement.status;
+ retryAfterMs = restatement.retryAfterMs;
+ log?.info?.(
+ "STATUS_RESTATE",
+ `${provider} ${restatement.fromStatus}→${statusCode} (${restatement.ruleId})`
);
+ }
- if (retryResult.response.ok) {
- providerResponse = retryResult.response;
- providerUrl = retryResult.url;
- providerHeaders = new Headers(retryResult.headers || {});
- finalBody = providerRequestCapture.body(retryResult.transformedBody);
+ const signatureRecovery = pipelineRecovered
+ ? { attempted: false, succeeded: false, execution: null, error: null, recoveryBody: null }
+ : await recoverAnthropicThinkingSignature({
+ provider,
+ statusCode,
+ message,
+ body: translatedBody,
+ execute: async (recoveryBody) => {
+ translatedBody = recoveryBody as typeof translatedBody;
+ return executeProviderRequest(currentModel, false);
+ },
+ parseError: (response) => parseUpstreamError(response, provider),
+ });
+ if (!pipelineRecovered && signatureRecovery.attempted && signatureRecovery.execution) {
+ providerResponse = signatureRecovery.execution.response;
+ if (signatureRecovery.succeeded) {
+ providerUrl = signatureRecovery.execution.url;
+ providerHeaders = signatureRecovery.execution.headers;
+ finalBody = providerRequestCapture.body(signatureRecovery.execution.transformedBody);
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
updatePendingScope(pendingScope, {
providerRequest: finalBody,
providerUrl,
stage: "provider_response_started",
});
- upstreamErrorParsed = false; // Reset since new response is OK
- } else {
- providerResponse = retryResult.response;
- upstreamErrorParsed = false; // Let it be parsed downstream
- }
- } catch (retryErr) {
- if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr);
- // Refresh succeeded but the retry leg failed (network blip, AbortError,
- // executor throw). Don't swallow — the operator-visible signal "the user
- // saw 401 even though auth was actually fixed" is much more confusing
- // than the original 401 alone. Surface at error level with sanitization.
- log?.error?.(
- "TOKEN",
- `${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}`
- );
- }
- } else {
- log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`);
- if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) {
- // Front 3 (reuse-race tolerance): before deactivating, re-read the DB.
- // If a sibling/concurrent refresh already rotated this connection's
- // refresh_token (common for Codex/OpenAI under one shared Auth0 client),
- // the failure we saw was a stale-token reuse — the account is healthy
- // with the newer token, so keep it active instead of killing it.
- let alreadyRotated = false;
- if (typeof connectionId === "string" && connectionId && attemptedRefreshToken) {
- try {
- const latest = await getProviderConnectionById(connectionId);
- if (wasRefreshTokenRotated(attemptedRefreshToken, latest?.refreshToken)) {
- alreadyRotated = true;
- log?.warn?.(
- "TOKEN",
- `${provider.toUpperCase()} | refresh_token already rotated by a concurrent refresh — keeping connection active`
- );
- }
- } catch {
- // DB read failed — fall through to the safe default (deactivate).
- }
- }
- if (!alreadyRotated) {
- await onCredentialsRefreshed({ testStatus: "expired", isActive: false });
+ log?.info?.(
+ "THINKING_SIGNATURE",
+ `Recovered ${provider}/${currentModel} after one historical-thinking retry`
+ );
+ } else if (signatureRecovery.error) {
+ statusCode = signatureRecovery.error.statusCode;
+ message = signatureRecovery.error.message;
+ retryAfterMs = signatureRecovery.error.retryAfterMs;
+ upstreamErrorBody = signatureRecovery.error.responseBody;
+ upstreamErrorCode = signatureRecovery.error.errorCode as string | undefined;
+ upstreamErrorType = signatureRecovery.error.errorType as string | undefined;
}
}
- }
- }
- // Check provider response - return error info for fallback handling
- providerFailure: if (!providerResponse.ok) {
- trackPendingRequest(model, provider, connectionId, false);
+ if (signatureRecovery.succeeded) break providerFailure;
- let statusCode = providerResponse.status;
- let message = "";
- let retryAfterMs: number | null = null;
- let upstreamErrorCode: string | undefined;
- let upstreamErrorType: string | undefined;
-
- if (upstreamErrorParsed) {
- statusCode = parsedStatusCode;
- message = parsedMessage;
- retryAfterMs = parsedRetryAfterMs;
- } else {
- const details = await parseUpstreamError(providerResponse, provider);
- statusCode = details.statusCode;
- message = details.message;
- retryAfterMs = details.retryAfterMs;
- upstreamErrorBody = details.responseBody;
- upstreamErrorCode = details.errorCode as string | undefined;
- upstreamErrorType = details.errorType as string | undefined;
- }
-
- // Gateways like agentrouter misstate temporary quota exhaustion as 403/400,
- // which downstream classification treats as AUTH_ERROR and clients like
- // Claude Code treat as permanent. Restate to 429 (+ synthetic Retry-After)
- // BEFORE any classification so both the fallback engine and the surfaced
- // client status see a retryable error. Registry-scoped per provider.
- const restatement = applyStatusRestatement({
- provider,
- status: statusCode,
- message,
- body: upstreamErrorBody,
- retryAfterMs,
- });
- if (restatement.ruleId) {
- statusCode = restatement.status;
- retryAfterMs = restatement.retryAfterMs;
- log?.info?.(
- "STATUS_RESTATE",
- `${provider} ${restatement.fromStatus}→${statusCode} (${restatement.ruleId})`
- );
- }
-
- const signatureRecovery = pipelineRecovered
- ? { attempted: false, succeeded: false, execution: null, error: null, recoveryBody: null }
- : await recoverAnthropicThinkingSignature({
- provider,
- statusCode,
- message,
- body: translatedBody,
- execute: async (recoveryBody) => {
- translatedBody = recoveryBody as typeof translatedBody;
- return executeProviderRequest(currentModel, false);
- },
- parseError: (response) => parseUpstreamError(response, provider),
- });
- if (!pipelineRecovered && signatureRecovery.attempted && signatureRecovery.execution) {
- providerResponse = signatureRecovery.execution.response;
- if (signatureRecovery.succeeded) {
- providerUrl = signatureRecovery.execution.url;
- providerHeaders = signatureRecovery.execution.headers;
- finalBody = providerRequestCapture.body(signatureRecovery.execution.transformedBody);
- reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
- updatePendingScope(pendingScope, {
- providerRequest: finalBody,
- providerUrl,
- stage: "provider_response_started",
+ // #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check
+ // sends `max_tokens: 1`): the model burns the whole budget on thinking, and
+ // some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty
+ // outcome with a 5xx ("empty response content") instead of a truncated 200.
+ // Answer such probes with a valid truncated response rather than relaying the
+ // upstream failure — which would also mark the connection unavailable and
+ // poison fallback/cooldown bookkeeping for a request that is only a probe.
+ if (
+ !stream &&
+ isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) &&
+ isEmptyContentUpstreamFailure(statusCode, message)
+ ) {
+ providerResponse = buildReasoningProbeTruncatedResponse({
+ model: currentModel,
+ maxTokens: toPositiveInteger(
+ (finalBody || translatedBody)?.max_tokens ??
+ (finalBody || translatedBody)?.max_completion_tokens
+ ),
+ requestId: skillRequestId,
});
- log?.info?.(
- "THINKING_SIGNATURE",
- `Recovered ${provider}/${currentModel} after one historical-thinking retry`
+ log?.warn?.(
+ "PROBE",
+ `Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"`
);
- } else if (signatureRecovery.error) {
- statusCode = signatureRecovery.error.statusCode;
- message = signatureRecovery.error.message;
- retryAfterMs = signatureRecovery.error.retryAfterMs;
- upstreamErrorBody = signatureRecovery.error.responseBody;
- upstreamErrorCode = signatureRecovery.error.errorCode as string | undefined;
- upstreamErrorType = signatureRecovery.error.errorType as string | undefined;
+ break providerFailure;
}
- }
- if (signatureRecovery.succeeded) break providerFailure;
-
- // #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check
- // sends `max_tokens: 1`): the model burns the whole budget on thinking, and
- // some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty
- // outcome with a 5xx ("empty response content") instead of a truncated 200.
- // Answer such probes with a valid truncated response rather than relaying the
- // upstream failure — which would also mark the connection unavailable and
- // poison fallback/cooldown bookkeeping for a request that is only a probe.
- if (
- !stream &&
- isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) &&
- isEmptyContentUpstreamFailure(statusCode, message)
- ) {
- providerResponse = buildReasoningProbeTruncatedResponse({
- model: currentModel,
- maxTokens: toPositiveInteger(
- (finalBody || translatedBody)?.max_tokens ??
- (finalBody || translatedBody)?.max_completion_tokens
- ),
- requestId: skillRequestId,
- });
- log?.warn?.(
- "PROBE",
- `Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"`
- );
- break providerFailure;
- }
-
- // T06/T10/T36: classify provider errors and persist terminal account states.
- let errorType = classifyProviderError(statusCode, message, provider);
- if (statusCode === 429 && isModelScope()) {
- const decision = classifyModelScope429(message, normalizeHeaders(providerResponse.headers));
- errorType =
- decision.kind === "quota_exhausted"
- ? PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED
- : PROVIDER_ERROR_TYPES.RATE_LIMITED;
- log?.warn?.(
- "MODELSCOPE_429",
- `${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 {
- if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
- {
- const probeIsolated = await shouldIsolateProbeFailures();
- await writeTerminalStatus(
- errorConnectionId,
- {
- testStatus: "banned",
- isActive: false,
- lastError: persistentMessage,
- lastErrorType: errorType,
- errorCode: String(statusCode),
- },
- probeIsolated ? "probe" : "production"
- );
- if (probeIsolated) {
- console.warn(
- `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
- );
- } else {
- console.warn(
- `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently`
- );
- }
- }
- } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
- // T-PROBE: probe-origin failures (test-all) never deactivate —
- // record but stay active; Plan A (extra keys) stays first so the
- // real path keeps its existing priority (#9817).
- // Plan A: if connection has extra API keys, don't disable — only the failing key is affected.
- // Single-key connections still get disabled as before.
- if (
- connectionHasExtraKeys(
- errorConnectionId,
- (credentials?.providerSpecificData as Record | undefined)
- ?.extraApiKeys as string[] | undefined
- )
- ) {
- await updateProviderConnection(errorConnectionId, {
- lastErrorType: errorType,
- lastError: persistentMessage,
- errorCode: statusCode,
- });
- console.warn(
- `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active`
- );
- } else {
- const probeIsolated2 = await shouldIsolateProbeFailures();
- await writeTerminalStatus(
- errorConnectionId,
- {
- testStatus: "deactivated",
- isActive: false,
- lastError: persistentMessage,
- lastErrorType: errorType,
- errorCode: String(statusCode),
- },
- probeIsolated2 ? "probe" : "production"
- );
- if (probeIsolated2) {
- console.warn(
- `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
- );
- } else {
- console.warn(
- `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — disabling permanently`
- );
- }
- }
- } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
- {
- const probeIsolated3 = await shouldIsolateProbeFailures();
- if (probeIsolated3) {
+ // T06/T10/T36: classify provider errors and persist terminal account states.
+ let errorType = classifyProviderError(statusCode, message, provider);
+ if (statusCode === 429 && isModelScope()) {
+ const decision = classifyModelScope429(message, normalizeHeaders(providerResponse.headers));
+ errorType =
+ decision.kind === "quota_exhausted"
+ ? PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED
+ : PROVIDER_ERROR_TYPES.RATE_LIMITED;
+ log?.warn?.(
+ "MODELSCOPE_429",
+ `${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 {
+ if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
+ {
+ const probeIsolated = await shouldIsolateProbeFailures();
await writeTerminalStatus(
errorConnectionId,
{
- testStatus: "credits_exhausted",
+ testStatus: "banned",
+ isActive: false,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
- "probe"
+ probeIsolated ? "probe" : "production"
);
- console.warn(
- `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
- );
- } else {
- // Kimi's 403 says "billing cycle" for both an exhausted subscription and a
- // temporary request window. Read its official usage endpoint before making
- // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit
- // window must recover automatically at the reported reset time.
- let kimiRateLimitResetAt: string | null = null;
- if (provider === "kimi-coding") {
- try {
- const { fetchAndPersistProviderLimits } =
- await import("@/lib/usage/providerLimits");
- const { usage } = await fetchAndPersistProviderLimits(
- errorConnectionId,
- "manual"
- );
- kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage);
- } catch {
- // Preserve the existing quota handling when Kimi's usage endpoint is unavailable.
- }
- }
-
- // Providers with per-model quotas — lock the model only, not the connection
- let quotaCooldownMs = kimiRateLimitResetAt
- ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
- : retryAfterMs || COOLDOWN_MS.rateLimit;
- const deferAntigravityQuotaStateToCaller = shouldDeferAntigravityQuotaStateToCaller(
- provider,
- typeof onStreamFailure === "function"
- );
- const isAntigravityQuotaFamily = shouldDeferAntigravityQuotaStateToCaller(
- provider,
- true
- );
- let coreOwnedAntigravityLockout: {
- cooldownMs: number;
- failureCount: number;
- } | null = null;
- if (isAntigravityQuotaFamily && !deferAntigravityQuotaStateToCaller) {
- const quotaErrorText =
- typeof upstreamErrorBody === "string"
- ? upstreamErrorBody
- : upstreamErrorBody == null
- ? message
- : JSON.stringify(upstreamErrorBody);
- coreOwnedAntigravityLockout = await recordCoreOwnedAntigravityQuotaState({
- provider,
- connectionId: errorConnectionId,
- model,
- status: statusCode,
- errorText: quotaErrorText,
- headers: providerResponse.headers,
- });
- quotaCooldownMs = coreOwnedAntigravityLockout.cooldownMs;
- }
- const accountSemaphoreKey = resolveAccountSemaphoreKey({
- provider,
- model: currentModel,
- connectionId: errorConnectionId,
- credentials,
- });
- if (accountSemaphoreKey && !deferAntigravityQuotaStateToCaller) {
- markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
- }
- if (deferAntigravityQuotaStateToCaller) {
- // Defer both model and account-semaphore cooldowns to
- // markAccountUnavailable, where header/body provenance and the
- // configured maxCooldownMs are available. Direct consumers such
- // as Responses pass no owner callback and retain core ownership.
- } else if (coreOwnedAntigravityLockout) {
+ if (probeIsolated) {
console.warn(
- `[provider] Node ${errorConnectionId} Antigravity model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(coreOwnedAntigravityLockout.cooldownMs / 1000)}s (failureCount=${coreOwnedAntigravityLockout.failureCount}, owner=core)`
- );
- } else if (kimiRateLimitResetAt) {
- await updateProviderConnection(errorConnectionId, {
- testStatus: "unavailable",
- rateLimitedUntil: kimiRateLimitResetAt,
- backoffLevel: 0,
- lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
- lastError: persistentMessage,
- errorCode: statusCode,
- });
- console.warn(
- `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}`
- );
- } else if (isModelScope() && errorConnectionId) {
- lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
- console.warn(
- `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
- );
- } else if (
- lockModelIfPerModelQuota(
- provider,
- errorConnectionId,
- model,
- "quota_exhausted",
- quotaCooldownMs
- )
- ) {
- const quotaScope = getQuotaScopeLabelForProvider(provider, model);
- console.warn(
- `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
+ `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
);
} else {
+ console.warn(
+ `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently`
+ );
+ }
+ }
+ } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
+ // T-PROBE: probe-origin failures (test-all) never deactivate —
+ // record but stay active; Plan A (extra keys) stays first so the
+ // real path keeps its existing priority (#9817).
+ // Plan A: if connection has extra API keys, don't disable — only the failing key is affected.
+ // Single-key connections still get disabled as before.
+ if (
+ connectionHasExtraKeys(
+ errorConnectionId,
+ (credentials?.providerSpecificData as Record | undefined)
+ ?.extraApiKeys as string[] | undefined
+ )
+ ) {
+ await updateProviderConnection(errorConnectionId, {
+ lastErrorType: errorType,
+ lastError: persistentMessage,
+ errorCode: statusCode,
+ });
+ console.warn(
+ `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active`
+ );
+ } else {
+ const probeIsolated2 = await shouldIsolateProbeFailures();
+ await writeTerminalStatus(
+ errorConnectionId,
+ {
+ testStatus: "deactivated",
+ isActive: false,
+ lastError: persistentMessage,
+ lastErrorType: errorType,
+ errorCode: String(statusCode),
+ },
+ probeIsolated2 ? "probe" : "production"
+ );
+ if (probeIsolated2) {
+ console.warn(
+ `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
+ );
+ } else {
+ console.warn(
+ `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — disabling permanently`
+ );
+ }
+ }
+ } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
+ {
+ const probeIsolated3 = await shouldIsolateProbeFailures();
+ if (probeIsolated3) {
await writeTerminalStatus(
errorConnectionId,
{
@@ -4406,176 +4291,314 @@ export async function handleChatCore({
lastErrorType: errorType,
errorCode: String(statusCode),
},
- "production"
+ "probe"
);
console.warn(
- `[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`
+ `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
);
+ } else {
+ // Kimi's 403 says "billing cycle" for both an exhausted subscription and a
+ // temporary request window. Read its official usage endpoint before making
+ // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit
+ // window must recover automatically at the reported reset time.
+ let kimiRateLimitResetAt: string | null = null;
+ if (provider === "kimi-coding") {
+ try {
+ const { fetchAndPersistProviderLimits } =
+ await import("@/lib/usage/providerLimits");
+ const { usage } = await fetchAndPersistProviderLimits(
+ errorConnectionId,
+ "manual"
+ );
+ kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage);
+ } catch {
+ // Preserve the existing quota handling when Kimi's usage endpoint is unavailable.
+ }
+ }
+
+ // Providers with per-model quotas — lock the model only, not the connection
+ let quotaCooldownMs = kimiRateLimitResetAt
+ ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
+ : retryAfterMs || COOLDOWN_MS.rateLimit;
+ const deferAntigravityQuotaStateToCaller = shouldDeferAntigravityQuotaStateToCaller(
+ provider,
+ typeof onStreamFailure === "function"
+ );
+ const isAntigravityQuotaFamily = shouldDeferAntigravityQuotaStateToCaller(
+ provider,
+ true
+ );
+ let coreOwnedAntigravityLockout: {
+ cooldownMs: number;
+ failureCount: number;
+ } | null = null;
+ if (isAntigravityQuotaFamily && !deferAntigravityQuotaStateToCaller) {
+ const quotaErrorText =
+ typeof upstreamErrorBody === "string"
+ ? upstreamErrorBody
+ : upstreamErrorBody == null
+ ? message
+ : JSON.stringify(upstreamErrorBody);
+ coreOwnedAntigravityLockout = await recordCoreOwnedAntigravityQuotaState({
+ provider,
+ connectionId: errorConnectionId,
+ model,
+ status: statusCode,
+ errorText: quotaErrorText,
+ headers: providerResponse.headers,
+ });
+ quotaCooldownMs = coreOwnedAntigravityLockout.cooldownMs;
+ }
+ const accountSemaphoreKey = resolveAccountSemaphoreKey({
+ provider,
+ model: currentModel,
+ connectionId: errorConnectionId,
+ credentials,
+ });
+ if (accountSemaphoreKey && !deferAntigravityQuotaStateToCaller) {
+ markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
+ }
+ if (deferAntigravityQuotaStateToCaller) {
+ // Defer both model and account-semaphore cooldowns to
+ // markAccountUnavailable, where header/body provenance and the
+ // configured maxCooldownMs are available. Direct consumers such
+ // as Responses pass no owner callback and retain core ownership.
+ } else if (coreOwnedAntigravityLockout) {
+ console.warn(
+ `[provider] Node ${errorConnectionId} Antigravity model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(coreOwnedAntigravityLockout.cooldownMs / 1000)}s (failureCount=${coreOwnedAntigravityLockout.failureCount}, owner=core)`
+ );
+ } else if (kimiRateLimitResetAt) {
+ await updateProviderConnection(errorConnectionId, {
+ testStatus: "unavailable",
+ rateLimitedUntil: kimiRateLimitResetAt,
+ backoffLevel: 0,
+ lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
+ lastError: persistentMessage,
+ errorCode: statusCode,
+ });
+ console.warn(
+ `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}`
+ );
+ } else if (isModelScope() && errorConnectionId) {
+ lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
+ console.warn(
+ `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
+ );
+ } else if (
+ lockModelIfPerModelQuota(
+ provider,
+ errorConnectionId,
+ model,
+ "quota_exhausted",
+ quotaCooldownMs
+ )
+ ) {
+ const quotaScope = getQuotaScopeLabelForProvider(provider, model);
+ console.warn(
+ `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
+ );
+ } else {
+ await writeTerminalStatus(
+ errorConnectionId,
+ {
+ testStatus: "credits_exhausted",
+ lastError: persistentMessage,
+ lastErrorType: errorType,
+ errorCode: String(statusCode),
+ },
+ "production"
+ );
+ console.warn(
+ `[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`
+ );
+ }
+ } // close probeIsolated3 else
+ }
+ } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
+ // Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
+ await updateProviderConnection(errorConnectionId, {
+ lastErrorType: errorType,
+ 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: persistentMessage,
+ errorCode: statusCode,
+ });
+ console.warn(
+ `[provider] Node ${errorConnectionId} OAuth token invalid (${statusCode}) — token refresh available`
+ );
+ } else if (errorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR) {
+ // Cloud Code 403 with stale project: not a ban, keep account active.
+ await updateProviderConnection(errorConnectionId, {
+ lastErrorType: errorType,
+ lastError: persistentMessage,
+ errorCode: statusCode,
+ });
+ console.warn(
+ `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning`
+ );
+ } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) {
+ // Google regional-availability refusal (e.g. "User location is not
+ // supported for the API use."). Account-independent and non-terminal:
+ // exclude the connection for the cooldown window so routing moves to
+ // other accounts instead of re-selecting this one on every request,
+ // and never mark it banned/expired. It becomes usable again once
+ // egress is routed through a supported-region proxy.
+ const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000;
+ await updateProviderConnection(errorConnectionId, {
+ lastErrorType: errorType,
+ lastError: persistentMessage,
+ errorCode: statusCode,
+ });
+ // T-PROBE: the 24h exclusion is a routing mutation — a probe must
+ // not push a connection into a day-long cooldown (#9817).
+ if (!(await shouldIsolateProbeFailures())) {
+ try {
+ const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
+ setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs);
+ } catch {
+ // DB write failure must never break the fallback loop
}
- } // close probeIsolated3 else
- }
- } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
- // Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
- await updateProviderConnection(errorConnectionId, {
- lastErrorType: errorType,
- 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: persistentMessage,
- errorCode: statusCode,
- });
- console.warn(
- `[provider] Node ${errorConnectionId} OAuth token invalid (${statusCode}) — token refresh available`
- );
- } else if (errorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR) {
- // Cloud Code 403 with stale project: not a ban, keep account active.
- await updateProviderConnection(errorConnectionId, {
- lastErrorType: errorType,
- lastError: persistentMessage,
- errorCode: statusCode,
- });
- console.warn(
- `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning`
- );
- } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) {
- // Google regional-availability refusal (e.g. "User location is not
- // supported for the API use."). Account-independent and non-terminal:
- // exclude the connection for the cooldown window so routing moves to
- // other accounts instead of re-selecting this one on every request,
- // and never mark it banned/expired. It becomes usable again once
- // egress is routed through a supported-region proxy.
- const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000;
- await updateProviderConnection(errorConnectionId, {
- lastErrorType: errorType,
- lastError: persistentMessage,
- errorCode: statusCode,
- });
- // T-PROBE: the 24h exclusion is a routing mutation — a probe must
- // not push a connection into a day-long cooldown (#9817).
- if (!(await shouldIsolateProbeFailures())) {
+ }
+ console.warn(
+ `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts`
+ );
+ } else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) {
+ // Antigravity BYOP: the account must Bring Its Own GCP Project.
+ // Account-specific and fixable by entering a Project ID — never a
+ // model lockout, never a ban. Exclude the connection for the
+ // cooldown window so selection prefers sibling accounts; the 422
+ // body carries the actionable message when no sibling is available.
+ const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000;
+ await updateProviderConnection(errorConnectionId, {
+ lastErrorType: errorType,
+ lastError: persistentMessage,
+ errorCode: statusCode,
+ });
try {
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
- setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs);
+ setConnectionRateLimitUntil(errorConnectionId, Date.now() + byopCooldownMs);
} catch {
- // DB write failure must never break the fallback loop
+ // best-effort — never break the error path
+ }
+ console.warn(
+ `[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)`
+ );
+ } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) {
+ // 404 — model/endpoint does not exist upstream. Lock the model so the
+ // retry/backoff loop stops hammering the dead endpoint (which would
+ // otherwise degenerate into a 429 rate-limit storm). Connection stays
+ // active since only the specific model is unavailable. (#6827)
+ const notFoundCooldownMs = COOLDOWN_MS.notFound;
+ // T-PROBE: the model lockout is a routing mutation — a probe must
+ // not lock a model for the cooldown window (#9817).
+ if (!(await shouldIsolateProbeFailures())) {
+ lockModel(
+ provider,
+ errorConnectionId,
+ currentModel,
+ "model_not_found",
+ notFoundCooldownMs
+ );
+ console.warn(
+ `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)`
+ );
}
}
- console.warn(
- `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts`
- );
- } else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) {
- // Antigravity BYOP: the account must Bring Its Own GCP Project.
- // Account-specific and fixable by entering a Project ID — never a
- // model lockout, never a ban. Exclude the connection for the
- // cooldown window so selection prefers sibling accounts; the 422
- // body carries the actionable message when no sibling is available.
- const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000;
- await updateProviderConnection(errorConnectionId, {
- lastErrorType: errorType,
- lastError: persistentMessage,
- errorCode: statusCode,
- });
- try {
- const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
- setConnectionRateLimitUntil(errorConnectionId, Date.now() + byopCooldownMs);
- } catch {
- // best-effort — never break the error path
- }
- console.warn(
- `[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)`
- );
- } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) {
- // 404 — model/endpoint does not exist upstream. Lock the model so the
- // retry/backoff loop stops hammering the dead endpoint (which would
- // otherwise degenerate into a 429 rate-limit storm). Connection stays
- // active since only the specific model is unavailable. (#6827)
- const notFoundCooldownMs = COOLDOWN_MS.notFound;
- // T-PROBE: the model lockout is a routing mutation — a probe must
- // not lock a model for the cooldown window (#9817).
- if (!(await shouldIsolateProbeFailures())) {
- lockModel(
- provider,
- errorConnectionId,
- currentModel,
- "model_not_found",
- notFoundCooldownMs
- );
- console.warn(
- `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)`
- );
- }
+ } catch {
+ // Best-effort state update; request flow should continue with fallback handling.
}
- } catch {
- // Best-effort state update; request flow should continue with fallback handling.
}
- }
- appendRequestLog({
- model,
- provider,
- connectionId: errorConnectionId,
- status: `FAILED ${statusCode}`,
- }).catch(() => {});
+ appendRequestLog({
+ model,
+ provider,
+ connectionId: errorConnectionId,
+ status: `FAILED ${statusCode}`,
+ }).catch(() => {});
- const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
- console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
+ const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
+ console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
- // Log Antigravity retry time if available
- if (retryAfterMs && provider === "antigravity") {
- const retrySeconds = Math.ceil(retryAfterMs / 1000);
- log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`);
- }
+ // Log Antigravity retry time if available
+ if (retryAfterMs && provider === "antigravity") {
+ const retrySeconds = Math.ceil(retryAfterMs / 1000);
+ log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`);
+ }
- // Log error with full request body for debugging
- reqLogger.logError(new Error(message), finalBody || translatedBody);
- reqLogger.logProviderResponse(
- providerResponse.status,
- providerResponse.statusText,
- providerResponse.headers,
- upstreamErrorBody
- );
+ // Log error with full request body for debugging
+ reqLogger.logError(new Error(message), finalBody || translatedBody);
+ reqLogger.logProviderResponse(
+ providerResponse.status,
+ providerResponse.statusText,
+ providerResponse.headers,
+ upstreamErrorBody
+ );
- // Update rate limiter from error response headers
- updateFromHeaders(provider, errorConnectionId, providerResponse.headers, statusCode, model);
- if (errorConnectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) {
- updateFromResponseBody(provider, errorConnectionId, upstreamErrorBody, statusCode, model);
- }
+ // Update rate limiter from error response headers
+ updateFromHeaders(provider, errorConnectionId, providerResponse.headers, statusCode, model);
+ if (errorConnectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) {
+ updateFromResponseBody(provider, errorConnectionId, upstreamErrorBody, statusCode, model);
+ }
- // ── T5: Intra-family model fallback ──────────────────────────────────────
- // Before returning a model-unavailable error upstream, try sibling models
- // from the same family. This keeps the request alive on the same account
- // instead of failing the entire combo.
- if (!pipelineRecovered && isModelUnavailableError(statusCode, message, provider)) {
- const nextModel = getNextFamilyFallback(currentModel, triedModels, provider);
- if (nextModel) {
- triedModels.add(nextModel);
- currentModel = nextModel;
- translatedBody.model = nextModel;
- log?.info?.("MODEL_FALLBACK", `${model} unavailable (${statusCode}) → trying ${nextModel}`);
- // Re-execute with the fallback model
- try {
- const fallbackResult = await executeProviderRequest(nextModel, false);
- if (fallbackResult.response.ok) {
- providerResponse = fallbackResult.response;
- providerUrl = fallbackResult.url;
- providerHeaders = fallbackResult.headers;
- finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
- reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
- updatePendingScope(pendingScope, {
- providerRequest: finalBody,
- providerUrl,
- stage: "provider_response_started",
- });
- // Continue processing with the fallback response — skip error return
- log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`);
- // Jump to streaming/non-streaming handling below
- // We fall through by NOT returning here
- } else {
- // Fallback also failed — return original error
+ // ── T5: Intra-family model fallback ──────────────────────────────────────
+ // Before returning a model-unavailable error upstream, try sibling models
+ // from the same family. This keeps the request alive on the same account
+ // instead of failing the entire combo.
+ if (!pipelineRecovered && isModelUnavailableError(statusCode, message, provider)) {
+ const nextModel = getNextFamilyFallback(currentModel, triedModels, provider);
+ if (nextModel) {
+ triedModels.add(nextModel);
+ currentModel = nextModel;
+ translatedBody.model = nextModel;
+ log?.info?.(
+ "MODEL_FALLBACK",
+ `${model} unavailable (${statusCode}) → trying ${nextModel}`
+ );
+ // Re-execute with the fallback model
+ try {
+ const fallbackResult = await executeProviderRequest(nextModel, false);
+ if (fallbackResult.response.ok) {
+ providerResponse = fallbackResult.response;
+ providerUrl = fallbackResult.url;
+ providerHeaders = fallbackResult.headers;
+ finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
+ reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
+ updatePendingScope(pendingScope, {
+ providerRequest: finalBody,
+ providerUrl,
+ stage: "provider_response_started",
+ });
+ // Continue processing with the fallback response — skip error return
+ log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`);
+ // Jump to streaming/non-streaming handling below
+ // We fall through by NOT returning here
+ } else {
+ // Fallback also failed — return original error
+ persistAttemptLogs({
+ status: statusCode,
+ error: errMsg,
+ providerRequest: finalBody || translatedBody,
+ providerResponse: upstreamErrorBody,
+ clientResponse: buildErrorBody(statusCode, errMsg),
+ cacheSource: "upstream",
+ });
+ persistFailureUsage(statusCode, "model_unavailable");
+ return createErrorResult(
+ statusCode,
+ errMsg,
+ retryAfterMs,
+ upstreamErrorCode,
+ upstreamErrorType,
+ upstreamErrorBody,
+ { passthrough: sourceFormat === FORMATS.CLAUDE }
+ );
+ }
+ } catch {
persistAttemptLogs({
status: statusCode,
error: errMsg,
@@ -4595,7 +4618,7 @@ export async function handleChatCore({
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
- } catch {
+ } else {
persistAttemptLogs({
status: statusCode,
error: errMsg,
@@ -4615,56 +4638,59 @@ export async function handleChatCore({
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
- } else {
- persistAttemptLogs({
- status: statusCode,
- error: errMsg,
- providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
- clientResponse: buildErrorBody(statusCode, errMsg),
- cacheSource: "upstream",
- });
- persistFailureUsage(statusCode, "model_unavailable");
- return createErrorResult(
- statusCode,
- errMsg,
- retryAfterMs,
- upstreamErrorCode,
- upstreamErrorType,
- upstreamErrorBody,
- { passthrough: sourceFormat === FORMATS.CLAUDE }
+ } else if (isContextOverflowError(statusCode, message)) {
+ const familyCandidates = getModelFamily(currentModel, provider).filter(
+ (m) => m !== currentModel && !triedModels.has(m)
);
- }
- } else if (isContextOverflowError(statusCode, message)) {
- const familyCandidates = getModelFamily(currentModel, provider).filter(
- (m) => m !== currentModel && !triedModels.has(m)
- );
- const nextModel =
- findLargerContextModel(currentModel, familyCandidates, provider) ??
- getNextFamilyFallback(currentModel, triedModels, provider);
- if (nextModel) {
- triedModels.add(nextModel);
- currentModel = nextModel;
- translatedBody.model = nextModel;
- log?.info?.("CONTEXT_OVERFLOW_FALLBACK", `${model} context overflow → trying ${nextModel}`);
- try {
- const fallbackResult = await executeProviderRequest(nextModel, false);
- if (fallbackResult.response.ok) {
- providerResponse = fallbackResult.response;
- providerUrl = fallbackResult.url;
- providerHeaders = fallbackResult.headers;
- finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
- reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
- updatePendingScope(pendingScope, {
- providerRequest: finalBody,
- providerUrl,
- stage: "provider_response_started",
- });
- log?.info?.(
- "CONTEXT_OVERFLOW_FALLBACK",
- `Serving ${nextModel} as fallback for ${model}`
- );
- } else {
+ const nextModel =
+ findLargerContextModel(currentModel, familyCandidates, provider) ??
+ getNextFamilyFallback(currentModel, triedModels, provider);
+ if (nextModel) {
+ triedModels.add(nextModel);
+ currentModel = nextModel;
+ translatedBody.model = nextModel;
+ log?.info?.(
+ "CONTEXT_OVERFLOW_FALLBACK",
+ `${model} context overflow → trying ${nextModel}`
+ );
+ try {
+ const fallbackResult = await executeProviderRequest(nextModel, false);
+ if (fallbackResult.response.ok) {
+ providerResponse = fallbackResult.response;
+ providerUrl = fallbackResult.url;
+ providerHeaders = fallbackResult.headers;
+ finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
+ reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
+ updatePendingScope(pendingScope, {
+ providerRequest: finalBody,
+ providerUrl,
+ stage: "provider_response_started",
+ });
+ log?.info?.(
+ "CONTEXT_OVERFLOW_FALLBACK",
+ `Serving ${nextModel} as fallback for ${model}`
+ );
+ } else {
+ persistAttemptLogs({
+ status: statusCode,
+ error: errMsg,
+ providerRequest: finalBody || translatedBody,
+ providerResponse: upstreamErrorBody,
+ clientResponse: buildErrorBody(statusCode, errMsg),
+ cacheSource: "upstream",
+ });
+ persistFailureUsage(statusCode, "context_overflow");
+ return createErrorResult(
+ statusCode,
+ errMsg,
+ retryAfterMs,
+ upstreamErrorCode,
+ upstreamErrorType,
+ upstreamErrorBody,
+ { passthrough: sourceFormat === FORMATS.CLAUDE }
+ );
+ }
+ } catch {
persistAttemptLogs({
status: statusCode,
error: errMsg,
@@ -4684,7 +4710,7 @@ export async function handleChatCore({
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
- } catch {
+ } else {
persistAttemptLogs({
status: statusCode,
error: errMsg,
@@ -4713,7 +4739,14 @@ export async function handleChatCore({
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
- persistFailureUsage(statusCode, "context_overflow");
+ persistFailureUsage(statusCode, `upstream_${statusCode}`);
+
+ // Emergency budget fallback is orchestrated exclusively by the routing layer
+ // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency
+ // provider through account selection. The executor-level hop that used to
+ // live here re-sent the FAILING provider's credentials to the emergency
+ // provider's endpoint (e.g. the OpenAI API key to integrate.api.nvidia.com)
+ // — a cross-provider credential leak that also never succeeded upstream.
return createErrorResult(
statusCode,
errMsg,
@@ -4724,41 +4757,18 @@ export async function handleChatCore({
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
- } else {
- persistAttemptLogs({
- status: statusCode,
- error: errMsg,
- providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
- clientResponse: buildErrorBody(statusCode, errMsg),
- cacheSource: "upstream",
- });
- persistFailureUsage(statusCode, `upstream_${statusCode}`);
-
- // Emergency budget fallback is orchestrated exclusively by the routing layer
- // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency
- // provider through account selection. The executor-level hop that used to
- // live here re-sent the FAILING provider's credentials to the emergency
- // provider's endpoint (e.g. the OpenAI API key to integrate.api.nvidia.com)
- // — a cross-provider credential leak that also never succeeded upstream.
- return createErrorResult(
- statusCode,
- errMsg,
- retryAfterMs,
- upstreamErrorCode,
- upstreamErrorType,
- upstreamErrorBody,
- { passthrough: sourceFormat === FORMATS.CLAUDE }
- );
+ // ── End T5 ───────────────────────────────────────────────────────────────
}
- // ── End T5 ───────────────────────────────────────────────────────────────
- }
}
// Non-streaming response
if (!stream) {
try {
- const runNonStreamingPipeline = async ({ policy, model: pipelineModel, translatedBody: wireBody }) => {
+ const runNonStreamingPipeline = async ({
+ policy,
+ model: pipelineModel,
+ translatedBody: wireBody,
+ }) => {
translatedBody = wireBody as typeof translatedBody;
currentModel = pipelineModel;
triedModels.add(pipelineModel);
@@ -4852,341 +4862,524 @@ export async function handleChatCore({
sendProviderAttempt: (modelToCall, allowDedup) =>
executeProviderRequest(modelToCall, allowDedup),
});
- };
+ };
- let toolLoopRan = false;
- let toolLoopUsage = null;
- let legResult = await runNonStreamingProviderLeg({
- phase: "initial",
- sourceBody: (body || {}) as Record,
- expectedConnectionId: managedLease
+ let toolLoopRan = false;
+ let toolLoopUsage = null;
+ let legResult = await runNonStreamingProviderLeg({
+ phase: "initial",
+ sourceBody: (body || {}) as Record,
+ expectedConnectionId: managedLease
+ ? String(getCurrentConnectionId() || connectionId || "") || undefined
+ : undefined,
+ allowAccountRotation: !managedLease && comboStrategy !== "context-relay",
+ allowModelFallback: true,
+ executeProviderRequest: (modelToCall, allowDedup) =>
+ executeProviderRequest(modelToCall, allowDedup),
+ runProviderExecution: runNonStreamingPipeline,
+ setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => {
+ translatedBody = nextBody as typeof translatedBody;
+ currentModel = nextModel;
+ triedModels.add(nextModel);
+ },
+ sourceFormat,
+ targetFormat,
+ clientResponseFormat,
+ provider,
+ model: effectiveModel,
+ connectionId: String(getCurrentConnectionId() || connectionId || ""),
+ getCurrentConnectionId: () => getCurrentConnectionId() || undefined,
+ effectiveModel: currentModel,
+ translatedBody: translatedBody as Record,
+ toolNameMap,
+ requestToolIdentityMap,
+ reasoningCacheScope,
+ clientHeaders: clientRawRequest?.headers ?? null,
+ isClaudeCodeCompatible,
+ log,
+ });
+
+ if (legResult.kind === "error") {
+ const err = legResult.result;
+ const captured = providerRequestCapture.latest?.() ?? null;
+ finalBody = captured?.body ?? finalBody ?? translatedBody;
+ if (captured) {
+ reqLogger.logTargetRequest(captured.url, captured.headers, captured.body);
+ }
+ reqLogger.logError(new Error(err.error || "Provider request failed"), finalBody);
+ const isNetworkThrow = Boolean(err.originalError);
+ if (err.response && !isNetworkThrow) {
+ reqLogger.logProviderResponse(
+ err.status,
+ err.response.statusText || "Error",
+ err.response.headers,
+ err.response
+ );
+ }
+ appendRequestLog({
+ model,
+ provider,
+ connectionId,
+ status: `FAILED ${err.status}`,
+ }).catch(() => {});
+ persistAttemptLogs({
+ status: err.status,
+ error: err.error || "Provider request failed",
+ providerRequest: finalBody || translatedBody,
+ providerResponse: isNetworkThrow ? undefined : err.response,
+ // On a client abort the client already disconnected before we got here, so this
+ // body is what we WOULD have sent, not what was delivered. The dashboard reads
+ // `clientResponse` as "what the client received", so logging it misleads —
+ // `error` above already records the reason. The pre-#12867 path omitted it here;
+ // the leg-based path must keep doing so.
+ clientResponse: isLocalStreamLifecycleError(err.originalError)
+ ? undefined
+ : buildErrorBody(err.status, err.error || "Provider request failed"),
+ cacheSource: "upstream",
+ });
+ persistFailureUsage(err.status, err.errorCode || `upstream_${err.status}`);
+ trackPendingRequest(model, provider, connectionId, false);
+ return err;
+ }
+
+ pipelineRecovered = true;
+ const expectedConn = managedLease
? String(getCurrentConnectionId() || connectionId || "") || undefined
- : undefined,
- allowAccountRotation: !managedLease && comboStrategy !== "context-relay",
- allowModelFallback: true,
- executeProviderRequest: (modelToCall, allowDedup) =>
- executeProviderRequest(modelToCall, allowDedup),
- runProviderExecution: runNonStreamingPipeline,
- setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => {
- translatedBody = nextBody as typeof translatedBody;
- currentModel = nextModel;
- triedModels.add(nextModel);
- },
- sourceFormat,
- targetFormat,
- clientResponseFormat,
- provider,
- model: effectiveModel,
- connectionId: String(getCurrentConnectionId() || connectionId || ""),
- getCurrentConnectionId: () => getCurrentConnectionId() || undefined,
- effectiveModel: currentModel,
- translatedBody: translatedBody as Record,
- toolNameMap,
- requestToolIdentityMap,
- reasoningCacheScope,
- clientHeaders: clientRawRequest?.headers ?? null,
- isClaudeCodeCompatible,
- log,
- });
+ : undefined;
+ // The identity is the tool loop's execution fence key, and deriveToolRequestIdentity
+ // canonicalizes the body — which by design rejects Dates, Maps and class instances.
+ // It was computed eagerly, so a body carrying any of those threw on EVERY
+ // non-streaming request even with SERVER_OWNED_TOOL_LOOP_ENABLED off (the default).
+ // Derive it only when the loop can run, and fail closed rather than crash: no
+ // identity means no fence, and without a fence the loop must not run.
+ let toolLoopEnabled = isServerOwnedToolLoopEnabled();
+ let postInjectionRequestIdentity = "";
+ if (toolLoopEnabled) {
+ try {
+ postInjectionRequestIdentity = derivePostInjectionRequestIdentity({
+ apiKeyId: memoryOwnerId || "local",
+ headers: clientRawRequest?.headers ?? null,
+ skillRequestId,
+ postInjectionBody: (body || {}) as Record,
+ });
+ } catch (identityError) {
+ log?.warn?.(
+ "SERVER_OWNED_TOOL_LOOP",
+ `request body is not canonicalizable, skipping the loop: ${
+ identityError instanceof Error ? identityError.message : "unknown"
+ }`
+ );
+ toolLoopEnabled = false;
+ }
+ }
+ const loopApply = await applyServerOwnedToolLoopIfNeeded({
+ enabled: toolLoopEnabled,
+ stream,
+ isResponsesEndpoint,
+ sourceFormat,
+ initialLeg: legResult,
+ sourceBody: (body || {}) as Record,
+ skillsModelId: getSkillsModelIdForFormat(sourceFormat),
+ executionContext: {
+ apiKeyId: memoryOwnerId || "local",
+ sessionId: pipelineSessionId,
+ requestId: skillRequestId,
+ requestIdentity: postInjectionRequestIdentity,
+ builtinToolNames: injectionResult.builtinToolNames,
+ injectedCustomSkillNames: injectionResult.injectedCustomSkillNames,
+ customSkillExecutionEnabled:
+ Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true,
+ executionFenceEnabled: true,
+ provider,
+ model: effectiveModel,
+ },
+ abortSignal: clientRawRequest?.signal,
+ expectedConnectionId: expectedConn,
+ followUpLeg: async (nextSourceBody) => {
+ translatedBody = translateRequest(
+ sourceFormat,
+ targetFormat,
+ model,
+ { ...nextSourceBody },
+ false,
+ credentials,
+ provider,
+ reqLogger,
+ {
+ normalizeToolCallId: getModelNormalizeToolCallId(
+ provider || "",
+ model || "",
+ sourceFormat
+ ),
+ preserveDeveloperRole: getModelPreserveOpenAIDeveloperRole(
+ provider || "",
+ model || "",
+ sourceFormat
+ ),
+ preserveCacheControl,
+ signatureNamespace: connectionId,
+ copilotClient: copilotCompatibleReasoning,
+ reasoningCacheScope,
+ }
+ );
+ return runNonStreamingProviderLeg(
+ followUpLegInput(
+ {
+ executeProviderRequest: (modelToCall, allowDedup) =>
+ executeProviderRequest(modelToCall, allowDedup),
+ runProviderExecution: runNonStreamingPipeline,
+ setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => {
+ translatedBody = nextBody as typeof translatedBody;
+ currentModel = nextModel;
+ triedModels.add(nextModel);
+ },
+ sourceFormat,
+ targetFormat,
+ clientResponseFormat,
+ provider,
+ model: effectiveModel,
+ connectionId: String(getCurrentConnectionId() || connectionId || ""),
+ getCurrentConnectionId: () => getCurrentConnectionId() || undefined,
+ effectiveModel: currentModel,
+ translatedBody: translatedBody as Record,
+ toolNameMap,
+ requestToolIdentityMap,
+ reasoningCacheScope,
+ clientHeaders: clientRawRequest?.headers ?? null,
+ isClaudeCodeCompatible,
+ log,
+ },
+ nextSourceBody,
+ expectedConn
+ )
+ );
+ },
+ logReceipt: (receipt) => reqLogger.logToolLoopReceipt(receipt),
+ });
+ if (loopApply.kind === "error") {
+ return await finalizeToolLoopError({
+ loop: loopApply.loop,
+ model,
+ provider,
+ connectionId,
+ providerRequest: loopApply.loop.finalProviderRequest || finalBody || translatedBody,
+ persistFailureUsage,
+ persistAttemptLogs,
+ trackPendingRequest,
+ });
+ }
+ // `legResult` is declared as the full NonStreamingProviderLegResult union. The
+ // `kind === "error"` guard above narrows it to the ok variant, but the conditional
+ // reassignment below widens it back to the declared type, so every field read past
+ // this point lost the narrowing — 13 TS2339 diagnostics under
+ // tsconfig.typecheck-api.json, which pulls chatCore.ts in through the route while
+ // tsconfig.typecheck-core.json does not. Pin the ok variant in its own binding:
+ // `loopApply.leg` is already `NonStreamingProviderLegResult & { kind: "ok" }`,
+ // so no cast is involved.
+ let okLeg: NonStreamingProviderLegResult & { kind: "ok" } = legResult;
+ if (loopApply.kind === "ok") {
+ toolLoopRan = true;
+ toolLoopUsage = loopApply.usage;
+ okLeg = loopApply.leg;
+ }
- if (legResult.kind === "error") {
- const err = legResult.result;
- const captured = providerRequestCapture.latest?.() ?? null;
- finalBody = captured?.body ?? finalBody ?? translatedBody;
- if (captured) {
- reqLogger.logTargetRequest(captured.url, captured.headers, captured.body);
+ if (okLeg.upstreamResponse) {
+ providerResponse = okLeg.upstreamResponse;
+ providerHeaders = normalizeHeaders(okLeg.upstreamResponse.headers);
+ } else {
+ providerResponse = new Response(null, {
+ status: 200,
+ headers: okLeg.headers,
+ });
+ providerHeaders = normalizeHeaders(okLeg.headers);
}
- reqLogger.logError(new Error(err.error || "Provider request failed"), finalBody);
- const isNetworkThrow = Boolean(err.originalError);
- if (err.response && !isNetworkThrow) {
- reqLogger.logProviderResponse(
- err.status,
- err.response.statusText || "Error",
- err.response.headers,
- err.response
- );
+ finalBody = providerRequestCapture.body(okLeg.providerRequest || translatedBody);
+ // Built inside executeProviderRequest on the pre-#12867 path. The leg now owns the
+ // first non-streaming send, so that assignment never runs here and the meta stayed
+ // null — `_omniroute.claudePromptCache` silently vanished from every call log on
+ // this path. Same inputs, same helper, at the point where they are available.
+ claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
+ targetFormat,
+ finalBody,
+ providerHeaders,
+ clientRawRequest?.headers
+ );
+ const capturedOk = providerRequestCapture.latest?.();
+ reqLogger.logTargetRequest(
+ okLeg.requestUrl || capturedOk?.url || "",
+ okLeg.requestHeaders || capturedOk?.headers || {},
+ capturedOk?.body ?? finalBody
+ );
+ const responseBody = okLeg.providerBody;
+ const responsePayloadFormat = okLeg.responsePayloadFormat;
+ const looksLikeSSE = okLeg.looksLikeSSE;
+ let translatedResponse = okLeg.response;
+ const memoryExtractionResponse = okLeg.responseForMemoryExtraction;
+ reqLogger.logProviderResponse(
+ 200,
+ "OK",
+ providerResponse.headers,
+ looksLikeSSE
+ ? { _streamed: true, _format: "sse-json", summary: responseBody }
+ : responseBody
+ );
+ effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier;
+ if (onRequestSuccess) {
+ await onRequestSuccess();
}
+ const successConnectionId = getCurrentConnectionId();
+ await maybeSyncClaudeExtraUsageState({
+ provider,
+ connectionId: successConnectionId,
+ providerSpecificData: credentials?.providerSpecificData,
+ log,
+ });
+ const usage = toolLoopUsage ?? extractUsageFromResponse(responseBody, provider);
+ const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
+ if (usage && typeof usage === "object") {
+ attachCompressionUsageReceiptAfterAnalytics(usage as Record, "provider");
+ if (provider === "gemini") {
+ const promptTokens =
+ typeof (usage as Record).prompt_tokens === "number"
+ ? ((usage as Record).prompt_tokens as number)
+ : 0;
+ if (promptTokens > 0) incrementTokenUsage(model, promptTokens);
+ }
+ }
+ recordContextEditingTelemetryHook({
+ contextEditingEnabled,
+ provider,
+ responseBody,
+ skillRequestId,
+ log,
+ });
appendRequestLog({
model,
provider,
- connectionId,
- status: `FAILED ${err.status}`,
+ connectionId: successConnectionId,
+ tokens: usage,
+ status: "200 OK",
}).catch(() => {});
- persistAttemptLogs({
- status: err.status,
- error: err.error || "Provider request failed",
- providerRequest: finalBody || translatedBody,
- providerResponse: isNetworkThrow ? undefined : err.response,
- clientResponse: buildErrorBody(err.status, err.error || "Provider request failed"),
- cacheSource: "upstream",
- });
- persistFailureUsage(
- err.status,
- err.errorCode || `upstream_${err.status}`
- );
- trackPendingRequest(model, provider, connectionId, false);
- return err;
- }
-
- pipelineRecovered = true;
- const expectedConn = managedLease
- ? String(getCurrentConnectionId() || connectionId || "") || undefined
- : undefined;
- const loopApply = await applyServerOwnedToolLoopIfNeeded({
- enabled: isServerOwnedToolLoopEnabled(),
- stream,
- isResponsesEndpoint,
- sourceFormat,
- initialLeg: legResult,
- sourceBody: (body || {}) as Record,
- skillsModelId: getSkillsModelIdForFormat(sourceFormat),
- executionContext: {
- apiKeyId: memoryOwnerId || "local",
- sessionId: pipelineSessionId,
- requestId: skillRequestId,
- requestIdentity: derivePostInjectionRequestIdentity({
- apiKeyId: memoryOwnerId || "local",
- headers: clientRawRequest?.headers ?? null,
- skillRequestId,
- postInjectionBody: (body || {}) as Record,
- }),
- builtinToolNames: injectionResult.builtinToolNames,
- injectedCustomSkillNames: injectionResult.injectedCustomSkillNames,
- customSkillExecutionEnabled:
- Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true,
- executionFenceEnabled: true,
+ recordNonStreamingUsageStats(usage, {
+ traceEnabled,
provider,
- model: effectiveModel,
- },
- abortSignal: clientRawRequest?.signal,
- expectedConnectionId: expectedConn,
- followUpLeg: async (nextSourceBody) => {
- translatedBody = translateRequest(
- sourceFormat,
- targetFormat,
- model,
- { ...nextSourceBody },
- false,
- credentials,
- provider,
- reqLogger,
+ connectionId: successConnectionId,
+ model,
+ startTime,
+ apiKeyInfo,
+ effectiveServiceTier,
+ isCombo,
+ comboStrategy,
+ endpoint: endpointPath,
+ });
+
+ // #12150 P1b surface 3 (fix round 1): a video-bridge-observed request's
+ // request- AND response-derived text both carry the full transcript (the
+ // flattened description on the request side, the model's own reply on
+ // the response side) — neither may populate durable Memory. See
+ // runMemoryExtractionGate for the shared gate + extraction wiring, unit
+ // tested directly in tests/unit/video-bridge-memory-suppression.test.ts.
+ runMemoryExtractionGate({
+ memoryOwnerId,
+ memorySettings,
+ videoBridgeObserved,
+ pipelineSessionId,
+ requestBody: body as Record,
+ responseBody: memoryExtractionResponse as Record | null,
+ extractFacts,
+ log,
+ });
+
+ const customSkillExecutionEnabled =
+ Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true;
+ const builtinToolNames = [
+ webSearchFallbackPlan.toolName,
+ webFetchFallbackPlan.toolName,
+ ...(memoryOwnerId && memorySettings?.enabled ? MEMORY_BUILTIN_TOOL_NAMES : []),
+ ].filter((name): name is string => Boolean(name));
+ if (!toolLoopRan && (customSkillExecutionEnabled || builtinToolNames.length > 0)) {
+ const skillSessionId = pipelineSessionId;
+
+ translatedResponse = await handleToolCallExecution(
+ translatedResponse,
+ getSkillsModelIdForFormat(sourceFormat),
{
- normalizeToolCallId: getModelNormalizeToolCallId(provider || "", model || "", sourceFormat),
- preserveDeveloperRole: getModelPreserveOpenAIDeveloperRole(
- provider || "",
- model || "",
- sourceFormat
- ),
- preserveCacheControl,
- signatureNamespace: connectionId,
- copilotClient: copilotCompatibleReasoning,
- reasoningCacheScope,
+ apiKeyId: memoryOwnerId || "local",
+ sessionId: skillSessionId,
+ requestId: skillRequestId,
+ builtinToolNames,
+ customSkillExecutionEnabled,
+ provider,
+ model: effectiveModel,
}
);
- return runNonStreamingProviderLeg(
- followUpLegInput(
- {
- executeProviderRequest: (modelToCall, allowDedup) =>
- executeProviderRequest(modelToCall, allowDedup),
- runProviderExecution: runNonStreamingPipeline,
- setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => {
- translatedBody = nextBody as typeof translatedBody;
- currentModel = nextModel;
- triedModels.add(nextModel);
- },
- sourceFormat,
- targetFormat,
- clientResponseFormat,
- provider,
- model: effectiveModel,
- connectionId: String(getCurrentConnectionId() || connectionId || ""),
- getCurrentConnectionId: () => getCurrentConnectionId() || undefined,
- effectiveModel: currentModel,
- translatedBody: translatedBody as Record,
- toolNameMap,
- requestToolIdentityMap,
- reasoningCacheScope,
- clientHeaders: clientRawRequest?.headers ?? null,
- isClaudeCodeCompatible,
- log,
- },
- nextSourceBody,
- expectedConn
- )
- );
- },
- logReceipt: (receipt) => reqLogger.logToolLoopReceipt(receipt),
- });
- if (loopApply.kind === "error") {
- return await finalizeToolLoopError({
- loop: loopApply.loop,
+ }
+
+ const guardrailContext = buildPostCallGuardrailContext({
+ apiKeyInfo,
+ body,
+ clientRawRequest,
+ log,
model,
provider,
- connectionId,
- providerRequest: loopApply.loop.finalProviderRequest || finalBody || translatedBody,
- persistFailureUsage,
- persistAttemptLogs,
- trackPendingRequest,
+ responsePayloadFormat,
+ clientResponseFormat,
});
- }
- if (loopApply.kind === "ok") {
- toolLoopRan = true;
- toolLoopUsage = loopApply.usage;
- legResult = loopApply.leg;
- }
-
- if (legResult.upstreamResponse) {
- providerResponse = legResult.upstreamResponse;
- providerHeaders = normalizeHeaders(legResult.upstreamResponse.headers);
- } else {
- providerResponse = new Response(null, {
- status: 200,
- headers: legResult.headers,
- });
- providerHeaders = normalizeHeaders(legResult.headers);
- }
- finalBody = providerRequestCapture.body(legResult.providerRequest || translatedBody);
- const capturedOk = providerRequestCapture.latest?.();
- reqLogger.logTargetRequest(
- legResult.requestUrl || capturedOk?.url || "",
- legResult.requestHeaders || capturedOk?.headers || {},
- capturedOk?.body ?? finalBody
- );
- const responseBody = legResult.providerBody;
- const responsePayloadFormat = legResult.responsePayloadFormat;
- const looksLikeSSE = legResult.looksLikeSSE;
- let translatedResponse = legResult.response;
- const memoryExtractionResponse = legResult.responseForMemoryExtraction;
- reqLogger.logProviderResponse(
- 200,
- "OK",
- providerResponse.headers,
- looksLikeSSE
- ? { _streamed: true, _format: "sse-json", summary: responseBody }
- : responseBody
- );
- effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier;
- if (onRequestSuccess) {
- await onRequestSuccess();
- }
- const successConnectionId = getCurrentConnectionId();
- await maybeSyncClaudeExtraUsageState({
- provider,
- connectionId: successConnectionId,
- providerSpecificData: credentials?.providerSpecificData,
- log,
- });
- const usage = toolLoopUsage ?? extractUsageFromResponse(responseBody, provider);
- const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
- if (usage && typeof usage === "object") {
- attachCompressionUsageReceiptAfterAnalytics(usage as Record, "provider");
- if (provider === "gemini") {
- const promptTokens =
- typeof (usage as Record).prompt_tokens === "number"
- ? ((usage as Record).prompt_tokens as number)
- : 0;
- if (promptTokens > 0) incrementTokenUsage(model, promptTokens);
- }
- }
- recordContextEditingTelemetryHook({
- contextEditingEnabled,
- provider,
- responseBody,
- skillRequestId,
- log,
- });
- appendRequestLog({
- model,
- provider,
- connectionId: successConnectionId,
- tokens: usage,
- status: "200 OK",
- }).catch(() => {});
- recordNonStreamingUsageStats(usage, {
- traceEnabled,
- provider,
- connectionId: successConnectionId,
- model,
- startTime,
- apiKeyInfo,
- effectiveServiceTier,
- isCombo,
- comboStrategy,
- endpoint: endpointPath,
- });
-
- // #12150 P1b surface 3 (fix round 1): a video-bridge-observed request's
- // request- AND response-derived text both carry the full transcript (the
- // flattened description on the request side, the model's own reply on
- // the response side) — neither may populate durable Memory. See
- // runMemoryExtractionGate for the shared gate + extraction wiring, unit
- // tested directly in tests/unit/video-bridge-memory-suppression.test.ts.
- runMemoryExtractionGate({
- memoryOwnerId,
- memorySettings,
- videoBridgeObserved,
- pipelineSessionId,
- requestBody: body as Record,
- responseBody: memoryExtractionResponse as Record | null,
- extractFacts,
- log,
- });
-
- const customSkillExecutionEnabled =
- Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true;
- const builtinToolNames = [
- webSearchFallbackPlan.toolName,
- webFetchFallbackPlan.toolName,
- ...(memoryOwnerId && memorySettings?.enabled ? MEMORY_BUILTIN_TOOL_NAMES : []),
- ].filter((name): name is string => Boolean(name));
- if (!toolLoopRan && (customSkillExecutionEnabled || builtinToolNames.length > 0)) {
- const skillSessionId = pipelineSessionId;
-
- translatedResponse = await handleToolCallExecution(
+ const postCallGuardrails = await guardrailRegistry.runPostCallHooks(
translatedResponse,
- getSkillsModelIdForFormat(sourceFormat),
- {
- apiKeyId: memoryOwnerId || "local",
- sessionId: skillSessionId,
- requestId: skillRequestId,
- builtinToolNames,
- customSkillExecutionEnabled,
- provider,
- model: effectiveModel,
- }
+ guardrailContext
);
- }
+ translatedResponse = postCallGuardrails.response;
- const guardrailContext = buildPostCallGuardrailContext({
- apiKeyInfo,
- body,
- clientRawRequest,
- log,
- model,
- provider,
- responsePayloadFormat,
- clientResponseFormat,
- });
- const postCallGuardrails = await guardrailRegistry.runPostCallHooks(
- translatedResponse,
- guardrailContext
- );
- translatedResponse = postCallGuardrails.response;
+ const responseUsage = isJsonRecord(usage)
+ ? usage
+ : isJsonRecord(translatedResponse.usage)
+ ? translatedResponse.usage
+ : null;
+ const costUsage = normalizeUsage(responseUsage);
+ const estimatedCost = costUsage
+ ? await calculateCost(provider, model, costUsage, { serviceTier: effectiveServiceTier })
+ : 0;
- const responseUsage = isJsonRecord(usage)
- ? usage
- : isJsonRecord(translatedResponse.usage)
- ? translatedResponse.usage
- : null;
- const costUsage = normalizeUsage(responseUsage);
- const estimatedCost = costUsage
- ? await calculateCost(provider, model, costUsage, { serviceTier: effectiveServiceTier })
- : 0;
+ if (postCallGuardrails.blocked) {
+ const guardrailMessage = postCallGuardrails.message || "Response blocked by guardrail";
+ persistAttemptLogs({
+ status: HTTP_STATUS.BAD_REQUEST,
+ tokens: usage,
+ responseBody,
+ providerRequest: finalBody || translatedBody,
+ providerResponse: looksLikeSSE
+ ? {
+ _streamed: true,
+ _format: "sse-json",
+ summary: responseBody,
+ }
+ : responseBody,
+ clientResponse: buildErrorBody(HTTP_STATUS.BAD_REQUEST, guardrailMessage),
+ claudeCacheMeta: claudePromptCacheLogMeta,
+ claudeCacheUsageMeta: cacheUsageLogMeta,
+ cacheSource: "upstream",
+ });
+ if (apiKeyInfo?.id && estimatedCost > 0) {
+ recordCost(apiKeyInfo.id, estimatedCost);
+ }
+ log?.warn?.(
+ "GUARDRAIL",
+ `Response blocked by ${postCallGuardrails.guardrail || "guardrail"}: ${guardrailMessage}`
+ );
+ finalizePendingScope(pendingScope, {
+ providerResponse: responseBody,
+ clientResponse: translatedResponse,
+ });
+ return createErrorResult(HTTP_STATUS.BAD_REQUEST, guardrailMessage);
+ }
- if (postCallGuardrails.blocked) {
- const guardrailMessage = postCallGuardrails.message || "Response blocked by guardrail";
+ // Validate the *translated* response actually carries client-usable output.
+ // isEmptyContentResponse (above) runs on the raw responseBody before translation;
+ // this check runs after translation + sanitization + tool-call execution to catch
+ // cases where a provider returns a structurally valid raw body that translates into
+ // choices:[] or output:[] with no usable content (Responses API shape included).
+ const malformedTranslatedReason = detectMalformedNonStream(translatedResponse);
+ if (malformedTranslatedReason) {
+ const totalLatency = Date.now() - startTime;
+ const rawBytes = (() => {
+ try {
+ return JSON.stringify(responseBody || {}).length;
+ } catch {
+ return -1;
+ }
+ })();
+ reportMalformed200({
+ mode: "nonstream",
+ provider,
+ model,
+ connectionId,
+ reason: malformedTranslatedReason,
+ recvBytes: rawBytes,
+ recvLines: -1,
+ emitted: -1,
+ events: {},
+ ttftMs: totalLatency,
+ elapsedMs: totalLatency,
+ });
+ appendRequestLog({
+ model,
+ provider,
+ connectionId,
+ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
+ }).catch(() => {});
+ const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason);
+ const malformedMessage = `[${provider}/${model}] ${malformed.message}`;
+ const malformedClientBody = buildErrorBody(
+ HTTP_STATUS.BAD_GATEWAY,
+ malformedMessage,
+ undefined,
+ { code: malformed.code, type: malformed.type }
+ );
+ persistAttemptLogs({
+ status: HTTP_STATUS.BAD_GATEWAY,
+ tokens: usage,
+ responseBody,
+ providerRequest: finalBody || translatedBody,
+ providerResponse: looksLikeSSE
+ ? { _streamed: true, _format: "sse-json", summary: responseBody }
+ : responseBody,
+ clientResponse: malformedClientBody,
+ claudeCacheMeta: claudePromptCacheLogMeta,
+ claudeCacheUsageMeta: cacheUsageLogMeta,
+ cacheSource: "upstream",
+ });
+ persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response");
+ trackPendingRequest(model, provider, pendingConnId, false);
+ // Routing event (feedback foundation) — record the malformed outcome so
+ // the quality tracker de-prioritizes this model over time.
+ void emitRoutingEvent(
+ createRoutingEvent({
+ requestId: traceId || pendingRequestId || "unknown",
+ provider: provider || "unknown",
+ model: model || "unknown",
+ strategy: isCombo ? (comboStrategy ?? "combo") : "direct",
+ latencyMs: Date.now() - startTime,
+ ttftMs: null,
+ inputTokens: null,
+ outputTokens: null,
+ cost: null,
+ retries: 0,
+ fallbackUsed: false, // combo-level fallback tracked by decisionTrace
+ outcome: "malformed",
+ status: HTTP_STATUS.BAD_GATEWAY,
+ finishReason: routingFinishReason(translatedResponse),
+ connectionId: credentials?.connectionId ?? null,
+ })
+ );
+ return createErrorResult(
+ HTTP_STATUS.BAD_GATEWAY,
+ malformedMessage,
+ null,
+ malformed.code,
+ malformed.type
+ );
+ }
+
+ // ── Phase 9.1: Cache store (non-streaming, temp=0) ──
+ storeSemanticCacheResponse({
+ enabled: semanticCacheEnabled,
+ body: bodyForCacheWrite,
+ headers: clientRawRequest?.headers,
+ translatedResponse,
+ model,
+ apiKeyId: apiKeyInfo?.id ?? undefined,
+ usage,
+ log,
+ });
+
+ // ── Phase 9.2: Save for idempotency ──
+ // Reuse the key resolved by checkIdempotencyCache() above (single derivation per
+ // request). (#3821-review LEDGER-6)
+ saveIdempotency(idempotencyKey, translatedResponse, 200);
+ reqLogger.logConvertedResponse(translatedResponse);
persistAttemptLogs({
- status: HTTP_STATUS.BAD_REQUEST,
+ status: 200,
tokens: usage,
responseBody,
providerRequest: finalBody || translatedBody,
@@ -5197,7 +5390,7 @@ export async function handleChatCore({
summary: responseBody,
}
: responseBody,
- clientResponse: buildErrorBody(HTTP_STATUS.BAD_REQUEST, guardrailMessage),
+ clientResponse: translatedResponse,
claudeCacheMeta: claudePromptCacheLogMeta,
claudeCacheUsageMeta: cacheUsageLogMeta,
cacheSource: "upstream",
@@ -5205,76 +5398,61 @@ export async function handleChatCore({
if (apiKeyInfo?.id && estimatedCost > 0) {
recordCost(apiKeyInfo.id, estimatedCost);
}
- log?.warn?.(
- "GUARDRAIL",
- `Response blocked by ${postCallGuardrails.guardrail || "guardrail"}: ${guardrailMessage}`
- );
+
+ // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open ===
+ await scheduleQuotaShareConsumption({
+ apiKeyId: apiKeyInfo?.id,
+ connectionId: credentials?.connectionId,
+ provider,
+ model,
+ usage,
+ estimatedCost,
+ log,
+ });
+ // === /Quota Share POST-hook ===
+
+ // ── Gamification event (fire-and-forget) ──
+ await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider });
+
finalizePendingScope(pendingScope, {
providerResponse: responseBody,
clientResponse: translatedResponse,
});
- return createErrorResult(HTTP_STATUS.BAD_REQUEST, guardrailMessage);
- }
+ const responseHeaders = buildNonStreamingResponseHeaders({
+ provider,
+ model,
+ startTime,
+ responseUsage,
+ estimatedCost,
+ requestId: skillRequestId,
+ compressionResponseMeta,
+ comboStrategy,
+ });
+ // #6426: align response body `model` with the `X-OmniRoute-Model` header
+ // (both must be the resolved backend model). Some upstreams (notably legacy
+ // /v1/completions text-completion path) return a body `model` field that
+ // differs from the resolved backend id we advertised in the header, leaving
+ // strict clients unable to reconcile the two. Rewrite body.model to `model`
+ // FIRST, then let #1311 echo override it when the opt-in setting is on.
+ if (typeof model === "string" && model) echoModelInObject(translatedResponse, model);
+ // #1311: echo the requested alias/combo name in the non-streaming response model.
+ if (echoModel) echoModelInObject(translatedResponse, echoModel);
- // Validate the *translated* response actually carries client-usable output.
- // isEmptyContentResponse (above) runs on the raw responseBody before translation;
- // this check runs after translation + sanitization + tool-call execution to catch
- // cases where a provider returns a structurally valid raw body that translates into
- // choices:[] or output:[] with no usable content (Responses API shape included).
- const malformedTranslatedReason = detectMalformedNonStream(translatedResponse);
- if (malformedTranslatedReason) {
- const totalLatency = Date.now() - startTime;
- const rawBytes = (() => {
- try {
- return JSON.stringify(responseBody || {}).length;
- } catch {
- return -1;
- }
- })();
- reportMalformed200({
- mode: "nonstream",
- provider,
- model,
- connectionId,
- reason: malformedTranslatedReason,
- recvBytes: rawBytes,
- recvLines: -1,
- emitted: -1,
- events: {},
- ttftMs: totalLatency,
- elapsedMs: totalLatency,
- });
- appendRequestLog({
+ // ── Plugin onResponse hook (fire-and-forget) ──
+ // #8395: the streaming branch below already calls this; the non-streaming
+ // (stream:false) branch returned without it, so onResponse never fired for
+ // non-streaming requests at all.
+ await runPluginOnResponseHook({
+ requestId: traceId,
+ body,
model,
provider,
- connectionId,
- status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
- }).catch(() => {});
- const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason);
- const malformedMessage = `[${provider}/${model}] ${malformed.message}`;
- const malformedClientBody = buildErrorBody(
- HTTP_STATUS.BAD_GATEWAY,
- malformedMessage,
- undefined,
- { code: malformed.code, type: malformed.type }
- );
- persistAttemptLogs({
- status: HTTP_STATUS.BAD_GATEWAY,
- tokens: usage,
- responseBody,
- providerRequest: finalBody || translatedBody,
- providerResponse: looksLikeSSE
- ? { _streamed: true, _format: "sse-json", summary: responseBody }
- : responseBody,
- clientResponse: malformedClientBody,
- claudeCacheMeta: claudePromptCacheLogMeta,
- claudeCacheUsageMeta: cacheUsageLogMeta,
- cacheSource: "upstream",
+ apiKeyInfo,
+ headers: clientRawRequest?.headers,
+ response: { status: 200, data: translatedResponse },
});
- persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response");
- trackPendingRequest(model, provider, pendingConnId, false);
- // Routing event (feedback foundation) — record the malformed outcome so
- // the quality tracker de-prioritizes this model over time.
+
+ // Routing event (feedback foundation) — fire-and-forget, cheap.
void emitRoutingEvent(
createRoutingEvent({
requestId: traceId || pendingRequestId || "unknown",
@@ -5283,158 +5461,38 @@ export async function handleChatCore({
strategy: isCombo ? (comboStrategy ?? "combo") : "direct",
latencyMs: Date.now() - startTime,
ttftMs: null,
- inputTokens: null,
- outputTokens: null,
- cost: null,
+ inputTokens:
+ usage && typeof usage === "object"
+ ? (() => {
+ const promptTokens = (usage as Record).prompt_tokens;
+ return typeof promptTokens === "number" && Number.isFinite(promptTokens)
+ ? promptTokens
+ : null;
+ })()
+ : null,
+ outputTokens:
+ usage && typeof usage === "object"
+ ? (() => {
+ const completionTokens = (usage as Record).completion_tokens;
+ return typeof completionTokens === "number" && Number.isFinite(completionTokens)
+ ? completionTokens
+ : null;
+ })()
+ : null,
+ cost: Number.isFinite(estimatedCost) ? estimatedCost : null,
retries: 0,
fallbackUsed: false, // combo-level fallback tracked by decisionTrace
- outcome: "malformed",
- status: HTTP_STATUS.BAD_GATEWAY,
+ outcome: "success",
+ status: 200,
finishReason: routingFinishReason(translatedResponse),
connectionId: credentials?.connectionId ?? null,
})
);
- return createErrorResult(
- HTTP_STATUS.BAD_GATEWAY,
- malformedMessage,
- null,
- malformed.code,
- malformed.type
- );
- }
- // ── Phase 9.1: Cache store (non-streaming, temp=0) ──
- storeSemanticCacheResponse({
- enabled: semanticCacheEnabled,
- body: bodyForCacheWrite,
- headers: clientRawRequest?.headers,
- translatedResponse,
- model,
- apiKeyId: apiKeyInfo?.id ?? undefined,
- usage,
- log,
- });
-
- // ── Phase 9.2: Save for idempotency ──
- // Reuse the key resolved by checkIdempotencyCache() above (single derivation per
- // request). (#3821-review LEDGER-6)
- saveIdempotency(idempotencyKey, translatedResponse, 200);
- reqLogger.logConvertedResponse(translatedResponse);
- persistAttemptLogs({
- status: 200,
- tokens: usage,
- responseBody,
- providerRequest: finalBody || translatedBody,
- providerResponse: looksLikeSSE
- ? {
- _streamed: true,
- _format: "sse-json",
- summary: responseBody,
- }
- : responseBody,
- clientResponse: translatedResponse,
- claudeCacheMeta: claudePromptCacheLogMeta,
- claudeCacheUsageMeta: cacheUsageLogMeta,
- cacheSource: "upstream",
- });
- if (apiKeyInfo?.id && estimatedCost > 0) {
- recordCost(apiKeyInfo.id, estimatedCost);
- }
-
- // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open ===
- await scheduleQuotaShareConsumption({
- apiKeyId: apiKeyInfo?.id,
- connectionId: credentials?.connectionId,
- provider,
- model,
- usage,
- estimatedCost,
- log,
- });
- // === /Quota Share POST-hook ===
-
- // ── Gamification event (fire-and-forget) ──
- await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider });
-
- finalizePendingScope(pendingScope, {
- providerResponse: responseBody,
- clientResponse: translatedResponse,
- });
- const responseHeaders = buildNonStreamingResponseHeaders({
- provider,
- model,
- startTime,
- responseUsage,
- estimatedCost,
- requestId: skillRequestId,
- compressionResponseMeta,
- comboStrategy,
- });
- // #6426: align response body `model` with the `X-OmniRoute-Model` header
- // (both must be the resolved backend model). Some upstreams (notably legacy
- // /v1/completions text-completion path) return a body `model` field that
- // differs from the resolved backend id we advertised in the header, leaving
- // strict clients unable to reconcile the two. Rewrite body.model to `model`
- // FIRST, then let #1311 echo override it when the opt-in setting is on.
- if (typeof model === "string" && model) echoModelInObject(translatedResponse, model);
- // #1311: echo the requested alias/combo name in the non-streaming response model.
- if (echoModel) echoModelInObject(translatedResponse, echoModel);
-
- // ── Plugin onResponse hook (fire-and-forget) ──
- // #8395: the streaming branch below already calls this; the non-streaming
- // (stream:false) branch returned without it, so onResponse never fired for
- // non-streaming requests at all.
- await runPluginOnResponseHook({
- requestId: traceId,
- body,
- model,
- provider,
- apiKeyInfo,
- headers: clientRawRequest?.headers,
- response: { status: 200, data: translatedResponse },
- });
-
- // Routing event (feedback foundation) — fire-and-forget, cheap.
- void emitRoutingEvent(
- createRoutingEvent({
- requestId: traceId || pendingRequestId || "unknown",
- provider: provider || "unknown",
- model: model || "unknown",
- strategy: isCombo ? (comboStrategy ?? "combo") : "direct",
- latencyMs: Date.now() - startTime,
- ttftMs: null,
- inputTokens:
- usage && typeof usage === "object"
- ? (() => {
- const promptTokens = (usage as Record).prompt_tokens;
- return typeof promptTokens === "number" && Number.isFinite(promptTokens)
- ? promptTokens
- : null;
- })()
- : null,
- outputTokens:
- usage && typeof usage === "object"
- ? (() => {
- const completionTokens = (usage as Record).completion_tokens;
- return typeof completionTokens === "number" && Number.isFinite(completionTokens)
- ? completionTokens
- : null;
- })()
- : null,
- cost: Number.isFinite(estimatedCost) ? estimatedCost : null,
- retries: 0,
- fallbackUsed: false, // combo-level fallback tracked by decisionTrace
- outcome: "success",
- status: 200,
- finishReason: routingFinishReason(translatedResponse),
- connectionId: credentials?.connectionId ?? null,
- })
- );
-
- return {
- success: true,
- response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders),
- };
+ return {
+ success: true,
+ response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders),
+ };
} catch (error) {
trackPendingRequest(model, provider, connectionId, false);
if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error);
diff --git a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts
index 0e8f2e5969..7656939533 100644
--- a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts
+++ b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts
@@ -23,6 +23,7 @@ import { restoreNonStreamingToolNames } from "./passthroughToolNames.ts";
import { extractUsageFromResponse } from "../usageExtractor.ts";
import { sanitizeUsagePayloadForRequest } from "../../utils/usageTracking.ts";
import { createErrorResult, formatProviderError } from "../../utils/error.ts";
+import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker";
import { unwrapClinepassEnvelope } from "../../utils/clinepassEnvelope.ts";
import { unwrapClineNonStreamingEnvelope } from "./clineResponseEnvelope.ts";
import {
@@ -232,7 +233,6 @@ function parseRetryAfterMs(response: Response): number | null {
return null;
}
-
function finishOk(
input: ProviderLegInput,
params: {
@@ -462,14 +462,22 @@ export async function runNonStreamingProviderLeg(
) {
throw error;
}
- const failureStatus =
- error instanceof Error && error.name === "AbortError"
- ? 499
- : error instanceof Error && error.name === "TimeoutError"
- ? 504
- : 502;
- const failureMessage =
- error instanceof Error
+ // `abort(reason)` can reject with a raw string that has no `name`/`status`, so
+ // `error.name === "AbortError"` is too narrow — that shape fell through to the 502
+ // provider-failure default (#7907). chatCore classified this through
+ // isLocalStreamLifecycleError before this leg took over the first send; mirror it.
+ const isRequestAborted = isLocalStreamLifecycleError(error);
+ const failureStatus = isRequestAborted
+ ? 499
+ : error instanceof Error && error.name === "TimeoutError"
+ ? 504
+ : 502;
+ // A client abort is not a provider failure: formatProviderError would stamp the raw
+ // upstream text as `[499]: `, leaking it to the client. chatCore has always
+ // normalized this to the fixed "Request aborted".
+ const failureMessage = isRequestAborted
+ ? "Request aborted"
+ : error instanceof Error
? formatProviderError(error, provider, currentModel, failureStatus)
: "Provider request failed";
const receipt = buildReceipt(input, {
diff --git a/open-sse/lib/deepseek-pow.ts b/open-sse/lib/deepseek-pow.ts
index d70998276b..3d373dcb13 100644
--- a/open-sse/lib/deepseek-pow.ts
+++ b/open-sse/lib/deepseek-pow.ts
@@ -106,7 +106,18 @@ function solveInWorker(
activeWorkerCount += 1;
return new Promise((resolve, reject) => {
- const worker = new Worker(resolveWorkerPath(), { workerData: validated });
+ // The slot is taken before this executor runs, so anything that throws here
+ // -- a missing worker script, a spawn failure -- has to hand it back. Without
+ // this, MAX_CONCURRENT_WORKERS spawn failures wedge the solver permanently
+ // and every later call reports "capacity reached" instead of the real cause.
+ let worker: Worker;
+ try {
+ worker = new Worker(resolveWorkerPath(), { workerData: validated });
+ } catch (error) {
+ activeWorkerCount = Math.max(0, activeWorkerCount - 1);
+ reject(error instanceof Error ? error : new Error(String(error)));
+ return;
+ }
let settled = false;
const cleanup = () => {
diff --git a/open-sse/services/codexAccount/quota.ts b/open-sse/services/codexAccount/quota.ts
index 3317ebd5be..80b15c8f0f 100644
--- a/open-sse/services/codexAccount/quota.ts
+++ b/open-sse/services/codexAccount/quota.ts
@@ -56,7 +56,9 @@ export async function persistCodexChildQuotaResponse(params: {
rateLimitedUntil,
rateLimitSource: exhaustedWindow ? ("quota_reset" as const) : ("fallback" as const),
}
- : {}),
+ : params.status === 200
+ ? { rateLimitedUntil: null }
+ : {}),
});
if (!providerSpecificData) return null;
diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts
index ac89aae30a..ab5a87a693 100644
--- a/open-sse/services/genericQuotaFetcher.ts
+++ b/open-sse/services/genericQuotaFetcher.ts
@@ -330,6 +330,19 @@ function antigravityWeeklyWindowMatchesFamily(
return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly";
}
+const TIME_WINDOW_KEYS = new Set([
+ "session",
+ "weekly",
+ "daily",
+ "monthly",
+ "session (5h)",
+ "weekly (7d)",
+ "AFPFiveHour",
+ "AFPWeekly",
+ "AFPDaily",
+ "AFPMonthly",
+]);
+
function normalizeQuotaWindows(
windows: Record,
context: UsageToQuotaContext
@@ -340,12 +353,14 @@ function normalizeQuotaWindows(
? getAntigravityQuotaFamily(context.requestedModel)
: null;
- // Claude-style explicit time windows.
- if (windows["session (5h)"] && !normalized.window5h) {
- normalized.window5h = windows["session (5h)"];
+ // Explicit time windows (canonical and legacy aliases).
+ const fiveHourWindow = windows["session (5h)"] || windows["session"];
+ if (fiveHourWindow && !normalized.window5h) {
+ normalized.window5h = fiveHourWindow;
}
- if (windows["weekly (7d)"] && !normalized.window7d) {
- normalized.window7d = windows["weekly (7d)"];
+ const sevenDayWindow = windows["weekly (7d)"] || windows["weekly"];
+ if (sevenDayWindow && !normalized.window7d) {
+ normalized.window7d = sevenDayWindow;
}
// Antigravity-style per-model windows: pick worst only inside requested family.
@@ -356,6 +371,7 @@ function normalizeQuotaWindows(
!key.startsWith("window") &&
!key.includes("(5h)") &&
!key.includes("(7d)") &&
+ !TIME_WINDOW_KEYS.has(key) &&
(requestedFamily === null ||
requestedFamily === "other" ||
getAntigravityQuotaFamily(key) === requestedFamily)
diff --git a/open-sse/services/usage/volcenginePlan.ts b/open-sse/services/usage/volcenginePlan.ts
index 64bc4a4b99..442176ca2b 100644
--- a/open-sse/services/usage/volcenginePlan.ts
+++ b/open-sse/services/usage/volcenginePlan.ts
@@ -101,9 +101,16 @@ function tsToIso(seconds: number): string | null {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
+const CODING_LEVEL_TO_CANONICAL: Record = {
+ session: "session (5h)",
+ weekly: "weekly (7d)",
+ daily: "daily",
+ monthly: "monthly",
+};
+
const CODING_WINDOW_LABEL: Record = {
- session: "Session (5h)",
- weekly: "Weekly",
+ "session (5h)": "Session (5h)",
+ "weekly (7d)": "Weekly",
monthly: "Monthly",
daily: "Daily",
};
@@ -119,27 +126,29 @@ function mapCodingPlanUsage(result: JsonRecord): Record {
const w = toRecord(raw);
const level = String(w.Level || "").toLowerCase();
if (!level) continue;
- const cap = toNumber(w.Cap, 100) || 100;
+ const canonicalKey = CODING_LEVEL_TO_CANONICAL[level] || level;
+ const rawCap = toNumber(w.Cap, 100);
+ const cap = rawCap > 0 ? rawCap : 100;
const usedPercent = toNumber(w.Percent, 0);
const remainingPercentage = Math.max(0, Math.min(100, cap - usedPercent));
- quotas[level] = {
+ quotas[canonicalKey] = {
used: usedPercent,
total: cap,
remaining: Math.max(0, cap - usedPercent),
remainingPercentage,
resetAt: tsToIso(toNumber(w.ResetTimestamp, 0)),
unlimited: false,
- displayName: CODING_WINDOW_LABEL[level] || level,
+ displayName: CODING_WINDOW_LABEL[canonicalKey] || level,
};
}
return quotas;
}
-const AGENT_WINDOW_LABEL: Array<[string, string]> = [
- ["AFPFiveHour", "Session (5h)"],
- ["AFPDaily", "Daily"],
- ["AFPWeekly", "Weekly"],
- ["AFPMonthly", "Monthly"],
+const AGENT_WINDOW_MAPPING: Array<[string, string, string]> = [
+ ["AFPFiveHour", "session (5h)", "Session (5h)"],
+ ["AFPDaily", "daily", "Daily"],
+ ["AFPWeekly", "weekly (7d)", "Weekly"],
+ ["AFPMonthly", "monthly", "Monthly"],
];
/**
@@ -148,16 +157,16 @@ const AGENT_WINDOW_LABEL: Array<[string, string]> = [
*/
function mapAgentPlanUsage(result: JsonRecord): Record {
const quotas: Record = {};
- for (const [key, label] of AGENT_WINDOW_LABEL) {
- const w = toRecord(result[key]);
+ for (const [sourceKey, canonicalKey, label] of AGENT_WINDOW_MAPPING) {
+ const w = toRecord(result[sourceKey]);
if (Object.keys(w).length === 0) continue;
const total = toNumber(w.Quota, 0);
const used = toNumber(w.Used, 0);
const remaining = Math.max(0, total - used);
const remainingPercentage =
- total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 100;
+ total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
const resetMs = toNumber(w.ResetTime, 0);
- quotas[key] = {
+ quotas[canonicalKey] = {
used,
total,
remaining,
@@ -315,3 +324,8 @@ export async function getVolcenginePlanUsage(
};
}
}
+
+export const __testing = {
+ mapCodingPlanUsage,
+ mapAgentPlanUsage,
+};
diff --git a/open-sse/utils/credentialPatterns.ts b/open-sse/utils/credentialPatterns.ts
index 02784a4ae5..b9a2366d70 100644
--- a/open-sse/utils/credentialPatterns.ts
+++ b/open-sse/utils/credentialPatterns.ts
@@ -18,7 +18,13 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
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]" },
+ // {20,} rather than the exact {35} of a standard 39-char Google API key. #12506 added
+ // this pattern with the exact length; #12620 landed the anti-drift test that asserts
+ // /\bAIza[A-Za-z0-9_-]{20,}/ must not survive. Anything shorter or longer than 39 was
+ // therefore passing straight through into error bodies. In an error message
+ // over-redacting a string that merely starts with AIza costs nothing; under-redacting
+ // one leaks a credential, so the loose bound is the correct side to err on.
+ { name: "google", regex: /AIza[0-9A-Za-z_-]{20,}/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]" },
diff --git a/package.json b/package.json
index f984ef4314..5cbcf71b42 100644
--- a/package.json
+++ b/package.json
@@ -277,6 +277,7 @@
"postbuild": "node scripts/build/colocate-standalone.mjs",
"release:contributors": "node scripts/release/gen-contributors.mjs",
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
+ "release:reconcile": "node scripts/release/reconcile-changelog.mjs",
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"",
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs"
diff --git a/scripts/release/aggregate-changelog.mjs b/scripts/release/aggregate-changelog.mjs
index 7d490045f6..efc65d0ba3 100644
--- a/scripts/release/aggregate-changelog.mjs
+++ b/scripts/release/aggregate-changelog.mjs
@@ -44,6 +44,15 @@ export const SECTIONS = Object.freeze({
const SKIP_FILES = new Set(["README.md", ".gitkeep"]);
+/** The living cycle version = package.json `version` (null when unreadable → legacy first-heading mode). */
+export function readVersion(root = ROOT) {
+ try {
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Validate one fragment's text. Returns null when OK, or a human-readable error.
* Pure — unit-tested.
@@ -89,17 +98,32 @@ export function collectFragments(root) {
/**
* Append bullets at the END of a living-section heading's bullet block (before the
- * next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this
- * repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested.
- * Throws when a needed heading is missing (the release captain adds the heading; the
- * script never invents structure).
+ * next "##"/"###" heading). When `version` is given the heading is searched INSIDE the
+ * `## [version]` block only — `[Unreleased]` still carries a `### ✨ New Features`
+ * heading, so the first occurrence in the file is the wrong one (v3.8.51: every feature
+ * fragment was landing under `[Unreleased]`, #12971). Without `version` the FIRST
+ * occurrence is used (legacy behaviour). Pure — unit-tested. Throws when a needed heading
+ * is missing (the release captain adds the heading; the script never invents structure).
*/
-export function insertBullets(changelogText, bulletsBySection) {
+export function insertBullets(changelogText, bulletsBySection, version = null) {
let lines = changelogText.split("\n");
for (const [section, heading] of Object.entries(SECTIONS)) {
const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b);
if (bullets.length === 0) continue;
- const headIdx = lines.findIndex((l) => l.trim() === heading);
+ let from = 0;
+ let to = lines.length;
+ if (version) {
+ from = lines.findIndex((l) => l.startsWith(`## [${version}]`));
+ if (from === -1) {
+ throw new Error(
+ `section "## [${version}]" not found in CHANGELOG.md — fragments must land in the living version section`
+ );
+ }
+ to = lines.findIndex((l, i) => i > from && l.startsWith("## ["));
+ if (to === -1) to = lines.length;
+ }
+ const rel = lines.slice(from, to).findIndex((l) => l.trim() === heading);
+ const headIdx = rel === -1 ? -1 : from + rel;
if (headIdx === -1) {
throw new Error(
`heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments`
@@ -125,7 +149,7 @@ export function insertBullets(changelogText, bulletsBySection) {
* Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is
* true nothing is written or deleted.
*/
-export function aggregate({ root = ROOT, dryRun = false } = {}) {
+export function aggregate({ root = ROOT, dryRun = false, version = readVersion(root) } = {}) {
const collected = collectFragments(root);
if (collected.invalid.length > 0) {
const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n");
@@ -134,7 +158,7 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) {
const total = collected.features.length + collected.fixes.length + collected.maintenance.length;
const changelogPath = join(root, "CHANGELOG.md");
const before = readFileSync(changelogPath, "utf8");
- const after = total === 0 ? before : insertBullets(before, collected);
+ const after = total === 0 ? before : insertBullets(before, collected, version);
if (!dryRun && total > 0) {
writeFileSync(changelogPath, after);
for (const section of Object.keys(SECTIONS)) {
diff --git a/scripts/release/reconcile-changelog.mjs b/scripts/release/reconcile-changelog.mjs
new file mode 100644
index 0000000000..a6e2d28739
--- /dev/null
+++ b/scripts/release/reconcile-changelog.mjs
@@ -0,0 +1,831 @@
+#!/usr/bin/env node
+// scripts/release/reconcile-changelog.mjs
+//
+// Reconcile the living `## []` CHANGELOG section against the FULL development cycle
+// (Phase 0a.1–0a.3a of /generate-release), so that at the moment a release is cut:
+// • every cycle commit is represented by a bullet whose PRIMARY reference is that commit's PR,
+// • every bullet carries the merged PR link and `— thanks @author` (Hard Rule #16),
+// • fragments are folded in under the RIGHT version section (not the first heading that
+// matches — `[Unreleased]` still carries a `### ✨ New Features` heading),
+// • fragments that duplicate bullets already shipped in a previous version are dropped,
+// • the section opens with "📊 Release by the numbers" + "🏆 Top 25 contributors" (v3.8.50 format).
+//
+// It never touches bullets that already exist in the section (the changelog-integrity gate
+// compares bullet lines against the base), never touches `[Unreleased]`, and never edits any
+// other version section. Run `npm run release:contributors -- --inject` afterwards to
+// (re)build the `### 🙌 Contributors` table, then `release:sync-changelog-i18n`.
+//
+// Lessons baked in (v3.8.51 reconciliation, 2026-09-07 — PR #12971):
+// • prefix of a fragment filename is NOT a reliable PR number (issue numbers, closed/recreated
+// PRs, literal `#PR_NUMBER`); the commit that ADDED the fragment (`git log --diff-filter=A`)
+// is the definitive origin — unless that commit is a "carrier" PR that only back-filled
+// fragments for other people's PRs (`--carrier N`), then the prefix wins;
+// • a commit is covered only when its OWN PR is a primary ref (a `/pull/N` link or the last
+// `#N` on the bullet line) — an incidental mention ("Opper #11629 + 1min.ai #11631") must not
+// hide a PR's own bullet;
+// • gen-contributors only reads lines that start with "- ", so fragment bullets are collapsed
+// to a single line (pre-existing section bullets are left verbatim).
+//
+// Usage:
+// node scripts/release/reconcile-changelog.mjs [--version 3.8.51] [--base [] [--head ][]
+// [--release-branch release/v3.8.51] [--credit 12255=backryun] [--carrier 11938]
+// [--drop-fragment changelog.d/fixes/x.md] [--fragment-pr changelog.d/fixes/y.md=11845]
+// [--prs ]] [--dry-run] [--report ]
+//
+// Defaults: version = package.json; release branch = release/v; head = HEAD;
+// base = parent of the commit that opened the cycle (first commit carrying the version string,
+// see resolveCycleBase in list-uncovered-commits.mjs) — pass `--base origin/release/v` to
+// use the previous release tip explicitly. Exit 0 always (advisory: the captain reviews the diff).
+
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { resolveCycleBase } from "./list-uncovered-commits.mjs";
+
+export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
+export const MAINTAINER = "diegosouzapw";
+export const SECTION_HEADINGS = Object.freeze({
+ features: "### ✨ New Features",
+ fixes: "### 🐛 Bug Fixes",
+ maintenance: "### 📝 Maintenance",
+});
+const TYPE_LABEL = {
+ fix: "🐛 Fixes",
+ feat: "✨ Features",
+ docs: "📚 Docs",
+ chore: "🧹 Chore",
+ test: "🧪 Tests",
+ refactor: "♻️ Refactor",
+ perf: "⚡ Performance",
+ security: "🔒 Security",
+ ci: "⚙️ CI",
+ deps: "📦 Dependencies",
+ build: "🏗️ Build",
+ revert: "⏪ Reverts",
+ other: "🔀 Other",
+};
+const BOT_RE = /dependabot|\[bot\]|^app\//i;
+
+// ───────────────────────────── pure helpers (unit-tested) ─────────────────────────────
+
+/** `[#N](url)` → `#N`, `[@h](url)` → `@h` so refs/handles can be scanned uniformly. */
+export const normalizeLinks = (s) =>
+ s.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1").replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1");
+export const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
+export const prLink = (repo, n) => `[#${n}](https://github.com/${repo}/pull/${n})`;
+
+/**
+ * Primary refs of a bullet: every `/pull/N` link, the LAST `#N` on its first line (the
+ * conventional trailing `(#N)` back-reference) and every explicit `(#N …)` group — a group
+ * that OPENS with the ref, e.g. `(#11436)` or `(#11436 — thanks @x)`. An incidental mention
+ * inside prose (`(Opper #11629 + 1min.ai #11631)`) does not count.
+ */
+export function primaryRefs(bullet) {
+ const flat = normalizeLinks(bullet.replace(/\n\s+/g, " "));
+ const out = new Set([...bullet.matchAll(/\/pull\/(\d+)\)/g)].map((m) => Number(m[1])));
+ const refs = refsIn(flat);
+ if (refs.length) out.add(refs[refs.length - 1]);
+ for (const m of flat.matchAll(/\(#(\d+)(?=[\s,)—-])/g)) out.add(Number(m[1]));
+ return out;
+}
+
+/** Commit hashes a bullet documents explicitly as `(direct commit \`\`)`. */
+export const directHashes = (bullet) =>
+ [...bullet.matchAll(/direct commit `([0-9a-f]{7,40})`/g)].map((m) => m[1]);
+
+/**
+ * Split a markdown text into bullet blocks per section heading. A block is a `- ` line plus its
+ * indented continuation lines. `fixedSection` forces every bullet into one section (fragments).
+ * `collapse` joins continuation lines into the first line (fragments only — never pre-existing
+ * section bullets, which the changelog-integrity gate compares line by line).
+ */
+export function parseBlocks(text, { fixedSection = null, collapse = false } = {}) {
+ const out = { features: [], fixes: [], maintenance: [] };
+ let cur = fixedSection;
+ let block = null;
+ const flush = () => {
+ if (block && cur) {
+ out[cur].push(
+ collapse
+ ? block.map((l, i) => (i ? l.trim() : l.replace(/\s+$/, ""))).join(" ")
+ : block.join("\n")
+ );
+ }
+ block = null;
+ };
+ for (const line of text.split("\n")) {
+ if (!fixedSection && line.startsWith("### ")) {
+ flush();
+ cur = Object.keys(SECTION_HEADINGS).find((k) => SECTION_HEADINGS[k] === line.trim()) || null;
+ continue;
+ }
+ if (line.startsWith("- ")) {
+ flush();
+ if (cur) block = [line];
+ continue;
+ }
+ if (block && /^\s+\S/.test(line)) {
+ block.push(line);
+ continue;
+ }
+ flush();
+ }
+ flush();
+ return out;
+}
+
+/** Escape every regex metacharacter (CodeQL js/incomplete-sanitization: never escape just one). */
+const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+const mentions = (b, h) => new RegExp(`@${escapeRegExp(h)}(?![A-Za-z0-9_-])`, "i").test(b);
+
+/** Append `— thanks @a / @b` (or extend an existing trailing thanks group) for handles not yet mentioned. */
+export function addCredit(bullet, handles, maintainer = MAINTAINER) {
+ const hs = [...new Set(handles.filter((h) => h && h !== maintainer && !mentions(bullet, h)))];
+ if (!hs.length) return bullet;
+ const lines = bullet.split("\n");
+ let last = lines[lines.length - 1];
+ const add = hs.map((h) => `@${h}`).join(" / ");
+ if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\s*$/.test(last)) {
+ last = `${last.replace(/\s*$/, "")} / ${add}`;
+ } else if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\)\s*$/.test(last)) {
+ last = `${last.replace(/\)\s*$/, "")} / ${add})`;
+ } else {
+ last = `${last.replace(/\s*$/, "")} — thanks ${add}`;
+ }
+ lines[lines.length - 1] = last;
+ return lines.join("\n");
+}
+
+/** Append a `([#N](…))` link to the last line of a bullet, before any trailing thanks group. */
+export function appendLink(bullet, repo, n) {
+ if (refsIn(normalizeLinks(bullet)).includes(n)) return bullet;
+ const lines = bullet.split("\n");
+ let last = lines[lines.length - 1];
+ const th = last.match(/\s*—\s*thanks\s+@[^\n]*$/);
+ last = th
+ ? `${last.slice(0, th.index).replace(/\s*$/, "")} (${prLink(repo, n)})${th[0]}`
+ : `${last.replace(/\s*$/, "")} (${prLink(repo, n)})`;
+ lines[lines.length - 1] = last;
+ return lines.join("\n");
+}
+
+/** Conventional-commit subject → { type, text } with the type prefix bolded (repo format). */
+export function bulletFromSubject(subject, special = {}) {
+ let s = subject
+ .replace(/\s*\(#\d+\)\s*$/, "")
+ .replace(/^\[?URGENT\]?\s*/i, "")
+ .trim();
+ if (special[s]) s = special[s];
+ const m = s.match(/^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/i);
+ if (!m) return { type: "other", text: s };
+ return { type: m[1].toLowerCase(), text: `**${m[1].toLowerCase()}${m[2] || ""}:** ${m[4]}` };
+}
+
+export const sectionForType = (type) =>
+ type === "feat"
+ ? "features"
+ : ["fix", "perf", "security", "revert"].includes(type)
+ ? "fixes"
+ : "maintenance";
+
+/** Type of an existing bullet (`- **fix(x):** …` or `- fix(x): …`), else "other". */
+export function typeOfBullet(bullet) {
+ const m = normalizeLinks(bullet).match(/^- \*{0,2}([a-z]+)(?:\([^)]*\))?!?:\*{0,2}/i);
+ return m ? m[1].toLowerCase() : "other";
+}
+
+/** Text key used to spot twins (two fragments for one PR, a direct-commit twin of a synced PR). */
+export const dedupKey = (b) =>
+ normalizeLinks(b)
+ .split("\n")[0]
+ .replace(/\s*\((?:#\d+[^)]*|direct commit[^)]*)\)\s*/g, " ")
+ .replace(/\s*—\s*thanks.*$/, "")
+ .toLowerCase()
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 100);
+
+/**
+ * Keep one bullet per dedupKey (the best-linked one) and MERGE every `([#N](…))` /
+ * `(direct commit …)` group the dropped twins carried into the survivor.
+ */
+export function dedupeBullets(list) {
+ const best = new Map();
+ const score = (b) => (b.match(/\/pull\//g) || []).length * 10 + b.length / 1000;
+ for (const b of list) {
+ const k = dedupKey(b);
+ if (!best.has(k) || score(b) > best.get(k).score) best.set(k, { b, score: score(b) });
+ }
+ const extras = new Map();
+ const dropped = [];
+ for (const b of list) {
+ const k = dedupKey(b);
+ if (best.get(k).b === b) continue;
+ dropped.push(b);
+ if (!extras.has(k)) extras.set(k, []);
+ for (const m of b.matchAll(/\(\[#\d+\]\([^)]*\)\)|\(direct commit `[0-9a-f]+`\)/g)) {
+ extras.get(k).push(m[0]);
+ }
+ }
+ const seen = new Set();
+ const kept = [];
+ for (const b of list) {
+ const k = dedupKey(b);
+ if (best.get(k).b !== b || seen.has(k)) continue;
+ seen.add(k);
+ let out = b;
+ for (const g of extras.get(k) || []) {
+ if (out.includes(g)) continue;
+ const th = out.match(/\s*—\s*thanks[^\n]*$/);
+ out = th ? `${out.slice(0, th.index)} ${g}${th[0]}` : `${out} ${g}`;
+ }
+ kept.push(out);
+ }
+ return { kept, dropped };
+}
+
+/** Extract `## [version]` … up to the next `## [` (exclusive). */
+export function versionSectionRange(changelog, version) {
+ const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const m = changelog.match(new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"));
+ if (!m) return null;
+ const bodyStart = m.index + m[0].length;
+ const rest = changelog.slice(bodyStart);
+ const next = rest.search(/\n## \[/);
+ return { start: m.index, bodyStart, end: next === -1 ? changelog.length : bodyStart + next };
+}
+
+/** Everything under every `## [` heading OTHER than `version` (used to spot already-shipped text). */
+export function otherSectionsText(changelog, version) {
+ const r = versionSectionRange(changelog, version);
+ if (!r) return changelog;
+ return changelog.slice(0, r.start) + changelog.slice(r.end);
+}
+
+/** Commits covered by the bullets: own PR is a primary ref (or closes a cited issue); no-PR commits by any ref. */
+export function computeCoverage(
+ rows,
+ bullets,
+ { closingPrs = new Set(), originHashes = new Set(), skipHashes = new Set() } = {}
+) {
+ const cited = new Set();
+ const hashes = [];
+ for (const b of bullets) {
+ for (const n of primaryRefs(b)) cited.add(n);
+ hashes.push(...directHashes(b));
+ }
+ const documentedHash = (full) =>
+ hashes.some((h) => full.startsWith(h) || h.startsWith(full.slice(0, 9)));
+ const uncovered = rows.filter((r) => {
+ const h = r.hash.slice(0, 9);
+ if (skipHashes.has(h) || originHashes.has(h)) return false;
+ if (r.pr) return !(cited.has(r.pr) || closingPrs.has(r.pr));
+ return !(documentedHash(r.hash) || r.refs.some((x) => cited.has(x)));
+ });
+ return { cited, uncovered };
+}
+
+/** Rank authors by commits; key = GitHub login of the merged PR when known, else the mailmap name. */
+export function rankAuthors(rows, limit = 25) {
+ const counts = {};
+ const names = {};
+ for (const r of rows) {
+ if (BOT_RE.test(r.authorName)) continue;
+ const k = r.prAuthor || r.authorName;
+ counts[k] = (counts[k] || 0) + 1;
+ names[k] ??= {};
+ names[k][r.authorName] = (names[k][r.authorName] || 0) + 1;
+ }
+ const display = (k) => Object.entries(names[k]).sort((a, b) => b[1] - a[1])[0][0];
+ return {
+ counts,
+ top: Object.entries(counts)
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, limit)
+ .map(([k, c]) => [display(k) === k ? k : `${display(k)} (@${k})`, c]),
+ };
+}
+
+const fmt = (n) => n.toLocaleString("en-US");
+const medal = (i) => (i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : String(i + 1));
+
+/** Render the whole `## [version]` section (header → stats → ranking → three sections). */
+export function renderSection({
+ version,
+ today,
+ baseTip,
+ headTip,
+ rows,
+ all,
+ ranking,
+ prNumbers,
+ dateSuffix = "TBD",
+}) {
+ const bullets = Object.values(all)
+ .flat()
+ .filter((b) => b.startsWith("- "));
+ const byType = {};
+ const SEC_DEFAULT = { features: "feat", fixes: "fix", maintenance: "chore" };
+ for (const k of Object.keys(all)) {
+ for (const b of all[k].filter((x) => x.startsWith("- "))) {
+ let t = typeOfBullet(b);
+ if (!TYPE_LABEL[t] || t === "other") t = SEC_DEFAULT[k];
+ byType[TYPE_LABEL[t]] = (byType[TYPE_LABEL[t]] || 0) + 1;
+ }
+ }
+ const prRefs = new Set();
+ const handles = new Set();
+ for (const b of bullets) {
+ for (const n of refsIn(normalizeLinks(b))) if (prNumbers.has(n)) prRefs.add(n);
+ for (const m of normalizeLinks(b).matchAll(/@([A-Za-z0-9_-]+)/g)) handles.add(m[1]);
+ }
+ const humans = rows.filter((r) => !BOT_RE.test(r.authorName));
+ const people = new Set([...Object.keys(ranking.counts), ...handles].map((x) => x.toLowerCase()));
+ const lines = [
+ `## [${version}] — ${dateSuffix}`,
+ ``,
+ `_Living section — reconciled ${today} from all cycle commits (\`${baseTip}\` → \`${headTip}\`, ${fmt(rows.length)} non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each \`/generate-release\` phase._`,
+ ``,
+ `### 📊 Release by the numbers`,
+ ``,
+ `| | |`,
+ `| --- | ---: |`,
+ `| 👥 People who contributed | **${fmt(people.size)}** |`,
+ `| 📝 Commits in the cycle | **${fmt(rows.length)}** |`,
+ `| 🔀 Pull requests referenced | **${fmt(prRefs.size)}** |`,
+ `| 📋 Changelog entries | **${fmt(bullets.length)}** |`,
+ `| 🙌 Contributors credited in entries | **${fmt(handles.size)}** |`,
+ `| 🤖 Automated dependency commits | ${rows.length - humans.length} |`,
+ ``,
+ `**Entries by type**`,
+ ``,
+ `| Type | Count |`,
+ `| --- | ---: |`,
+ ...Object.entries(byType)
+ .sort((a, b) => b[1] - a[1])
+ .map(([t, c]) => `| ${t} | ${c} |`),
+ ``,
+ `### 🏆 Top 25 contributors this cycle`,
+ ``,
+ `_By commits in \`${baseTip}..${headTip}\`, author identities consolidated via \`.mailmap\` and the merged PR's GitHub login. Bots excluded._`,
+ ``,
+ `| # | Contributor | Commits |`,
+ `| ---: | --- | ---: |`,
+ ...ranking.top.map(([n, c], i) => `| ${medal(i)} | ${n} | ${c} |`),
+ ``,
+ ];
+ for (const k of ["features", "fixes", "maintenance"]) {
+ lines.push(SECTION_HEADINGS[k], "", ...all[k], "");
+ }
+ return `${lines.join("\n")}\n---\n`;
+}
+
+// ───────────────────────────── data acquisition (git + gh) ─────────────────────────────
+
+const git = (args, cwd = ROOT) =>
+ execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }).trim();
+const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 1 << 28 });
+
+export function repoSlug(cwd = ROOT) {
+ const url = git(["remote", "get-url", "origin"], cwd);
+ const m = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
+ return m ? m[1] : "diegosouzapw/OmniRoute";
+}
+
+/** Non-merge commits in `base..head` with mailmap identities, PR number and co-author trailers. */
+export function readCommits(base, head, cwd = ROOT) {
+ const raw = git(
+ [
+ "log",
+ "--no-merges",
+ "--use-mailmap",
+ "--date=short",
+ "--format=%H%x1f%ad%x1f%aN%x1f%aE%x1f%s%x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1e)%x1e%x1e",
+ `${base}..${head}`,
+ ],
+ cwd
+ );
+ return raw
+ .split("\x1e\x1e")
+ .map((s) => s.replace(/^\n/, ""))
+ .filter((s) => s.trim())
+ .map((r) => {
+ const [hash, date, authorName, authorEmail, subject, coa] = r.split("\x1f");
+ const refs = refsIn(subject);
+ const prMatch = subject.match(/\(#(\d+)\)\s*$/);
+ return {
+ hash,
+ date,
+ authorName,
+ authorEmail,
+ subject,
+ refs,
+ pr: prMatch ? Number(prMatch[1]) : null,
+ prAuthor: null,
+ coauthors: (coa || "")
+ .split("\x1e")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ };
+ });
+}
+
+export function fetchMergedPrs(repo, releaseBranch) {
+ return JSON.parse(
+ gh([
+ "pr",
+ "list",
+ "--repo",
+ repo,
+ "--state",
+ "merged",
+ "--base",
+ releaseBranch,
+ "--limit",
+ "1000",
+ "--json",
+ "number,title,author,body,closingIssuesReferences,mergedAt",
+ ])
+ );
+}
+
+export function fetchPr(repo, n) {
+ try {
+ return JSON.parse(
+ execFileSync(
+ "gh",
+ [
+ "pr",
+ "view",
+ String(n),
+ "--repo",
+ repo,
+ "--json",
+ "number,title,author,body,closingIssuesReferences",
+ ],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ }
+ )
+ );
+ } catch {
+ return null;
+ }
+}
+
+/** Fragment files at `ref` + the commit that ADDED each one (definitive origin of the credit). */
+export function readFragments(ref = "HEAD", cwd = ROOT) {
+ const out = [];
+ for (const dir of Object.keys(SECTION_HEADINGS)) {
+ let files = "";
+ try {
+ files = git(["ls-tree", "--name-only", ref, `changelog.d/${dir}/`], cwd);
+ } catch {
+ continue;
+ }
+ for (const f of files
+ .split("\n")
+ .filter((x) => x && !/README\.md$|\.gitkeep$/.test(x))
+ .sort()) {
+ const text = git(["show", `${ref}:${f}`], cwd);
+ const origin =
+ git(["log", "--diff-filter=A", "--format=%h%x09%s", "--", f], cwd)
+ .split("\n")
+ .filter(Boolean)
+ .pop() || "";
+ const [ohash, osubject = ""] = origin.split("\t");
+ const om = osubject.match(/\(#(\d+)\)\s*$/);
+ const pm = f.match(/\/(\d{4,6})-/);
+ out.push({
+ path: f,
+ section: dir,
+ text,
+ originHash: ohash ? ohash.slice(0, 9) : null,
+ originPr: om ? Number(om[1]) : null,
+ prefixPr: pm ? Number(pm[1]) : null,
+ });
+ }
+ }
+ return out;
+}
+
+// ───────────────────────────── the reconciliation itself ─────────────────────────────
+
+export function reconcile({
+ changelog,
+ version,
+ repo,
+ rows,
+ prs,
+ fragments,
+ extraPrs = new Map(),
+ credits = {},
+ carriers = new Set(),
+ dropFragments = new Set(),
+ fragmentPr = {},
+ fragmentCredit = {},
+ today,
+ baseTip,
+ headTip,
+ skipHashes = new Set(),
+ special = {},
+}) {
+ const prBy = new Map(prs.map((p) => [p.number, p]));
+ for (const [n, p] of extraPrs) if (!prBy.has(n)) prBy.set(n, p);
+ const prAuthor = (n) => prBy.get(n)?.author?.login || null;
+ for (const r of rows) r.prAuthor = r.pr ? prAuthor(r.pr) : null;
+ const issueToPrs = new Map();
+ for (const p of prs)
+ for (const ci of p.closingIssuesReferences || []) {
+ if (!issueToPrs.has(ci.number)) issueToPrs.set(ci.number, []);
+ issueToPrs.get(ci.number).push(p.number);
+ }
+
+ const range = versionSectionRange(changelog, version);
+ if (!range) throw new Error(`CHANGELOG.md has no "## [${version}]" section`);
+ const existing = parseBlocks(changelog.slice(range.bodyStart, range.end));
+ const shippedElsewhere = normalizeLinks(otherSectionsText(changelog, version));
+
+ const blocks = {
+ features: [...existing.features],
+ fixes: [...existing.fixes],
+ maintenance: [...existing.maintenance],
+ };
+ const srcOf = new Map();
+ for (const f of fragments) {
+ const parsed = parseBlocks(f.text, { fixedSection: f.section, collapse: true });
+ for (const b of parsed[f.section]) {
+ blocks[f.section].push(b);
+ srcOf.set(b, f);
+ }
+ }
+
+ const dropped = [];
+ const patched = [];
+ const mismatches = [];
+ const originHashes = new Set();
+ for (const k of Object.keys(blocks)) {
+ const out = [];
+ for (let b of blocks[k]) {
+ const frag = srcOf.get(b);
+ const first = b.split("\n")[0].trim();
+ if (frag && dropFragments.has(frag.path)) {
+ dropped.push({ why: "dropped by --drop-fragment", first, src: frag.path });
+ continue;
+ }
+ const probe = normalizeLinks(first)
+ .replace(/\s*\(#\d+.*$/, "")
+ .slice(0, 90);
+ if (frag && probe.length > 40 && shippedElsewhere.includes(probe)) {
+ dropped.push({
+ why: "text already shipped in another version section",
+ first,
+ src: frag.path,
+ });
+ continue;
+ }
+ if (frag) {
+ const refs0 = refsIn(normalizeLinks(b));
+ const override = fragmentPr[frag.path];
+ const oPr = frag.originPr;
+ const fPr = frag.prefixPr;
+ let defPrs = override
+ ? [override]
+ : oPr && prBy.has(oPr) && !carriers.has(oPr)
+ ? [oPr]
+ : fPr && prBy.has(fPr)
+ ? [fPr]
+ : oPr && prBy.has(oPr)
+ ? [oPr]
+ : [];
+ if (!defPrs.length)
+ for (const n of refs0) for (const pr of issueToPrs.get(n) || []) defPrs.push(pr);
+ defPrs = [...new Set(defPrs)];
+ if (oPr && fPr && prBy.has(fPr) && oPr !== fPr) {
+ mismatches.push(
+ `${frag.path}: prefix #${fPr} (${prAuthor(fPr)}) vs origin #${oPr} (${prAuthor(oPr)}) → used #${defPrs.join("/")}`
+ );
+ }
+ if (b.includes("#PR_NUMBER") && defPrs.length) {
+ b = b
+ .replace(/\[#PR_NUMBER\]\([^)]*\)/g, prLink(repo, defPrs[0]))
+ .replace(/#PR_NUMBER/g, `#${defPrs[0]}`);
+ }
+ for (const h of fragmentCredit[frag.path] || []) b = addCredit(b, [h]);
+ for (const n of defPrs) {
+ const before = b;
+ b = appendLink(b, repo, n);
+ b = addCredit(b, [prAuthor(n), ...(credits[n] || [])]);
+ if (b !== before) patched.push(`${frag.path} → #${n} @${prAuthor(n)}`);
+ }
+ if (!override && !(oPr && carriers.has(oPr)) && frag.originHash)
+ originHashes.add(frag.originHash);
+ }
+ out.push(b);
+ }
+ blocks[k] = out;
+ }
+
+ const allExisting = Object.values(blocks)
+ .flat()
+ .filter((b) => b.startsWith("- "));
+ const closingPrs = new Set();
+ const citedIssues = new Set(allExisting.flatMap((b) => refsIn(normalizeLinks(b))));
+ for (const p of prs)
+ for (const ci of p.closingIssuesReferences || [])
+ if (citedIssues.has(ci.number)) closingPrs.add(p.number);
+ const { uncovered } = computeCoverage(rows, allExisting, {
+ closingPrs,
+ originHashes,
+ skipHashes,
+ });
+
+ const gen = { features: [], fixes: [], maintenance: [] };
+ const deps = [];
+ for (const r of [...uncovered].sort(
+ (a, b) => (a.pr || 0) - (b.pr || 0) || a.date.localeCompare(b.date)
+ )) {
+ if (BOT_RE.test(r.authorName)) {
+ deps.push(r);
+ continue;
+ }
+ const c = bulletFromSubject(r.subject, special);
+ let bullet = `- ${c.text}`;
+ bullet += r.pr ? ` (${prLink(repo, r.pr)})` : ` (direct commit \`${r.hash.slice(0, 10)}\`)`;
+ bullet = addCredit(bullet, [
+ r.pr ? prAuthor(r.pr) : null,
+ ...(r.pr ? credits[r.pr] || [] : []),
+ ]);
+ gen[sectionForType(c.type)].push(bullet);
+ }
+ if (deps.length) {
+ gen.maintenance.push(
+ `- **deps:** ${deps.length} Dependabot bumps — ${deps
+ .map(
+ (r) =>
+ `${r.subject.replace(/^deps(\([^)]*\))?:\s*/, "").replace(/\s*\(#\d+\)\s*$/, "")} (${prLink(repo, r.pr)})`
+ )
+ .join("; ")}`
+ );
+ }
+
+ const all = {};
+ const dedupDropped = [];
+ for (const k of Object.keys(blocks)) {
+ const existingSet = new Set(existing[k]);
+ const merged = [...blocks[k], ...gen[k]];
+ const kept = merged.filter((b) => existingSet.has(b));
+ const { kept: rest, dropped: dd } = dedupeBullets(merged.filter((b) => !existingSet.has(b)));
+ dedupDropped.push(...dd);
+ all[k] = [...kept, ...rest];
+ }
+
+ const ranking = rankAuthors(rows);
+ const section = renderSection({
+ version,
+ today,
+ baseTip,
+ headTip,
+ rows,
+ all,
+ ranking,
+ prNumbers: new Set(prBy.keys()),
+ });
+ const next = `${changelog.slice(0, range.start)}${section}${changelog.slice(range.end + 1)}`;
+ const report = {
+ version,
+ baseTip,
+ headTip,
+ commits: rows.length,
+ existing: Object.fromEntries(Object.entries(existing).map(([k, v]) => [k, v.length])),
+ fragments: fragments.length,
+ generated: Object.fromEntries(Object.entries(gen).map(([k, v]) => [k, v.length])),
+ bullets: Object.values(all)
+ .flat()
+ .filter((b) => b.startsWith("- ")).length,
+ dropped,
+ patched: patched.length,
+ mismatches,
+ dedupDropped,
+ uncovered: uncovered.length,
+ depsRolled: deps.length,
+ ranking: ranking.top,
+ };
+ return { changelog: next, report };
+}
+
+// ───────────────────────────── CLI ─────────────────────────────
+
+function parseArgs(argv) {
+ const o = {
+ credit: {},
+ carriers: new Set(),
+ dropFragments: new Set(),
+ fragmentPr: {},
+ dryRun: false,
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ const v = () => argv[++i];
+ if (a === "--version") o.version = v();
+ else if (a === "--base") o.base = v();
+ else if (a === "--head") o.head = v();
+ else if (a === "--release-branch") o.releaseBranch = v();
+ else if (a === "--prs") o.prs = v();
+ else if (a === "--report") o.report = v();
+ else if (a === "--dry-run") o.dryRun = true;
+ else if (a === "--carrier") o.carriers.add(Number(v()));
+ else if (a === "--drop-fragment") o.dropFragments.add(v());
+ else if (a === "--credit") {
+ const [n, hs] = v().split("=");
+ o.credit[Number(n)] = hs.split(",").map((h) => h.replace(/^@/, ""));
+ } else if (a === "--fragment-pr") {
+ const [p, n] = v().split("=");
+ o.fragmentPr[p] = Number(n);
+ }
+ }
+ return o;
+}
+
+export function main(argv = process.argv.slice(2)) {
+ const o = parseArgs(argv);
+ const version =
+ o.version || JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
+ const releaseBranch = o.releaseBranch || `release/v${version}`;
+ const head = o.head || "HEAD";
+ let base = o.base;
+ if (!base) {
+ const { base: open, source } = resolveCycleBase(version);
+ base = `${open}^`;
+ console.log(`[reconcile-changelog] base = parent of the ${source} commit ${open.slice(0, 10)}`);
+ }
+ const repo = repoSlug();
+ const rows = readCommits(base, head);
+ const prs = o.prs
+ ? JSON.parse(fs.readFileSync(o.prs, "utf8"))
+ : fetchMergedPrs(repo, releaseBranch);
+ const known = new Set(prs.map((p) => p.number));
+ const extraPrs = new Map();
+ for (const n of new Set(rows.filter((r) => r.pr && !known.has(r.pr)).map((r) => r.pr))) {
+ const p = fetchPr(repo, n);
+ if (p) extraPrs.set(n, p);
+ }
+ const fragments = readFragments(head);
+ const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
+ const baseTip = git(["rev-parse", "--short=10", base]);
+ const headTip = git(["rev-parse", "--short=10", head]);
+ // the cycle-open bump and the living-section restore are the only legitimate non-bullet commits
+ const skipHashes = new Set(
+ rows
+ .filter(
+ (r) =>
+ /^chore\(release\): (open v[\d.]+ development cycle|restore the living)/.test(
+ r.subject
+ ) || /^Release v[\d.]+$/.test(r.subject)
+ )
+ .map((r) => r.hash.slice(0, 9))
+ );
+ const { changelog: next, report } = reconcile({
+ changelog,
+ version,
+ repo,
+ rows,
+ prs,
+ fragments,
+ extraPrs,
+ credits: o.credit,
+ carriers: o.carriers,
+ dropFragments: o.dropFragments,
+ fragmentPr: o.fragmentPr,
+ today: new Date().toISOString().slice(0, 10),
+ baseTip,
+ headTip,
+ skipHashes,
+ });
+ if (!o.dryRun) {
+ fs.writeFileSync(path.join(ROOT, "CHANGELOG.md"), next);
+ for (const f of fragments) {
+ try {
+ fs.unlinkSync(path.join(ROOT, f.path));
+ } catch {
+ /* already gone */
+ }
+ }
+ }
+ if (o.report) fs.writeFileSync(o.report, JSON.stringify(report, null, 1));
+ const { dropped, mismatches, dedupDropped, ranking, ...summary } = report;
+ console.log(`[reconcile-changelog] ${o.dryRun ? "(dry-run) " : ""}${JSON.stringify(summary)}`);
+ for (const d of dropped) console.log(` dropped: ${d.why} — ${d.first.slice(0, 90)}`);
+ for (const m of mismatches) console.log(` review: ${m}`);
+ for (const d of dedupDropped) console.log(` deduped: ${d.slice(0, 90)}`);
+ console.log(
+ `[reconcile-changelog] next: npm run release:contributors -- ${version} --inject && npx prettier --write CHANGELOG.md && npm run release:sync-changelog-i18n -- ${version} && npm run check:changelog-integrity`
+ );
+ return 0;
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ process.exit(main());
+}
diff --git a/skills/cli-tunnel/SKILL.md b/skills/cli-tunnel/SKILL.md
index 4d7388bb7b..d62aab6abb 100644
--- a/skills/cli-tunnel/SKILL.md
+++ b/skills/cli-tunnel/SKILL.md
@@ -37,12 +37,12 @@ omniroute tunnel
omniroute tunnel list
```
-### `tunnel create [type]`
+### `tunnel create`
**Example:**
```bash
-omniroute tunnel create [type]
+omniroute tunnel create
```
### `tunnel stop `
diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx
index d36ea43170..c0e93258a3 100644
--- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx
+++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx
@@ -526,7 +526,7 @@ function ComboHealthCard({
{combo.quotaHealth.providers.map((provider) => {
const trendMeta = getTrendMeta(provider.trend);
- const width = `${Math.max(provider.remainingPct, provider.remainingPct > 0 ? 6 : 0)}%`;
+ const pct = provider.remainingPct;
+ const width = `${pct === null ? 0 : Math.max(pct, pct > 0 ? 6 : 0)}%`;
return (
{t("comboHealthRemainingQuota", {
- value: formatPercent(provider.remainingPct, 1),
+ value: formatPercentOrDash(provider.remainingPct, 1),
})}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index 2ead593b78..860e9f03f9 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -14,6 +14,7 @@ import { NextResponse } from "next/server";
* public on an exposed instance, so version, uptime and memory stay behind the authenticated
* `/api/monitoring/health`. For a probe that also confirms the database answers, use
* `/api/health/ping`.
+ * Readiness (DB-backed) lives at /api/health/ping (pingDb, 503 when down); this route stays liveness-only so a slow DB never restarts the container.
*/
export const dynamic = "force-dynamic";
diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts
index c859ad7bdd..08f3cf04bd 100644
--- a/src/app/api/monitoring/health/route.ts
+++ b/src/app/api/monitoring/health/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getProviderConnections } from "@/lib/db/providers";
import { getCachedSettings } from "@/lib/db/readCache";
+import { getWalMaintenanceState } from "@/lib/db/walMaintenance";
import { buildHealthPayload } from "@/lib/monitoring/observability";
import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
import { APP_CONFIG } from "@/shared/constants/config";
@@ -241,6 +242,10 @@ async function rebuildHealthPayload(): Promise {
null
)
: null;
+ // #12853: WAL maintenance state (ticks/busy streak + totals) next to the
+ // admission gates. getWalMaintenanceState never throws and never touches
+ // the DB — a monitoring read stays cheap. Additive key, nothing moves.
+ const walMaintenance = readHealthValue("wal maintenance", () => getWalMaintenanceState(), null);
const payload = buildHealthPayload({
appVersion: APP_CONFIG.version,
@@ -266,6 +271,7 @@ async function rebuildHealthPayload(): Promise {
credentialHealth,
adaptiveAdmission,
chatAdmission,
+ walMaintenance,
});
if (generation === healthPayloadCacheGeneration) {
diff --git a/src/app/api/provider-metrics/route.ts b/src/app/api/provider-metrics/route.ts
index 3b6e16a0a7..ad2d0e06d7 100644
--- a/src/app/api/provider-metrics/route.ts
+++ b/src/app/api/provider-metrics/route.ts
@@ -4,7 +4,7 @@ import pino from "pino";
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { getProviderMetrics } from "@/lib/db/callLogStats";
-import { toNumber } from "@/shared/utils/numeric";
+import { toNumber, toNumberOrNull } from "@/shared/utils/numeric";
const logger = pino({ name: "provider-metrics-api" });
@@ -22,7 +22,7 @@ export async function GET() {
totalRequests: number;
totalSuccesses: number;
successRate: number;
- avgLatencyMs: number;
+ avgLatencyMs: number | null;
lastRequestAt: string | null;
lastErrorAt: string | null;
lastStatus: number | null;
@@ -41,7 +41,7 @@ export async function GET() {
: "unknown";
const totalRequests = toNumber(row.totalRequests);
const totalSuccesses = toNumber(row.totalSuccesses);
- const avgLatencyMs = toNumber(row.avgLatencyMs);
+ const avgLatencyMs = toNumberOrNull(row.avgLatencyMs);
const lastRequestAt = typeof row.lastRequestAt === "string" ? row.lastRequestAt : null;
const lastErrorAt = typeof row.lastErrorAt === "string" ? row.lastErrorAt : null;
const lastStatus = row.lastStatus == null ? null : toNumber(row.lastStatus);
@@ -66,8 +66,7 @@ export async function GET() {
// Only flag as errorProvider if the provider's MOST RECENT request was itself
// a failure. A provider with a historical lastErrorAt but a recent success
// (lastStatus 2xx/3xx) must not be shown as currently errored (#3619).
- const isCurrentlyInError =
- lastStatus !== null && (lastStatus < 200 || lastStatus >= 400);
+ const isCurrentlyInError = lastStatus !== null && (lastStatus < 200 || lastStatus >= 400);
const errorTs = isCurrentlyInError && lastErrorAt ? Date.parse(lastErrorAt) : 0;
if (Number.isFinite(errorTs) && errorTs > errorProviderTs) {
errorProvider = provider;
diff --git a/src/app/api/search/stats/route.ts b/src/app/api/search/stats/route.ts
index bf0992f1a4..6559ff732a 100644
--- a/src/app/api/search/stats/route.ts
+++ b/src/app/api/search/stats/route.ts
@@ -16,7 +16,7 @@ export async function GET(request: Request) {
const providers: Record<
string,
- { requests: number; avg_latency_ms: number; total_cost: number }
+ { requests: number; avg_latency_ms: number | null; total_cost: number }
> = {};
for (const row of providerStats) {
const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0;
diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts
index f3ec9c26d4..5284d00077 100644
--- a/src/app/api/system/version/route.ts
+++ b/src/app/api/system/version/route.ts
@@ -25,6 +25,7 @@ import {
} from "@/lib/system/versionCheck";
import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath";
import { restartRunningServer } from "@/lib/system/processManagerRestart";
+import { APP_CONFIG } from "@/shared/constants/appConfig";
// #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without
// a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the
// shell on win32 only; SERVICE_VERSION_PATTERN keeps the shell-joined version safe.
@@ -35,11 +36,7 @@ const execFileAsync = promisify(execFile);
export const dynamic = "force-dynamic";
function getCurrentVersion(): string {
- try {
- return require("../../../../../package.json").version as string;
- } catch {
- return "unknown";
- }
+ return APP_CONFIG.version;
}
/**
diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts
index 825f96942c..d606d2ec93 100644
--- a/src/lib/combos/controlCenter.ts
+++ b/src/lib/combos/controlCenter.ts
@@ -31,10 +31,10 @@ export interface ComboControlCenterHealth {
totalRequests?: number;
};
quotaHealth?: {
- worstRemainingPct?: number;
+ worstRemainingPct?: number | null;
providers?: Array<{
provider: string;
- remainingPct: number;
+ remainingPct: number | null;
isExhausted: boolean;
trend: "improving" | "stable" | "declining";
}>;
diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts
index f3845f31f1..8498ce3331 100644
--- a/src/lib/db/callLogStats.ts
+++ b/src/lib/db/callLogStats.ts
@@ -18,7 +18,7 @@ export interface ProviderMetricRow {
provider: string;
totalRequests: number;
totalSuccesses: number;
- avgLatencyMs: number;
+ avgLatencyMs: number | null;
lastRequestAt: string | null;
lastErrorAt: string | null;
lastStatus: number | null;
@@ -37,7 +37,7 @@ export interface ProviderUsageRow {
export interface SearchProviderStatRow {
provider: string;
requests: number;
- avg_latency_ms: number;
+ avg_latency_ms: number | null;
}
export interface SearchRecentRow {
@@ -126,10 +126,9 @@ export function getProviderMetrics(): ProviderMetricRow[] {
*
* Deliberately NOT `getProviderMetrics()` with a `since` parameter: that query
* carries two correlated subqueries (`lastStatus`, `lastErrorStatus`) which a
- * ranking never displays, and they dominate its cost — `call_logs` is indexed
- * on `timestamp` alone, so each correlated pass rescans the whole window per
- * provider. Here a single bounded `GROUP BY` uses `idx_cl_timestamp` and stops
- * there. The rules are shared with its neighbour, not the query: same success
+ * ranking never displays, and they dominate its cost. Here a single bounded
+ * `GROUP BY` leans on `idx_cl_timestamp` plus `idx_cl_provider_timestamp` /
+ * `idx_cl_request_provider` (migration 174) and stops there. The rules are shared with its neighbour, not the query: same success
* definition, same `#10714` guard against providers whose connections are gone.
*/
export function getProviderUsageSince(since: string): ProviderUsageRow[] {
@@ -259,17 +258,17 @@ export function getFallbackStats(
.prepare(
`
SELECT
- SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as total,
- SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as with_requested,
- SUM(CASE
+ COALESCE(SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END), 0) as total,
+ COALESCE(SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END), 0) as with_requested,
+ COALESCE(SUM(CASE
WHEN (combo_name IS NULL OR combo_name = '')
AND requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND model != ''
THEN 1 ELSE 0 END
- ) as fallback_eligible,
- SUM(CASE
+ ), 0) as fallback_eligible,
+ COALESCE(SUM(CASE
WHEN (combo_name IS NULL OR combo_name = '')
AND requested_model IS NOT NULL
AND requested_model != ''
@@ -277,7 +276,7 @@ export function getFallbackStats(
AND model != ''
AND LOWER(CASE WHEN instr(requested_model, '/') > 0 THEN substr(requested_model, instr(requested_model, '/') + 1) ELSE requested_model END) != LOWER(model)
THEN 1 ELSE 0 END
- ) as fallbacks
+ ), 0) as fallbacks
FROM call_logs
${whereClause}
`
@@ -289,8 +288,9 @@ export function getFallbackStats(
/**
* Failure-family breakdown over `call_logs` for the usage analytics endpoint.
* Failures are rows with status >= 400 or a non-empty error summary; successes
- * are excluded in SQL. Pre-migration rows and failures the classifier does not
- * recognize (null family) land in the explicit `unclassified` bucket.
+ * are excluded in SQL. Rows predating migration 158 (`error_type` NULL,
+ * `timestamp` before 2026-08-20) land in `pre_migration`; other NULL families
+ * land in `unclassified`.
*
* @param whereClause - SQL WHERE clause (may be empty string) using the same
* named params as the usage_history queries.
@@ -305,7 +305,9 @@ export function getErrorTypeBreakdown(
.prepare(
`
SELECT
- COALESCE(error_type, 'unclassified') AS errorType,
+ -- '2026-08-20' = commit 4c15c05f9 that added error_type (migration 158).
+ -- Lower bound, not exact: late upgraders have post-cutoff rows with NULL values.
+ CASE WHEN error_type IS NULL AND timestamp < '2026-08-20' THEN 'pre_migration' WHEN error_type IS NULL THEN 'unclassified' ELSE error_type END AS errorType,
COUNT(*) AS count
FROM call_logs
${whereClause} ${whereClause ? "AND" : "WHERE"} (status >= 400 OR error_summary IS NOT NULL)
diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts
index c8ba9b1f18..a69db56e64 100644
--- a/src/lib/db/core.ts
+++ b/src/lib/db/core.ts
@@ -41,6 +41,14 @@ import { rowToCamel } from "./caseMapping";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
import { parseModelAccessMode } from "./apiKeys/modelAccessMode";
import { getExistingDbInstance as getDb, setDbInstance as setDb } from "./singleton";
+import type { WalCheckpointMode } from "./walMaintenance";
+import {
+ startWalMaintenance,
+ stopWalMaintenance,
+ runCheckpointNow,
+ getWalMaintenanceState,
+ logCheckpointOutcome,
+} from "./walMaintenance";
// Re-exported so existing call sites that pull these helpers off the core module keep working.
export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping";
import {
@@ -55,7 +63,6 @@ import {
type SqliteDatabase = SqliteAdapter;
type JsonRecord = Record;
-type CheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
type DatabaseOptimizationSettings = DatabaseSettings["optimization"];
type PreservedTableSnapshot = {
table: string;
@@ -400,6 +407,8 @@ const SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status);
+ CREATE INDEX IF NOT EXISTS idx_cl_provider_timestamp ON call_logs(provider, timestamp);
+ CREATE INDEX IF NOT EXISTS idx_cl_request_provider ON call_logs(request_type, provider);
CREATE TABLE IF NOT EXISTS proxy_logs (
id TEXT PRIMARY KEY,
@@ -529,12 +538,6 @@ declare global {
var __omnirouteDbOomFailureCount: number | undefined;
}
-function checkpointDb(db: SqliteDatabase, mode: CheckpointMode = "TRUNCATE"): boolean {
- if (isCloud || isBuildPhase || !SQLITE_FILE) return false;
- db.pragma(`wal_checkpoint(${mode})`);
- return true;
-}
-
function summarizePreservedTables(tables: PreservedTableSnapshot[]): string {
if (tables.length === 0) return "none";
return tables.map((table) => `${table.table}(${table.rowCount})`).join(", ");
@@ -962,50 +965,9 @@ function startDbHealthCheckScheduler(db: SqliteDatabase) {
dbHealthCheckTimer.unref?.();
}
-let walTruncateTimer: NodeJS.Timeout | null = null;
-
-function getWalTruncateIntervalMs(): number {
- const rawValue = process.env.OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS;
- if (typeof rawValue === "string" && rawValue.trim().length > 0) {
- const parsed = Number(rawValue);
- if (Number.isFinite(parsed) && parsed >= 0) {
- return parsed;
- }
- }
- return 6 * 60 * 60 * 1000;
-}
-
-function clearWalTruncateScheduler() {
- if (walTruncateTimer) {
- clearInterval(walTruncateTimer);
- walTruncateTimer = null;
- }
-}
-
// Auto-checkpoint moves WAL pages back into the main DB file but never shrinks the WAL
// file itself; only wal_checkpoint(TRUNCATE) does, and a long-running server never closes its DB.
-function startWalTruncateScheduler(db: SqliteDatabase) {
- clearWalTruncateScheduler();
- if (isCloud || isBuildPhase || isAutomatedTestProcess()) return;
-
- const intervalMs = getWalTruncateIntervalMs();
- if (intervalMs <= 0) return;
-
- walTruncateTimer = setInterval(() => {
- try {
- if (!db.open) return;
- // TRUNCATE waits for readers; under concurrent write load it can no-op without
- // shrinking the file. That is expected — it retries on the next tick.
- if (checkpointDb(db, "TRUNCATE")) {
- console.log("[DB] Periodic SQLite WAL checkpoint completed (TRUNCATE).");
- }
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- console.warn("[DB] Periodic WAL truncate failed:", message);
- }
- }, intervalMs);
- walTruncateTimer.unref?.();
-}
+// The scheduler lives in ./walMaintenance (periodic TRUNCATE + busy warn + PASSIVE retry).
export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) {
const db = getDbInstance();
@@ -1390,7 +1352,7 @@ export function getDbInstance(): SqliteDatabase {
}
startDbHealthCheckScheduler(db);
- startWalTruncateScheduler(db);
+ startWalMaintenance(db, SQLITE_FILE);
// Log the resolved absolute DATA_DIR + SQLITE_FILE once at init so a
// multi-replica / Docker volume-topology mismatch (each replica opening a
// different on-disk DB → "phantom"/missing combos & connections) is
@@ -1416,9 +1378,10 @@ export function pingDb(): boolean {
}
}
-export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean {
+export function closeDbInstance(options?: { checkpointMode?: WalCheckpointMode | null }): boolean {
clearDbHealthCheckScheduler();
- clearWalTruncateScheduler();
+ const streakBefore = getWalMaintenanceState().busyStreak;
+ stopWalMaintenance();
const db = getDb();
if (!db) return false;
@@ -1427,9 +1390,12 @@ export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | nu
try {
if (checkpointMode) {
try {
- if (checkpointDb(db, checkpointMode)) {
- console.log(`[DB] SQLite WAL checkpoint completed (${checkpointMode}).`);
- }
+ const outcome = runCheckpointNow(db, checkpointMode, {
+ sqliteFile: SQLITE_FILE,
+ isCloud,
+ isBuildPhase,
+ });
+ logCheckpointOutcome(outcome, checkpointMode, streakBefore);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[DB] WAL checkpoint failed during close (${checkpointMode}):`, message);
diff --git a/src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql b/src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql
new file mode 100644
index 0000000000..b6a8a7b5d1
--- /dev/null
+++ b/src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql
@@ -0,0 +1,6 @@
+-- GROUP BY provider support. (provider,timestamp) backs the bare
+-- GROUP BY provider in getProviderMetrics; (request_type,provider)
+-- backs WHERE request_type='search' GROUP BY provider. Non-covering for the
+-- real queries (duration/status outside the index) by design — no third index.
+CREATE INDEX IF NOT EXISTS idx_cl_provider_timestamp ON call_logs(provider, timestamp);
+CREATE INDEX IF NOT EXISTS idx_cl_request_provider ON call_logs(request_type, provider);
diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts
index 04c4de9b6f..1cb3f5a73a 100644
--- a/src/lib/db/providers.ts
+++ b/src/lib/db/providers.ts
@@ -39,6 +39,7 @@ import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSel
import { isMicrosoftDesignerWebRetiredProviderId } from "@/shared/constants/designerWebRetirement";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
+import { applyCodexChildCooldownClearOnUpdate } from "./providers/codexAccountState";
/**
* normalizeProviderSpecificData + the Codex fingerprint-seed invariant: Codex
@@ -951,11 +952,14 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
...data,
updatedAt: new Date().toISOString(),
};
- merged.providerSpecificData = normalizeConnectionProviderSpecificData(
- toStringOrNull(merged.provider),
- merged.providerSpecificData,
- merged,
- existingCamel.providerSpecificData
+ merged.providerSpecificData = applyCodexChildCooldownClearOnUpdate(
+ data,
+ normalizeConnectionProviderSpecificData(
+ toStringOrNull(merged.provider),
+ merged.providerSpecificData,
+ merged,
+ existingCamel.providerSpecificData
+ )
);
// Mirror the sanitization the create path applies — keep the returned
// object in lockstep with what we persist.
@@ -1024,65 +1028,13 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
export {
updateCodexScopedQuotaState,
updateCodexScopeCooldown,
+ applyCodexChildCooldownClearOnUpdate,
+ stripCodexChildCooldownFields,
+ stripCodexChildCooldownsFromConnection,
+ hasCodexScopeCooldown,
+ liftCodexScopeCooldownOnHeadroom,
} from "./providers/codexAccountState";
-/**
- * Atomic conditional clear of recoverable error state on a connection row.
- *
- * Returns true when the row was cleared, false when a concurrent writer
- * (markAccountUnavailable, connectionRecovery tick, test, etc.) changed the
- * row between the caller's snapshot read and this UPDATE — in which case the
- * clear is skipped to preserve the freshest error state. Closes the TOCTOU
- * window in the quota-recovery path.
- *
- * CAS token = (test_status, last_error_at, rate_limited_until).
- * markAccountUnavailable always bumps last_error_at on every cooldown/error
- * write, so an unchanged last_error_at reliably indicates no concurrent write.
- */
-export async function clearConnectionErrorIfUnchanged(
- id: string,
- expected: {
- testStatus: string | null | undefined;
- lastErrorAt: string | null | undefined;
- rateLimitedUntil: string | null | undefined;
- }
-): Promise {
- const db = getDbInstance() as unknown as DbLike;
- const result = db
- .prepare(
- `
- UPDATE provider_connections SET
- test_status = 'active',
- last_error = NULL,
- last_error_at = NULL,
- last_error_type = NULL,
- last_error_source = NULL,
- error_code = NULL,
- rate_limited_until = NULL,
- backoff_level = 0,
- updated_at = ?
- WHERE id = ?
- AND IFNULL(test_status, '') = ?
- AND IFNULL(last_error_at, '') = ?
- AND IFNULL(rate_limited_until, '') = ?
- `
- )
- .run(
- new Date().toISOString(),
- id,
- expected.testStatus ?? "",
- expected.lastErrorAt ?? "",
- expected.rateLimitedUntil ?? ""
- );
- const applied = (result.changes ?? 0) > 0;
- if (applied) {
- backupDbFile("pre-write");
- invalidateDbCache("connections");
- bumpProxyConfigGeneration();
- }
- return applied;
-}
-
/**
* Lightweight stat bump — updates lastUsedAt and consecutiveUseCount without
* SELECT, re-encrypt, cache invalidation, or file backup.
@@ -1183,4 +1135,5 @@ export {
formatResetCountdown,
isConnectionRateLimited,
getRateLimitedConnections,
+ clearConnectionErrorIfUnchanged,
} from "./providers/rateLimit";
diff --git a/src/lib/db/providers/codexAccountState.ts b/src/lib/db/providers/codexAccountState.ts
index 77600de3bd..ab3c0fc57b 100644
--- a/src/lib/db/providers/codexAccountState.ts
+++ b/src/lib/db/providers/codexAccountState.ts
@@ -18,10 +18,105 @@ interface DbLike {
type CodexScopedQuotaPatch = {
quotaState?: JsonRecord;
exhaustedWindow?: "5h" | "7d" | null;
- rateLimitedUntil?: string;
+ rateLimitedUntil?: string | null;
rateLimitSource?: "fallback" | "quota_reset";
};
+const CODEX_CHILD_COOLDOWN_KEYS = [
+ "codexScopeRateLimitedUntil",
+ "codexScopeRateLimitSource",
+] as const;
+
+function omitEmptyRecord(record: JsonRecord): JsonRecord | undefined {
+ return Object.keys(record).length > 0 ? record : undefined;
+}
+
+/** Drop nested Codex child cooldowns; keep quota snapshots and unrelated keys. */
+export function stripCodexChildCooldownFields(psd: JsonRecord): JsonRecord {
+ if (!connectionHasCodexChildCooldown(psd)) return psd;
+ const next = { ...psd };
+ for (const key of CODEX_CHILD_COOLDOWN_KEYS) delete next[key];
+ return next;
+}
+
+/** PUT/CAS payload that clears the parent column must also drop nested maps. */
+export function applyCodexChildCooldownClearOnUpdate(
+ data: JsonRecord,
+ psd: T
+): T {
+ if (psd == null) return psd;
+ if (!Object.hasOwn(data, "rateLimitedUntil")) return psd;
+ if (data.rateLimitedUntil != null && data.rateLimitedUntil !== "") return psd;
+ return stripCodexChildCooldownFields(psd) as T;
+}
+
+function connectionHasCodexChildCooldown(psd: JsonRecord): boolean {
+ return "codexScopeRateLimitedUntil" in psd || "codexScopeRateLimitSource" in psd;
+}
+
+/**
+ * Persist a full-parent cooldown lift into the nested Codex child maps.
+ * When `alsoClearTopLevel` is set, the parent `rate_limited_until` column is
+ * nulled in the same transaction so a crash between the two writes cannot
+ * leave a nested child map behind a cleared parent column.
+ */
+export function stripCodexChildCooldownsFromConnection(
+ id: string,
+ options?: { alsoClearTopLevel?: boolean }
+): void {
+ if (typeof id !== "string" || id.length === 0) return;
+ const db = getDbInstance() as unknown as DbLike;
+ const alsoClearTopLevel = options?.alsoClearTopLevel === true;
+ const candidate = db
+ .prepare("SELECT provider FROM provider_connections WHERE id = ?")
+ .get(id);
+ const isCodex = toRecord(candidate).provider === "codex";
+ if (!alsoClearTopLevel && !isCodex) return;
+
+ backupDbFile("pre-write");
+ const wrote = db.transaction(() => {
+ const existing = db
+ .prepare(
+ "SELECT provider, provider_specific_data FROM provider_connections WHERE id = ?"
+ )
+ .get(id);
+ if (!existing) return false;
+ const existingRecord = toRecord(rowToCamel(existing));
+ const providerSpecificData = toRecord(existingRecord.providerSpecificData);
+ const stripNested =
+ existingRecord.provider === "codex" &&
+ connectionHasCodexChildCooldown(providerSpecificData);
+ if (!alsoClearTopLevel && !stripNested) return false;
+
+ const now = new Date().toISOString();
+ if (alsoClearTopLevel && stripNested) {
+ db.prepare(
+ `UPDATE provider_connections
+ SET rate_limited_until = NULL,
+ provider_specific_data = ?,
+ updated_at = ?
+ WHERE id = ?`
+ ).run(JSON.stringify(stripCodexChildCooldownFields(providerSpecificData)), now, id);
+ return true;
+ }
+ if (alsoClearTopLevel) {
+ db.prepare(
+ `UPDATE provider_connections
+ SET rate_limited_until = NULL, updated_at = ?
+ WHERE id = ?`
+ ).run(now, id);
+ return true;
+ }
+ db.prepare(
+ `UPDATE provider_connections
+ SET provider_specific_data = ?, updated_at = ?
+ WHERE id = ?`
+ ).run(JSON.stringify(stripCodexChildCooldownFields(providerSpecificData)), now, id);
+ return true;
+ })();
+ if (wrote) invalidateDbCache("connections");
+}
+
/**
* Atomically merge one virtual Codex child's quota evidence into its persisted parent.
* The transaction reads the latest row so sibling child state cannot be lost.
@@ -70,28 +165,35 @@ export async function updateCodexScopedQuotaState(
}
}
- if (patch.rateLimitedUntil) {
- const scopeCooldowns = toRecord(providerSpecificData.codexScopeRateLimitedUntil);
- const sourceByScope = toRecord(providerSpecificData.codexScopeRateLimitSource);
- const existingCooldownMs =
- typeof scopeCooldowns[scope] === "string"
- ? new Date(scopeCooldowns[scope] as string).getTime()
- : NaN;
- const existingIsAuthoritative =
- sourceByScope[scope] === "quota_reset" &&
- patch.rateLimitSource !== "quota_reset" &&
- Number.isFinite(existingCooldownMs) &&
- existingCooldownMs > Date.now();
- nextProviderSpecificData.codexScopeRateLimitedUntil = {
- ...scopeCooldowns,
- [scope]: existingIsAuthoritative ? scopeCooldowns[scope] : patch.rateLimitedUntil,
- };
- nextProviderSpecificData.codexScopeRateLimitSource = {
- ...sourceByScope,
- [scope]: existingIsAuthoritative
+ if (patch.rateLimitedUntil !== undefined) {
+ const scopeCooldowns = { ...toRecord(providerSpecificData.codexScopeRateLimitedUntil) };
+ const sourceByScope = { ...toRecord(providerSpecificData.codexScopeRateLimitSource) };
+ if (patch.rateLimitedUntil) {
+ const existingCooldownMs =
+ typeof scopeCooldowns[scope] === "string"
+ ? new Date(scopeCooldowns[scope] as string).getTime()
+ : NaN;
+ const existingIsAuthoritative =
+ sourceByScope[scope] === "quota_reset" &&
+ patch.rateLimitSource !== "quota_reset" &&
+ Number.isFinite(existingCooldownMs) &&
+ existingCooldownMs > Date.now();
+ scopeCooldowns[scope] = existingIsAuthoritative
+ ? scopeCooldowns[scope]
+ : patch.rateLimitedUntil;
+ sourceByScope[scope] = existingIsAuthoritative
? sourceByScope[scope]
- : (patch.rateLimitSource ?? "fallback"),
- };
+ : (patch.rateLimitSource ?? "fallback");
+ } else {
+ delete scopeCooldowns[scope];
+ delete sourceByScope[scope];
+ }
+ const nextCooldowns = omitEmptyRecord(scopeCooldowns);
+ const nextSources = omitEmptyRecord(sourceByScope);
+ if (nextCooldowns) nextProviderSpecificData.codexScopeRateLimitedUntil = nextCooldowns;
+ else delete nextProviderSpecificData.codexScopeRateLimitedUntil;
+ if (nextSources) nextProviderSpecificData.codexScopeRateLimitSource = nextSources;
+ else delete nextProviderSpecificData.codexScopeRateLimitSource;
}
db.prepare(
@@ -106,6 +208,107 @@ export async function updateCodexScopedQuotaState(
return persisted;
}
+/** Grace window absorbing clock skew against the upstream quota server. */
+const QUOTA_RESET_CLOCK_SKEW_GRACE_MS = 30_000;
+
+/** Cheap probe: does this connection+scope currently carry a child cooldown? */
+export function hasCodexScopeCooldown(id: string, scope: "codex" | "spark"): boolean {
+ if (typeof id !== "string" || id.length === 0) return false;
+ const db = getDbInstance() as unknown as DbLike;
+ const row = db
+ .prepare("SELECT provider, provider_specific_data FROM provider_connections WHERE id = ?")
+ .get(id);
+ if (!row) return false;
+ const record = toRecord(rowToCamel(row));
+ if (record.provider !== "codex") return false;
+ const psd = toRecord(record.providerSpecificData);
+ return Boolean(toRecord(psd.codexScopeRateLimitedUntil)[scope]);
+}
+
+/**
+ * #12860: When fresh quota snapshot data demonstrates headroom on a scope,
+ * lift any fallback-sourced cooldown (e.g. parked by quota preflight).
+ * Cooldowns sourced from upstream 429 quota_reset retain their authority
+ * until their reset timestamp has elapsed.
+ */
+export function liftCodexScopeCooldownOnHeadroom(
+ id: string,
+ scope: "codex" | "spark"
+): boolean {
+ if (typeof id !== "string" || id.length === 0) return false;
+ const db = getDbInstance() as unknown as DbLike;
+
+ // Cheap eligibility probe so the backup only runs when a write is plausible.
+ // The transaction below re-reads under lock and remains the authority.
+ if (!hasCodexScopeCooldown(id, scope)) return false;
+
+ backupDbFile("pre-write");
+ const wrote = db.transaction(() => {
+ const existing = db
+ .prepare(
+ "SELECT provider, provider_specific_data FROM provider_connections WHERE id = ?"
+ )
+ .get(id);
+ if (!existing) return false;
+ const existingRecord = toRecord(rowToCamel(existing));
+ if (existingRecord.provider !== "codex") return false;
+ const currentPsd = toRecord(existingRecord.providerSpecificData);
+ const currentCooldowns = { ...toRecord(currentPsd.codexScopeRateLimitedUntil) };
+ if (!currentCooldowns[scope]) return false;
+
+ const currentSources = { ...toRecord(currentPsd.codexScopeRateLimitSource) };
+ const curSource = currentSources[scope];
+ const curUntilMs =
+ typeof currentCooldowns[scope] === "string"
+ ? new Date(currentCooldowns[scope] as string).getTime()
+ : NaN;
+ // An upstream-authoritative `quota_reset` deadline outranks a local snapshot:
+ // the grace window absorbs clock skew against the quota server, so a fast
+ // local clock cannot lift a cooldown upstream still considers active.
+ if (
+ curSource === "quota_reset" &&
+ Number.isFinite(curUntilMs) &&
+ curUntilMs > Date.now() - QUOTA_RESET_CLOCK_SKEW_GRACE_MS
+ ) {
+ return false;
+ }
+
+ delete currentCooldowns[scope];
+ delete currentSources[scope];
+
+ const nextPsd: JsonRecord = { ...currentPsd };
+ const nextCooldowns = omitEmptyRecord(currentCooldowns);
+ const nextSources = omitEmptyRecord(currentSources);
+ if (nextCooldowns) nextPsd.codexScopeRateLimitedUntil = nextCooldowns;
+ else delete nextPsd.codexScopeRateLimitedUntil;
+ if (nextSources) nextPsd.codexScopeRateLimitSource = nextSources;
+ else delete nextPsd.codexScopeRateLimitSource;
+
+ const exhaustedByScope = { ...toRecord(currentPsd.codexExhaustedWindowByScope) };
+ if (exhaustedByScope[scope]) {
+ delete exhaustedByScope[scope];
+ const nextExhausted = omitEmptyRecord(exhaustedByScope);
+ if (nextExhausted) {
+ nextPsd.codexExhaustedWindowByScope = nextExhausted;
+ } else {
+ delete nextPsd.codexExhaustedWindowByScope;
+ delete nextPsd.codexExhaustedWindow;
+ }
+ }
+
+ const now = new Date().toISOString();
+ db.prepare(
+ `UPDATE provider_connections
+ SET provider_specific_data = ?, updated_at = ?
+ WHERE id = ?`
+ ).run(JSON.stringify(nextPsd), now, id);
+ return true;
+ })();
+
+ if (wrote) invalidateDbCache("connections");
+ return wrote;
+}
+
/** Persist one child cooldown through the shared scoped quota-state transaction. */
export async function updateCodexScopeCooldown(
id: string,
diff --git a/src/lib/db/providers/rateLimit.ts b/src/lib/db/providers/rateLimit.ts
index 7812080a0f..c15dd3b2ee 100644
--- a/src/lib/db/providers/rateLimit.ts
+++ b/src/lib/db/providers/rateLimit.ts
@@ -4,6 +4,9 @@
import { getDbInstance } from "../core";
import { invalidateDbCache } from "../readCache";
+import { backupDbFile } from "../backup";
+import { bumpProxyConfigGeneration } from "../settings";
+import { stripCodexChildCooldownsFromConnection } from "./codexAccountState";
interface StatementLike {
all: (...params: unknown[]) => TRow[];
@@ -28,6 +31,15 @@ interface DbLike {
* @param until - Epoch ms when the rate limit expires (null to clear)
*/
export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void {
+ // Guard: never persist a non-finite or already-expired timestamp. The TEXT
+ // column would store "NaN"/"Infinity" and pollute every future read. null
+ // is the only clear path (via clearConnectionRateLimit); past/zero
+ // timestamps are noops so an expired write cannot overwrite a live row.
+ if (until !== null && (!Number.isFinite(until) || until <= Date.now())) return;
+ if (until == null) {
+ stripCodexChildCooldownsFromConnection(connectionId, { alsoClearTopLevel: true });
+ return;
+ }
const db = getDbInstance() as unknown as DbLike;
db.prepare(
"UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?"
@@ -229,6 +241,72 @@ export function clearStaleCrashCooldowns(): { cleared: number } {
return { cleared: toReset.length };
}
+/**
+ * Atomic conditional clear of recoverable error state on a connection row.
+ *
+ * Returns true when the row was cleared, false when a concurrent writer
+ * (markAccountUnavailable, connectionRecovery tick, test, etc.) changed the
+ * row between the caller's snapshot read and this UPDATE — in which case the
+ * clear is skipped to preserve the freshest error state. Closes the TOCTOU
+ * window in the quota-recovery path.
+ *
+ * CAS token = (test_status, last_error_at, rate_limited_until).
+ * Nested Codex child cooldown maps are stripped in the same UPDATE so a
+ * concurrent writer cannot re-persist them between two statements.
+ */
+export async function clearConnectionErrorIfUnchanged(
+ id: string,
+ expected: {
+ testStatus: string | null | undefined;
+ lastErrorAt: string | null | undefined;
+ rateLimitedUntil: string | null | undefined;
+ }
+): Promise {
+ const db = getDbInstance() as unknown as DbLike;
+ backupDbFile("pre-write");
+ const result = db
+ .prepare(
+ `
+ UPDATE provider_connections SET
+ test_status = 'active',
+ last_error = NULL,
+ last_error_at = NULL,
+ last_error_type = NULL,
+ last_error_source = NULL,
+ error_code = NULL,
+ rate_limited_until = NULL,
+ backoff_level = 0,
+ provider_specific_data = CASE
+ WHEN provider = 'codex' AND json_valid(provider_specific_data)
+ THEN json_remove(
+ provider_specific_data,
+ '$.codexScopeRateLimitedUntil',
+ '$.codexScopeRateLimitSource'
+ )
+ ELSE provider_specific_data
+ END,
+ updated_at = ?
+ WHERE id = ?
+ AND IFNULL(test_status, '') = ?
+ AND IFNULL(last_error_at, '') = ?
+ AND IFNULL(rate_limited_until, '') = ?
+ `
+ )
+ .run(
+ new Date().toISOString(),
+ id,
+ expected.testStatus ?? "",
+ expected.lastErrorAt ?? "",
+ expected.rateLimitedUntil ?? ""
+ );
+ const applied = (result.changes ?? 0) > 0;
+ if (applied) {
+ invalidateDbCache("connections");
+ bumpProxyConfigGeneration();
+ }
+ return applied;
+}
+
// T13: Format a reset countdown as a human-readable string ("2h 35m" / "4m 30s").
// The implementation lives in the client-safe formatting utils so client
// components (e.g. CoolingConnectionsPanel) can import it without pulling this
diff --git a/src/lib/db/quotaSnapshots.ts b/src/lib/db/quotaSnapshots.ts
index 1e77cc47fb..5a2391130e 100644
--- a/src/lib/db/quotaSnapshots.ts
+++ b/src/lib/db/quotaSnapshots.ts
@@ -1,5 +1,10 @@
import { getDbInstance, rowToCamel } from "./core";
import type { QuotaSnapshotRow, ProviderUtilizationPoint } from "@/shared/types/utilization";
+import {
+ hasCodexScopeCooldown,
+ liftCodexScopeCooldownOnHeadroom,
+} from "./providers/codexAccountState";
+import { isCodexSparkQuotaKey } from "@omniroute/open-sse/config/codexQuotaScopes";
type JsonRecord = Record;
@@ -45,6 +50,61 @@ export function saveQuotaSnapshot(snapshot: Omit & {
+ windowKey?: string;
+ remainingPercentage?: number;
+ isExhausted?: number;
+};
+
+function snapshotHasHeadroom(s: SnapshotShape): boolean {
+ const pct = s.remainingPercentage ?? s.remaining_percentage ?? 0;
+ const exhausted = s.isExhausted ?? s.is_exhausted ?? 0;
+ return pct > 0 && exhausted !== 1;
+}
+
+function maybeLiftCodexCooldownOnHeadroom(
+ snapshot: Omit
+): void {
+ if (
+ snapshot.provider?.toLowerCase() !== "codex" ||
+ typeof snapshot.connection_id !== "string" ||
+ snapshot.connection_id.length === 0 ||
+ (snapshot.remaining_percentage ?? 0) <= 0 ||
+ snapshot.is_exhausted === 1
+ ) {
+ return;
+ }
+
+ try {
+ const scope = isCodexSparkQuotaKey(snapshot.window_key) ? "spark" : "codex";
+ // The scope-wide read is only worth paying for when a cooldown is
+ // actually parked on this connection+scope; the common case is clean.
+ if (!hasCodexScopeCooldown(snapshot.connection_id, scope)) return;
+
+ const scopeWindows = getLatestQuotaSnapshotsForConnection(snapshot.connection_id).filter(
+ (s: SnapshotShape) => {
+ const key = s.windowKey ?? s.window_key;
+ return scope === "spark" ? isCodexSparkQuotaKey(key) : !isCodexSparkQuotaKey(key);
+ }
+ );
+ // Every window in the scope must be healthy: one exhausted window still
+ // justifies the cooldown even when a sibling reports full headroom.
+ if (scopeWindows.length > 0 && scopeWindows.every(snapshotHasHeadroom)) {
+ liftCodexScopeCooldownOnHeadroom(snapshot.connection_id, scope);
+ }
+ } catch (err) {
+ console.debug("[QuotaSnapshots] Headroom evaluation skipped:", err);
+ }
}
export function getQuotaSnapshots(opts: {
diff --git a/src/lib/db/walMaintenance.ts b/src/lib/db/walMaintenance.ts
new file mode 100644
index 0000000000..8dfad87bad
--- /dev/null
+++ b/src/lib/db/walMaintenance.ts
@@ -0,0 +1,236 @@
+import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
+import { isNextBuildPhase } from "../buildPhase";
+import type { SqliteAdapter } from "./adapters/types";
+import { registerDbStateResetter } from "./stateReset";
+
+/**
+ * WAL maintenance owns the periodic `wal_checkpoint(TRUNCATE)` lifecycle that
+ * used to live inside `core.ts`: interval parsing, the scheduler, and reading
+ * the pragma result so a busy checkpoint warns instead of logging success.
+ */
+export type WalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
+
+export interface WalCheckpointOutcome {
+ ok: boolean;
+ busy: boolean;
+ skipped: boolean;
+ logFrames: number | null;
+ checkpointedFrames: number | null;
+ error: string | null;
+}
+
+export interface WalCheckpointContext {
+ sqliteFile?: string | null;
+ isCloud?: boolean;
+ isBuildPhase?: boolean;
+}
+
+export interface WalMaintenanceState {
+ ticks: number;
+ busyStreak: number;
+ busyTotal: number;
+ lastBusyAt: string | null;
+ lastOkAt: string | null;
+}
+
+const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
+
+const DEFAULT_WAL_TRUNCATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
+const RETRY_DELAY_MS = 60_000;
+
+let walTimer: NodeJS.Timeout | null = null;
+let retryTimer: NodeJS.Timeout | null = null;
+let ticks = 0;
+let busyStreak = 0;
+let busyTotal = 0;
+let lastBusyAt: string | null = null;
+let lastOkAt: string | null = null;
+
+function recordBusy(): void {
+ busyStreak++;
+ busyTotal++;
+ lastBusyAt = new Date().toISOString();
+}
+
+function recordOk(): void {
+ busyStreak = 0;
+ lastOkAt = new Date().toISOString();
+}
+
+function toFiniteNumber(value: unknown): number | null {
+ const num = Number(value);
+ return Number.isFinite(num) ? num : null;
+}
+
+function failOpen(): WalCheckpointOutcome {
+ return {
+ ok: true,
+ busy: false,
+ skipped: false,
+ logFrames: null,
+ checkpointedFrames: null,
+ error: null,
+ };
+}
+
+function parseCheckpointRow(result: unknown): WalCheckpointOutcome {
+ const row = Array.isArray(result) ? result[0] : result;
+ if (row === undefined || row === null) return failOpen();
+ if (typeof row !== "object") return failOpen();
+ const record = row as Record;
+ const busy = toFiniteNumber(record.busy);
+ const logFrames = toFiniteNumber(record.log);
+ const checkpointedFrames = toFiniteNumber(record.checkpointed);
+ if (busy === null || logFrames === null || checkpointedFrames === null) return failOpen();
+ return {
+ ok: busy !== 1,
+ busy: busy === 1,
+ skipped: false,
+ logFrames,
+ checkpointedFrames,
+ error: null,
+ };
+}
+
+export function runCheckpointNow(
+ db: SqliteAdapter,
+ mode: WalCheckpointMode = "TRUNCATE",
+ ctx: WalCheckpointContext = {}
+): WalCheckpointOutcome {
+ if (ctx.sqliteFile === null || ctx.isCloud === true || ctx.isBuildPhase === true) {
+ return {
+ ok: false,
+ busy: false,
+ skipped: true,
+ logFrames: null,
+ checkpointedFrames: null,
+ error: null,
+ };
+ }
+ try {
+ return parseCheckpointRow(db.pragma(`wal_checkpoint(${mode})`));
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ ok: false,
+ busy: false,
+ skipped: false,
+ logFrames: null,
+ checkpointedFrames: null,
+ error: message,
+ };
+ }
+}
+
+export function getWalMaintenanceIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
+ const rawValue = env.OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS;
+ if (typeof rawValue === "string" && rawValue.trim().length > 0) {
+ const parsed = Number(rawValue);
+ if (Number.isFinite(parsed) && parsed >= 0) {
+ return parsed;
+ }
+ }
+ return DEFAULT_WAL_TRUNCATE_INTERVAL_MS;
+}
+
+export function logCheckpointOutcome(
+ outcome: WalCheckpointOutcome,
+ mode: WalCheckpointMode,
+ streak: number
+): void {
+ if (outcome.skipped) return;
+ if (outcome.busy) {
+ console.warn(
+ `[DB] SQLite WAL checkpoint busy — ${outcome.logFrames} frames pending (streak ${streak})`
+ );
+ return;
+ }
+ if (!outcome.ok) {
+ console.warn(
+ `[DB] SQLite WAL checkpoint failed (${mode}): ${outcome.error ?? "unknown error"}`
+ );
+ return;
+ }
+ console.log(`[DB] SQLite WAL checkpoint completed (${mode})`);
+}
+
+function schedulePassiveRetry(db: SqliteAdapter): void {
+ if (retryTimer) return;
+ retryTimer = setTimeout(() => {
+ retryTimer = null;
+ try {
+ if (isCloud || isNextBuildPhase() || isAutomatedTestProcess()) return;
+ if (!db.open) return;
+ const outcome = runCheckpointNow(db, "PASSIVE");
+ if (outcome.skipped) return;
+ if (outcome.busy) {
+ recordBusy();
+ logCheckpointOutcome(outcome, "PASSIVE", busyStreak);
+ } else if (outcome.ok) {
+ recordOk();
+ } else {
+ logCheckpointOutcome(outcome, "PASSIVE", busyStreak);
+ }
+ } catch {
+ // A periodic retry must never throw into the event loop.
+ }
+ }, RETRY_DELAY_MS);
+ retryTimer.unref?.();
+}
+
+export function startWalMaintenance(
+ db: SqliteAdapter,
+ sqliteFile: string | null,
+ env: NodeJS.ProcessEnv = process.env
+): void {
+ stopWalMaintenance();
+ if (sqliteFile === null || isCloud || isNextBuildPhase() || isAutomatedTestProcess()) return;
+ const intervalMs = getWalMaintenanceIntervalMs(env);
+ if (intervalMs <= 0) return;
+ walTimer = setInterval(() => {
+ try {
+ if (!db.open) return;
+ const outcome = runCheckpointNow(db, "TRUNCATE");
+ if (outcome.skipped) return;
+ ticks++;
+ if (outcome.busy) {
+ recordBusy();
+ logCheckpointOutcome(outcome, "TRUNCATE", busyStreak);
+ schedulePassiveRetry(db);
+ } else if (outcome.ok) {
+ recordOk();
+ } else {
+ logCheckpointOutcome(outcome, "TRUNCATE", busyStreak);
+ }
+ } catch {
+ // A periodic scheduler must never throw into the event loop.
+ }
+ }, intervalMs);
+ walTimer.unref?.();
+}
+
+export function stopWalMaintenance(): void {
+ if (walTimer) {
+ clearInterval(walTimer);
+ walTimer = null;
+ }
+ if (retryTimer) {
+ clearTimeout(retryTimer);
+ retryTimer = null;
+ }
+ ticks = 0;
+ busyStreak = 0;
+ busyTotal = 0;
+ lastBusyAt = null;
+ lastOkAt = null;
+}
+
+export function getWalMaintenanceState(): WalMaintenanceState {
+ return { ticks, busyStreak, busyTotal, lastBusyAt, lastOkAt };
+}
+
+export function __resetForTests(): void {
+ stopWalMaintenance();
+}
+
+registerDbStateResetter(stopWalMaintenance);
diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts
index 747afa0b91..91854bde4a 100644
--- a/src/lib/monitoring/observability.ts
+++ b/src/lib/monitoring/observability.ts
@@ -4,6 +4,7 @@ import {
} from "@omniroute/open-sse/services/codexAccount/index.ts";
import type { AdaptiveAdmissionPublicSnapshot } from "@omniroute/open-sse/services/admission/runtime.ts";
import type { PerConnectionAdmissionController } from "@/shared/middleware/chatBodyAdmission";
+import type { WalMaintenanceState } from "@/lib/db/walMaintenance";
type JsonRecord = Record;
@@ -37,6 +38,32 @@ export type ChatAdmissionHealthSummary = {
countCapEnabled: boolean;
};
+/**
+ * WAL maintenance health summary (#12853) — the periodic TRUNCATE lifecycle
+ * from walMaintenance.ts. Fixed low-cardinality shape, never raw-spread.
+ */
+export type WalMaintenanceSnapshot = Pick<
+ WalMaintenanceState,
+ "ticks" | "busyStreak" | "busyTotal" | "lastBusyAt" | "lastOkAt"
+>;
+
+/**
+ * Explicit allowlisted projection of the WAL maintenance state.
+ * Copies only the documented scalar fields — no timers, no internals.
+ */
+export function projectWalMaintenanceSummary(
+ state: WalMaintenanceState | null | undefined
+): WalMaintenanceSnapshot | null {
+ if (!state || typeof state !== "object") return null;
+ return {
+ ticks: state.ticks,
+ busyStreak: state.busyStreak,
+ busyTotal: state.busyTotal,
+ lastBusyAt: state.lastBusyAt,
+ lastOkAt: state.lastOkAt,
+ };
+}
+
/**
* Explicit allowlisted projection of the structural admission snapshot.
* Never spreads the snapshot — only the documented low-cardinality fields pass.
@@ -217,6 +244,8 @@ interface BuildHealthPayloadOptions {
adaptiveAdmission?: AdaptiveAdmissionPublicSnapshot | null;
/** #11244: optional structural chat-admission snapshot; projected, never raw-spread. */
chatAdmission?: ChatAdmissionSnapshot | null;
+ /** #12853: optional WAL maintenance snapshot; projected, never raw-spread. */
+ walMaintenance?: WalMaintenanceSnapshot | null;
}
function limitMonitors(monitors: QuotaMonitorSnapshot[], maxItems = 8): QuotaMonitorSnapshot[] {
@@ -405,6 +434,7 @@ export function buildHealthPayload({
credentialHealth,
adaptiveAdmission = null,
chatAdmission = null,
+ walMaintenance = null,
buildSha = null,
}: BuildHealthPayloadOptions) {
const timestamp = new Date().toISOString();
@@ -510,6 +540,9 @@ export function buildHealthPayload({
// #11244: the STRUCTURAL gate (chatBodyAdmission.ts) next to the adaptive one —
// distinct key so clients reading `adaptiveAdmission` are untouched.
chatAdmission: projectChatAdmissionSummary(chatAdmission),
+ // #12853: WAL maintenance next to the admission gates — additive key,
+ // nothing existing moves.
+ walMaintenance: projectWalMaintenanceSummary(walMaintenance),
dedup: {
inflightRequests,
},
diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts
index aaa72497d2..301ad61f75 100644
--- a/src/lib/providers/requestDefaults.ts
+++ b/src/lib/providers/requestDefaults.ts
@@ -346,6 +346,8 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec
delete sanitized.alibabaConsoleSecToken;
delete sanitized.runtimeKey;
delete sanitized.validationId;
+ delete sanitized.volcConsoleCookie;
+ delete sanitized.volcCsrfToken;
// System-managed Codex fingerprint seed: never exposed through the API
// (mirrors sub2api stripping `codex_fingerprint_seed`); the server-side
// partial-update merge keeps it alive without the client round-tripping it.
diff --git a/src/lib/providers/volcenginePlanBinding.ts b/src/lib/providers/volcenginePlanBinding.ts
index ae989d5eae..96d2e34fba 100644
--- a/src/lib/providers/volcenginePlanBinding.ts
+++ b/src/lib/providers/volcenginePlanBinding.ts
@@ -118,7 +118,20 @@ export async function detectPlan(
if (!result.ok) {
return { available: false, usage: {}, error: result.error };
}
- return { available: true, usage: record(result.json.Result), error: null };
+ const usage = record(result.json.Result);
+ if (kind === "coding") {
+ const quotaUsage = usage.QuotaUsage;
+ if (!Array.isArray(quotaUsage) || quotaUsage.length === 0) {
+ return { available: false, usage: {}, error: "No active Coding Plan quota windows" };
+ }
+ } else if (kind === "agent") {
+ const hasFiveHour = Boolean(usage.AFPFiveHour && Object.keys(record(usage.AFPFiveHour)).length > 0);
+ const hasWeekly = Boolean(usage.AFPWeekly && Object.keys(record(usage.AFPWeekly)).length > 0);
+ if (!hasFiveHour && !hasWeekly) {
+ return { available: false, usage: {}, error: "No active Agent Plan quota windows" };
+ }
+ }
+ return { available: true, usage, error: null };
}
function firstApiKeyItem(result: JsonRecord): JsonRecord | null {
@@ -173,16 +186,102 @@ async function fetchRawApiKey(
return { apiKey, id, maskedKey: stringField(item?.Key) || null, error: null };
}
-async function upsertConnection(
+export interface FindTargetConnectionCriteria {
+ targetConnectionId?: string;
+ provider: string;
+ apiKey?: string;
+ apiKeyId?: number | null;
+ defaultName: string;
+}
+
+/**
+ * Pure matcher to find an existing connection to adopt or update during console binding.
+ *
+ * Matching precedence:
+ * 1. targetConnectionId priority match (strictly verified against criteria.provider).
+ * 2. Exact apiKey match, or volcApiKeyId match (numeric and > 0).
+ * 3. Canonical default name match (e.g. 'Volcano Ark Coding Plan').
+ * 4. Intentional fallback: safe adoption for single existing connection under this provider.
+ * - Design rationale: Operators commonly created custom connections (e.g. named 'main')
+ * prior to console login. This fallback connects console cookies to that sole instance.
+ * - Safety boundary: strictly scoped to allConnections.length === 1 so that multiple
+ * distinct accounts are never silently clobbered.
+ * 5. When multiple connections exist and none match: returns undefined (triggers new connection creation).
+ */
+export function findTargetConnection(
+ allConnections: JsonRecord[],
+ criteria: FindTargetConnectionCriteria
+): JsonRecord | undefined {
+ // 1. targetConnectionId priority match (with provider affinity check)
+ if (criteria.targetConnectionId) {
+ const matched = allConnections.find(
+ (conn) =>
+ stringField(conn.id) === criteria.targetConnectionId &&
+ stringField(conn.provider) === criteria.provider
+ );
+ if (matched) return matched;
+ }
+
+ // 2. Match by valid apiKey or valid volcApiKeyId
+ if (criteria.apiKey) {
+ const matched = allConnections.find(
+ (conn) =>
+ stringField(conn.apiKey) === criteria.apiKey &&
+ stringField(conn.provider) === criteria.provider
+ );
+ if (matched) return matched;
+ }
+ if (typeof criteria.apiKeyId === "number" && criteria.apiKeyId > 0) {
+ const matched = allConnections.find((conn) => {
+ const psd = record(conn.providerSpecificData);
+ return (
+ Number(psd.volcApiKeyId) === criteria.apiKeyId &&
+ stringField(conn.provider) === criteria.provider
+ );
+ });
+ if (matched) return matched;
+ }
+
+ // 3. Match by canonical default name
+ const nameMatched = allConnections.find(
+ (conn) =>
+ stringField(conn.name) === criteria.defaultName &&
+ stringField(conn.provider) === criteria.provider
+ );
+ if (nameMatched) return nameMatched;
+
+ // 4. Safe adoption for single connection scenario under matching provider (e.g. user-named 'main')
+ if (allConnections.length === 1 && stringField(allConnections[0].provider) === criteria.provider) {
+ return allConnections[0];
+ }
+
+ // 5. Multiple connections exist and none matched -> do not clobber; return undefined
+ return undefined;
+}
+
+export async function upsertConnection(
kind: PlanKind,
apiKey: string,
cookieHeader: string,
csrfToken: string,
apiKeyId: number | null,
- usage: JsonRecord
+ usage: JsonRecord,
+ targetConnectionId?: string
) {
const cfg = PLAN_CONFIG[kind];
+ const allConnections = (await getProviderConnections({ provider: cfg.provider })) as JsonRecord[];
+
+ const matched = findTargetConnection(allConnections, {
+ targetConnectionId,
+ provider: cfg.provider,
+ apiKey,
+ apiKeyId,
+ defaultName: cfg.name,
+ });
+
+ const existingPsd = matched ? record(matched.providerSpecificData) : {};
const providerSpecificData = {
+ ...existingPsd,
volcConsoleCookie: cookieHeader,
volcCsrfToken: csrfToken,
volcApiKeyId: apiKeyId,
@@ -192,14 +291,10 @@ async function upsertConnection(
autoSync: true,
};
- const existing = (await getProviderConnections({ provider: cfg.provider })).find(
- (conn: JsonRecord) => stringField(conn.name) === cfg.name
- );
-
- if (existing?.id) {
- return await updateProviderConnection(stringField(existing.id), {
+ if (matched?.id) {
+ return await updateProviderConnection(stringField(matched.id), {
apiKey,
- name: cfg.name,
+ name: stringField(matched.name) || cfg.name,
providerSpecificData,
isActive: true,
testStatus: "active",
@@ -217,7 +312,10 @@ async function upsertConnection(
});
}
-export async function bindVolcenginePlansFromConsoleCredentials(credentials: JsonRecord) {
+export async function bindVolcenginePlansFromConsoleCredentials(
+ credentials: JsonRecord,
+ options?: { targetConnectionId?: string }
+) {
const cookieHeader = buildCookieHeader(credentials);
const csrfToken = extractCsrf(credentials, cookieHeader);
if (!cookieHeader || !csrfToken) {
@@ -260,7 +358,8 @@ export async function bindVolcenginePlansFromConsoleCredentials(credentials: Jso
cookieHeader,
csrfToken,
key.id,
- detected.usage
+ detected.usage,
+ options?.targetConnectionId
);
results.push({
plan: kind,
@@ -277,3 +376,8 @@ export async function bindVolcenginePlansFromConsoleCredentials(credentials: Jso
results,
};
}
+
+export const __testing = {
+ findTargetConnection,
+ upsertConnection,
+};
diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts
index 0eee4331dc..eb0365445a 100644
--- a/src/lib/quota/saturationSignals.ts
+++ b/src/lib/quota/saturationSignals.ts
@@ -51,6 +51,10 @@ const CACHE_TTL_MS = 30_000; // 30 seconds
const _cache = new Map();
+// Pending miss fetches, keyed like _cache. Concurrent getSaturation calls for
+// the same key share the promise instead of firing one upstream read each.
+const _inflight = new Map>();
+
// ---------------------------------------------------------------------------
// Rate-limit header cache (populated by response handlers)
// ---------------------------------------------------------------------------
@@ -259,6 +263,7 @@ function cacheKey(connectionId: string, provider: string, dim: DimensionSpec): s
// Exported for test reset
export function _clearSaturationCache(): void {
_cache.clear();
+ _inflight.clear();
}
// ---------------------------------------------------------------------------
@@ -537,31 +542,40 @@ export async function getSaturation(
return cached.value;
}
- let value = 0;
- try {
- switch (provider) {
- case "codex":
- value = await fetchCodexSaturation(connectionId, dim, connection);
- break;
- case "bailian":
- value = await fetchBailianSaturation(connectionId, dim);
- break;
- case "anthropic":
- case "claude":
- value = await fetchAnthropicSaturation(connectionId, dim);
- break;
- default:
- value = await fetchGenericSaturation(connectionId, provider);
- break;
+ const pending = _inflight.get(key);
+ if (pending) return pending;
+ const task = (async (): Promise => {
+ let value = 0;
+ try {
+ switch (provider) {
+ case "codex":
+ value = await fetchCodexSaturation(connectionId, dim, connection);
+ break;
+ case "bailian":
+ value = await fetchBailianSaturation(connectionId, dim);
+ break;
+ case "anthropic":
+ case "claude":
+ value = await fetchAnthropicSaturation(connectionId, dim);
+ break;
+ default:
+ value = await fetchGenericSaturation(connectionId, provider);
+ break;
+ }
+ } catch (err) {
+ log.warn(
+ { err: (err as Error)?.message, connectionId, provider },
+ "saturation fetch failed — failing open with 0"
+ );
+ value = 0;
}
- } catch (err) {
- log.warn(
- { err: (err as Error)?.message, connectionId, provider },
- "saturation fetch failed — failing open with 0"
- );
- value = 0;
+ _cache.set(key, { value, ts: Date.now() });
+ return value;
+ })();
+ _inflight.set(key, task);
+ try {
+ return await task;
+ } finally {
+ _inflight.delete(key);
}
-
- _cache.set(key, { value, ts: Date.now() });
- return value;
}
diff --git a/src/lib/usage/comboHealth.ts b/src/lib/usage/comboHealth.ts
index a9f12ac1f2..2bb72b5e95 100644
--- a/src/lib/usage/comboHealth.ts
+++ b/src/lib/usage/comboHealth.ts
@@ -32,7 +32,7 @@ type QuotaSnapshotView = {
type ProviderHealth = {
provider: string;
- remainingPct: number;
+ remainingPct: number | null;
isExhausted: boolean;
trend: "improving" | "stable" | "declining";
};
@@ -112,11 +112,12 @@ function calculateGini(values: number[]): number {
return (2 * weightedSum) / (count * sum) - (count + 1) / count;
}
-function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): ProviderHealth {
+export function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): ProviderHealth {
if (snapshots.length === 0) {
return {
provider,
- remainingPct: 0,
+ remainingPct: null,
+ // stable: no data yet, not exhausted — null pct, not 0
isExhausted: false,
trend: "stable",
};
@@ -186,7 +187,7 @@ function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): P
return {
provider,
- remainingPct: roundNumber(lastAverage),
+ remainingPct: lastValues.length === 0 ? null : roundNumber(lastAverage),
isExhausted,
trend,
};
@@ -218,10 +219,10 @@ function buildConnectionHealth(
});
const firstRemaining =
- (firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0;
+ (firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? null;
const lastRemaining =
- (lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0;
- const delta = lastRemaining - firstRemaining;
+ (lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? null;
+ const delta = (lastRemaining ?? 0) - (firstRemaining ?? 0);
let trend: ProviderHealth["trend"] = "stable";
if (delta >= 5) trend = "improving";
@@ -229,7 +230,7 @@ function buildConnectionHealth(
return {
provider: `${provider}:${connectionId}`,
- remainingPct: roundNumber(lastRemaining),
+ remainingPct: lastRemaining === null ? null : roundNumber(lastRemaining),
isExhausted:
(ordered[ordered.length - 1] as unknown as QuotaSnapshotView | undefined)?.isExhausted === 1,
trend,
@@ -317,22 +318,19 @@ function buildPerformance(comboName: string, since: string): ComboHealthMetrics[
};
}
-function buildQuotaHealth(providers: string[], since: string): ComboHealthMetrics["quotaHealth"] {
+export function buildQuotaHealth(providers: string[], since: string): ComboHealthMetrics["quotaHealth"] {
const providerHealth = providers.map((provider) =>
buildProviderHealth(provider, getQuotaSnapshots({ provider, since }))
);
- const worstRemainingPct =
- providerHealth.length > 0
- ? providerHealth.reduce(
- (lowest, entry) => Math.min(lowest, entry.remainingPct),
- providerHealth[0].remainingPct
- )
- : 0;
+ const nonNull = providerHealth
+ .map((entry) => entry.remainingPct)
+ .filter((v): v is number => typeof v === "number");
+ const worst = nonNull.length > 0 ? Math.min(...nonNull) : null;
return {
providers: providerHealth,
- worstRemainingPct: roundNumber(worstRemainingPct),
+ worstRemainingPct: worst === null ? null : roundNumber(worst),
};
}
diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts
index 90fb14fce0..ba519ed9fe 100644
--- a/src/shared/types/utilization.ts
+++ b/src/shared/types/utilization.ts
@@ -58,11 +58,11 @@ export interface ComboHealthMetrics {
quotaHealth: {
providers: Array<{
provider: string;
- remainingPct: number;
+ remainingPct: number | null;
isExhausted: boolean;
trend: "improving" | "stable" | "declining";
}>;
- worstRemainingPct: number;
+ worstRemainingPct: number | null;
};
usageSkew: {
modelDistribution: Array<{
diff --git a/tests/unit/api-health-version-source.test.ts b/tests/unit/api-health-version-source.test.ts
new file mode 100644
index 0000000000..24d32d2be6
--- /dev/null
+++ b/tests/unit/api-health-version-source.test.ts
@@ -0,0 +1,24 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import path from "node:path";
+import { GET } from "@/app/api/health/route";
+import { APP_CONFIG } from "@/shared/constants/appConfig";
+
+test("GET /api/health stays minimal: 200, no version anywhere", async () => {
+ const res = await GET();
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get("ETag"), null);
+ assert.equal(res.headers.get("X-OmniRoute-Version"), null);
+ const body = (await res.json()) as Record;
+ assert.equal(body.status, "ok");
+ assert.ok(typeof body.timestamp === "string");
+ assert.ok(!("version" in body));
+});
+
+test("system/version reads no package.json directly", async () => {
+ const { readFileSync } = await import("node:fs");
+ const src = readFileSync(path.join(process.cwd(), "src/app/api/system/version/route.ts"), "utf8");
+ assert.ok(!src.includes("require("), "direct require(package.json) must be gone");
+ assert.ok(src.includes("APP_CONFIG.version"));
+ assert.equal(typeof APP_CONFIG.version, "string");
+});
diff --git a/tests/unit/changelog-fragments.test.ts b/tests/unit/changelog-fragments.test.ts
index 2dddf03613..7c503fa16d 100644
--- a/tests/unit/changelog-fragments.test.ts
+++ b/tests/unit/changelog-fragments.test.ts
@@ -189,3 +189,63 @@ test("SECTIONS maps every dir to a real living-section heading in the fixture",
assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`);
}
});
+
+test("insertBullets with a version anchors on THAT section even when [Unreleased] has the same heading", () => {
+ const withUnreleasedHeading = `# Changelog
+
+## [Unreleased]
+
+### ✨ New Features
+
+- **unreleased**: leftover from an older cycle (#9)
+
+## [3.8.47] — TBD
+
+### ✨ New Features
+
+- **existing feature**: already here (#1 — thanks @a)
+
+### 🐛 Bug Fixes
+
+- **fix(x):** existing fix (#2 — thanks @b)
+
+## [3.8.46] - 2026-07-04
+
+### ✨ New Features
+
+- **old feature**: shipped (#0)
+`;
+ const out = insertBullets(
+ withUnreleasedHeading,
+ { features: [{ text: "- **new**: landed (#4)" }] },
+ "3.8.47"
+ );
+ const unreleased = out.slice(out.indexOf("## [Unreleased]"), out.indexOf("## [3.8.47]"));
+ const living = out.slice(out.indexOf("## [3.8.47]"), out.indexOf("## [3.8.46]"));
+ assert.ok(!unreleased.includes("landed (#4)"), "must not land under [Unreleased]");
+ assert.ok(
+ living.includes("- **existing feature**: already here (#1 — thanks @a)\n- **new**: landed (#4)")
+ );
+ assert.throws(
+ () => insertBullets(withUnreleasedHeading, { features: [{ text: "- x" }] }, "9.9.9"),
+ /section "## \[9\.9\.9\]" not found/
+ );
+});
+
+test("aggregate reads the living version from package.json and lands fragments under it", () => {
+ const root = makeRoot({
+ fragments: { "features/9-new.md": "- **new**: from a fragment (#9)\n" },
+ });
+ writeFileSync(join(root, "package.json"), JSON.stringify({ version: "3.8.47" }));
+ const before = readFileSync(join(root, "CHANGELOG.md"), "utf8");
+ const withHeading = before.replace(
+ "## [Unreleased]\n",
+ "## [Unreleased]\n\n### ✨ New Features\n\n- stale (#8)\n"
+ );
+ writeFileSync(join(root, "CHANGELOG.md"), withHeading);
+ aggregate({ root });
+ const after = readFileSync(join(root, "CHANGELOG.md"), "utf8");
+ const unreleased = after.slice(after.indexOf("## [Unreleased]"), after.indexOf("## [3.8.47]"));
+ assert.ok(!unreleased.includes("from a fragment (#9)"));
+ assert.ok(after.slice(after.indexOf("## [3.8.47]")).includes("from a fragment (#9)"));
+});
diff --git a/tests/unit/codex-reset-credits.test.ts b/tests/unit/codex-reset-credits.test.ts
index 2a6ecccee7..b1278a5d11 100644
--- a/tests/unit/codex-reset-credits.test.ts
+++ b/tests/unit/codex-reset-credits.test.ts
@@ -11,6 +11,7 @@ process.env.API_KEY_SECRET = "test-codex-reset-credits-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const resetCredits = await import("../../src/lib/usage/codexResetCredits.ts");
+const codexAccount = await import("../../open-sse/services/codexAccount/index.ts");
const originalFetch = globalThis.fetch;
type QuotaUsageRecord = Record;
@@ -45,6 +46,69 @@ test.after(async () => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
+async function persistBothChildCooldowns(id: string) {
+ const codexUntil = new Date(Date.now() + 60_000).toISOString();
+ const sparkUntil = new Date(Date.now() + 120_000).toISOString();
+ await codexAccount.persistCodexChildCooldown({
+ connectionId: id,
+ model: "gpt-5.5",
+ rateLimitedUntil: codexUntil,
+ });
+ await codexAccount.persistCodexChildCooldown({
+ connectionId: id,
+ model: "gpt-5.3-codex-spark",
+ rateLimitedUntil: sparkUntil,
+ });
+ return { codexUntil, sparkUntil };
+}
+
+async function readConnection(id: string) {
+ const connection = await providersDb.getProviderConnectionById(id);
+ assert.ok(connection);
+ return connection as Record;
+}
+
+function mockResetThenUsage(opts: {
+ consumeBody: unknown;
+ consumeStatus?: number;
+ usageBody?: unknown;
+ usageStatus?: number;
+}) {
+ globalThis.fetch = (async (url) => {
+ const href = String(url);
+ if (href.endsWith("/rate-limit-reset-credits")) {
+ return new Response(
+ JSON.stringify({ credits: [{ id: "credit-123", status: "available" }] }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ }
+ if (href.includes("/rate-limit-reset-credits/consume")) {
+ return new Response(JSON.stringify(opts.consumeBody), {
+ status: opts.consumeStatus ?? 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ if (href.includes("/backend-api/wham/usage")) {
+ if (opts.usageStatus && opts.usageStatus >= 500) {
+ return new Response("upstream down", { status: opts.usageStatus });
+ }
+ return new Response(
+ JSON.stringify(
+ opts.usageBody ?? {
+ plan_type: "plus",
+ rate_limit: {
+ primary_window: { used_percent: 0 },
+ secondary_window: { used_percent: 0 },
+ },
+ }
+ ),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ }
+ return new Response("unexpected", { status: 500 });
+ }) as typeof fetch;
+}
+
test("consumeCodexResetCredit fetches a credit id, posts it, then refreshes usage", async () => {
const connection = (await createCodexConnection()) as { id: string };
const calls: Array<{ url: string; init: RequestInit }> = [];
@@ -355,3 +419,92 @@ test("consumeCodexResetCredit rejects non-Codex and missing connections", async
error.code === "codex_provider_required"
);
});
+
+test("#12951 consumeCodexResetCredit lifts fallback Codex child cooldown after healthy usage refresh", async () => {
+ const connection = (await createCodexConnection()) as { id: string };
+ const parked = await persistBothChildCooldowns(connection.id);
+ const before = await readConnection(connection.id);
+ assert.equal(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5"), parked.codexUntil);
+ assert.equal(
+ codexAccount.getCodexChildCooldown(before as never, "gpt-5.3-codex-spark"),
+ parked.sparkUntil
+ );
+
+ mockResetThenUsage({ consumeBody: { code: "reset" } });
+
+ const result = await resetCredits.consumeCodexResetCredit(connection.id, "redeem-button-path");
+ assert.equal(result.outcome, "reset");
+
+ const after = await readConnection(connection.id);
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+ assert.equal(
+ codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"),
+ parked.sparkUntil
+ );
+});
+
+test("#12951 consumeCodexResetCredit alreadyRedeemed still needs healthy usage to lift fallback cooldown", async () => {
+ const connection = (await createCodexConnection()) as { id: string };
+ await persistBothChildCooldowns(connection.id);
+
+ mockResetThenUsage({
+ consumeBody: { code: "alreadyRedeemed" },
+ usageBody: {
+ plan_type: "plus",
+ rate_limit: {
+ primary_window: { used_percent: 100 },
+ secondary_window: { used_percent: 100 },
+ },
+ },
+ });
+
+ const result = await resetCredits.consumeCodexResetCredit(connection.id, "redeem-already");
+ assert.equal(result.outcome, "alreadyRedeemed");
+
+ const after = await readConnection(connection.id);
+ assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"));
+ assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"));
+});
+
+test("#12951 consumeCodexResetCredit failed redeem keeps nested child cooldowns", async () => {
+ const connection = (await createCodexConnection()) as { id: string };
+ const parked = await persistBothChildCooldowns(connection.id);
+
+ mockResetThenUsage({
+ consumeBody: { code: "noCredit" },
+ consumeStatus: 409,
+ });
+
+ await assert.rejects(
+ () => resetCredits.consumeCodexResetCredit(connection.id, "redeem-fail"),
+ (error: unknown) =>
+ error instanceof resetCredits.CodexResetCreditError &&
+ error.status === 409 &&
+ error.code === "no_credit"
+ );
+
+ const after = await readConnection(connection.id);
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), parked.codexUntil);
+ assert.equal(
+ codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"),
+ parked.sparkUntil
+ );
+});
+
+test("#12951 consumeCodexResetCredit incomplete usage refresh keeps nested child cooldowns", async () => {
+ const connection = (await createCodexConnection()) as { id: string };
+ const parked = await persistBothChildCooldowns(connection.id);
+
+ mockResetThenUsage({ consumeBody: { code: "reset" }, usageStatus: 500 });
+
+ const result = await resetCredits.consumeCodexResetCredit(connection.id, "redeem-incomplete");
+ assert.equal(result.outcome, "reset");
+
+ const after = await readConnection(connection.id);
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), parked.codexUntil);
+ assert.equal(
+ codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"),
+ parked.sparkUntil
+ );
+});
diff --git a/tests/unit/codex-scope-cooldown-clear-12817.test.ts b/tests/unit/codex-scope-cooldown-clear-12817.test.ts
new file mode 100644
index 0000000000..10a538cb98
--- /dev/null
+++ b/tests/unit/codex-scope-cooldown-clear-12817.test.ts
@@ -0,0 +1,449 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-clear-12817-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-clear-12817-secret";
+
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const rateLimit = await import("../../src/lib/db/providers/rateLimit.ts");
+const codexAccount = await import("../../open-sse/services/codexAccount/index.ts");
+const quotaSnapshots = await import("../../src/lib/db/quotaSnapshots.ts");
+const quotaCache = await import("../../src/domain/quotaCache.ts");
+
+async function resetStorage(): Promise {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+interface SeededConnection {
+ id: string;
+ providerSpecificData: Record;
+}
+
+async function seedCodexConnection(): Promise {
+ return providersDb.createProviderConnection({
+ provider: "codex",
+ authType: "oauth",
+ name: "codex-clear-12817",
+ email: "codex-clear-12817@example.com",
+ apiKey: "codex-clear-12817-key",
+ accessToken: "codex-clear-12817-access",
+ refreshToken: "codex-clear-12817-refresh",
+ providerSpecificData: {
+ unrelated: { retained: true },
+ },
+ }) as unknown as Promise;
+}
+
+async function seedGlmConnection(): Promise {
+ return providersDb.createProviderConnection({
+ provider: "glm",
+ authType: "apikey",
+ name: "glm-clear-12817",
+ apiKey: "glm-clear-12817-key",
+ providerSpecificData: {
+ leftover: "keep-me",
+ },
+ }) as unknown as Promise;
+}
+
+async function readConnection(id: string): Promise> {
+ const connection = await providersDb.getProviderConnectionById(id);
+ assert.ok(connection);
+ return connection as unknown as Record;
+}
+
+function psd(connection: Record): Record {
+ return (connection.providerSpecificData ?? {}) as Record;
+}
+
+function futureIso(ms: number): string {
+ return new Date(Date.now() + ms).toISOString();
+}
+
+function quotaHeaders(resetAt5h: string, resetAt7d: string, usage5h = "10") {
+ return {
+ "x-codex-5h-usage": usage5h,
+ "x-codex-5h-limit": "100",
+ "x-codex-5h-reset-at": resetAt5h,
+ "x-codex-7d-usage": "10",
+ "x-codex-7d-limit": "100",
+ "x-codex-7d-reset-at": resetAt7d,
+ };
+}
+
+async function persistBothChildCooldowns(id: string): Promise<{
+ codexUntil: string;
+ sparkUntil: string;
+}> {
+ const codexUntil = futureIso(60_000);
+ const sparkUntil = futureIso(120_000);
+ await codexAccount.persistCodexChildCooldown({
+ connectionId: id,
+ model: "gpt-5.5",
+ rateLimitedUntil: codexUntil,
+ });
+ await codexAccount.persistCodexChildCooldown({
+ connectionId: id,
+ model: "gpt-5.3-codex-spark",
+ rateLimitedUntil: sparkUntil,
+ });
+ return { codexUntil, sparkUntil };
+}
+
+test.beforeEach(resetStorage);
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+test("#12817 PUT rateLimitedUntil:null also drops nested Codex child cooldowns", async () => {
+ const connection = await seedCodexConnection();
+ const { sparkUntil } = await persistBothChildCooldowns(connection.id);
+ await providersDb.updateProviderConnection(connection.id, {
+ rateLimitedUntil: futureIso(90_000),
+ });
+
+ const before = await readConnection(connection.id);
+ assert.equal(
+ codexAccount.getCodexChildCooldown(before as never, "gpt-5.3-codex-spark"),
+ sparkUntil
+ );
+
+ await providersDb.updateProviderConnection(connection.id, { rateLimitedUntil: null });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ assert.equal(after.rateLimitedUntil, undefined);
+ assert.equal(data.codexScopeRateLimitedUntil, undefined);
+ assert.equal(data.codexScopeRateLimitSource, undefined);
+ assert.deepEqual(data.unrelated, { retained: true });
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null);
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), null);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12817 PUT rateLimitedUntil:\"\" also drops nested Codex child cooldowns", async () => {
+ const connection = await seedCodexConnection();
+ await persistBothChildCooldowns(connection.id);
+ await providersDb.updateProviderConnection(connection.id, {
+ rateLimitedUntil: futureIso(90_000),
+ });
+
+ await providersDb.updateProviderConnection(connection.id, { rateLimitedUntil: "" });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ assert.equal(after.rateLimitedUntil, undefined);
+ assert.equal(data.codexScopeRateLimitedUntil, undefined);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12817 clearConnectionRateLimit strips nested Codex child cooldowns", async () => {
+ const connection = await seedCodexConnection();
+ await persistBothChildCooldowns(connection.id);
+ rateLimit.setConnectionRateLimitUntil(connection.id, Date.now() + 90_000);
+
+ rateLimit.clearConnectionRateLimit(connection.id);
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ assert.equal(after.rateLimitedUntil, undefined);
+ assert.equal(data.codexScopeRateLimitedUntil, undefined);
+ assert.equal(data.codexScopeRateLimitSource, undefined);
+ assert.deepEqual(data.unrelated, { retained: true });
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12817 CAS error-clear also strips nested Codex child cooldowns", async () => {
+ const connection = await seedCodexConnection();
+ await persistBothChildCooldowns(connection.id);
+ const until = futureIso(90_000);
+ await providersDb.updateProviderConnection(connection.id, {
+ testStatus: "unavailable",
+ lastError: "429",
+ lastErrorAt: new Date().toISOString(),
+ lastErrorType: "rate_limit_exceeded",
+ rateLimitedUntil: until,
+ });
+ const before = await readConnection(connection.id);
+
+ const applied = await providersDb.clearConnectionErrorIfUnchanged(connection.id, {
+ testStatus: (before.testStatus as string) ?? null,
+ lastErrorAt: (before.lastErrorAt as string) ?? null,
+ rateLimitedUntil: (before.rateLimitedUntil as string) ?? null,
+ });
+ assert.equal(applied, true);
+
+ const after = await readConnection(connection.id);
+ assert.equal(after.testStatus, "active");
+ assert.equal(psd(after).codexScopeRateLimitedUntil, undefined);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12817 a successful quota observation clears that child's leftover cooldown", async () => {
+ const connection = await seedCodexConnection();
+ const reset5h = futureIso(60_000);
+ const reset7d = futureIso(600_000);
+
+ await persistBothChildCooldowns(connection.id);
+ await codexAccount.persistCodexChildQuotaResponse({
+ connectionId: connection.id,
+ model: "gpt-5.5",
+ headers: quotaHeaders(reset5h, reset7d, "95"),
+ status: 429,
+ });
+ await codexAccount.persistCodexChildQuotaResponse({
+ connectionId: connection.id,
+ model: "gpt-5.5",
+ headers: quotaHeaders(reset5h, reset7d, "10"),
+ status: 200,
+ });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ const until = data.codexScopeRateLimitedUntil as Record | undefined;
+ const exhausted = data.codexExhaustedWindowByScope as Record | undefined;
+ assert.equal(until?.codex, undefined);
+ assert.equal(typeof until?.spark, "string");
+ assert.equal(exhausted?.codex, undefined);
+ assert.deepEqual(data.unrelated, { retained: true });
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.3-codex-spark"), true);
+});
+
+test("#12817 clearing a non-Codex cooldown leaves providerSpecificData alone", async () => {
+ const connection = await seedGlmConnection();
+ await providersDb.updateProviderConnection(connection.id, {
+ rateLimitedUntil: futureIso(90_000),
+ });
+ await providersDb.updateProviderConnection(connection.id, { rateLimitedUntil: null });
+ const after = await readConnection(connection.id);
+ assert.equal(after.rateLimitedUntil, undefined);
+ assert.equal(psd(after).leftover, "keep-me");
+});
+
+test("#12860 saveQuotaSnapshot with headroom lifts fallback-sourced scope cooldown", async () => {
+ const connection = await seedCodexConnection();
+ await persistBothChildCooldowns(connection.id);
+
+ const before = await readConnection(connection.id);
+ assert.ok(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5"));
+
+ quotaSnapshots.saveQuotaSnapshot({
+ provider: "codex",
+ connection_id: connection.id,
+ window_key: "primary",
+ remaining_percentage: 100,
+ is_exhausted: 0,
+ next_reset_at: futureIso(3600_000),
+ window_duration_ms: 18_000_000,
+ raw_data: null,
+ });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ const until = data.codexScopeRateLimitedUntil as Record | undefined;
+ assert.equal(until?.codex, undefined);
+ assert.equal(typeof until?.spark, "string");
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12860 saveQuotaSnapshot with headroom does NOT lift authoritative quota_reset cooldown", async () => {
+ const connection = await seedCodexConnection();
+ const reset5h = futureIso(120_000);
+ const reset7d = futureIso(600_000);
+
+ await codexAccount.persistCodexChildQuotaResponse({
+ connectionId: connection.id,
+ model: "gpt-5.5",
+ headers: quotaHeaders(reset5h, reset7d, "95"),
+ status: 429,
+ });
+
+ const before = await readConnection(connection.id);
+ assert.ok(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5"));
+
+ quotaSnapshots.saveQuotaSnapshot({
+ provider: "codex",
+ connection_id: connection.id,
+ window_key: "session",
+ remaining_percentage: 100,
+ is_exhausted: 0,
+ next_reset_at: futureIso(3600_000),
+ window_duration_ms: 18_000_000,
+ raw_data: null,
+ });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ const until = data.codexScopeRateLimitedUntil as Record | undefined;
+ assert.equal(typeof until?.codex, "string");
+ assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"));
+});
+
+test("#12860 saveQuotaSnapshot does NOT lift scope cooldown if another window for that scope is still exhausted", async () => {
+ const connection = await seedCodexConnection();
+ await codexAccount.persistCodexChildCooldown({
+ connectionId: connection.id,
+ model: "gpt-5.5",
+ rateLimitedUntil: futureIso(120_000),
+ });
+
+ // Weekly window is currently exhausted (0% remaining)
+ quotaSnapshots.saveQuotaSnapshot({
+ provider: "codex",
+ connection_id: connection.id,
+ window_key: "weekly",
+ remaining_percentage: 0,
+ is_exhausted: 1,
+ next_reset_at: futureIso(7200_000),
+ window_duration_ms: 604_800_000,
+ raw_data: null,
+ });
+
+ // Session window reports 100% headroom, but weekly is still exhausted
+ quotaSnapshots.saveQuotaSnapshot({
+ provider: "codex",
+ connection_id: connection.id,
+ window_key: "session",
+ remaining_percentage: 100,
+ is_exhausted: 0,
+ next_reset_at: futureIso(3600_000),
+ window_duration_ms: 18_000_000,
+ raw_data: null,
+ });
+
+ let mid = await readConnection(connection.id);
+ assert.ok(codexAccount.getCodexChildCooldown(mid as never, "gpt-5.5"));
+
+ // Now weekly also recovers to 100% headroom
+ quotaSnapshots.saveQuotaSnapshot({
+ provider: "codex",
+ connection_id: connection.id,
+ window_key: "weekly",
+ remaining_percentage: 100,
+ is_exhausted: 0,
+ next_reset_at: futureIso(7200_000),
+ window_duration_ms: 604_800_000,
+ raw_data: null,
+ });
+
+ let after = await readConnection(connection.id);
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12860 saveQuotaSnapshot for spark scope with headroom lifts only spark cooldown", async () => {
+ const connection = await seedCodexConnection();
+ await persistBothChildCooldowns(connection.id);
+
+ quotaSnapshots.saveQuotaSnapshot({
+ provider: "codex",
+ connection_id: connection.id,
+ window_key: "gpt_5_3_codex_spark_session",
+ remaining_percentage: 100,
+ is_exhausted: 0,
+ next_reset_at: futureIso(3600_000),
+ window_duration_ms: 18_000_000,
+ raw_data: null,
+ });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ const until = data.codexScopeRateLimitedUntil as Record | undefined;
+ assert.equal(typeof until?.codex, "string");
+ assert.equal(until?.spark, undefined);
+ assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"));
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), null);
+});
+
+test("#12860 setQuotaCache with fresh usage headroom lifts fallback-sourced scope cooldown", async () => {
+ const connection = await seedCodexConnection();
+ await persistBothChildCooldowns(connection.id);
+
+ const before = await readConnection(connection.id);
+ assert.ok(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5"));
+
+ quotaCache.setQuotaCache(connection.id, "codex", {
+ session: {
+ used: 0,
+ total: 100,
+ remainingPercentage: 100,
+ resetAt: futureIso(3600_000),
+ },
+ });
+
+ const after = await readConnection(connection.id);
+ const data = psd(after);
+ const until = data.codexScopeRateLimitedUntil as Record | undefined;
+ assert.equal(until?.codex, undefined);
+ assert.equal(typeof until?.spark, "string");
+ assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null);
+ assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false);
+});
+
+test("#12860 quota_reset cooldown that just elapsed is still held by the skew grace window", async () => {
+ const connection = await seedCodexConnection();
+ // Reset deadline sits 5s in the past — inside the 30s clock-skew grace, so a
+ // fast local clock must not lift an upstream-authoritative cooldown early.
+ const justElapsed = new Date(Date.now() - 5_000).toISOString();
+
+ await providersDb.updateProviderConnection(connection.id, {
+ providerSpecificData: {
+ codexScopeRateLimitedUntil: { codex: justElapsed },
+ codexScopeRateLimitSource: { codex: "quota_reset" },
+ },
+ });
+
+ const lifted = providersDb.liftCodexScopeCooldownOnHeadroom(connection.id, "codex");
+ assert.equal(lifted, false);
+
+ const after = await readConnection(connection.id);
+ const until = psd(after).codexScopeRateLimitedUntil as Record | undefined;
+ assert.equal(until?.codex, justElapsed);
+});
+
+test("#12860 quota_reset cooldown past the skew grace window is lifted", async () => {
+ const connection = await seedCodexConnection();
+ const wellElapsed = new Date(Date.now() - 120_000).toISOString();
+
+ await providersDb.updateProviderConnection(connection.id, {
+ providerSpecificData: {
+ codexScopeRateLimitedUntil: { codex: wellElapsed },
+ codexScopeRateLimitSource: { codex: "quota_reset" },
+ },
+ });
+
+ const lifted = providersDb.liftCodexScopeCooldownOnHeadroom(connection.id, "codex");
+ assert.equal(lifted, true);
+
+ const after = await readConnection(connection.id);
+ assert.equal(psd(after).codexScopeRateLimitedUntil, undefined);
+});
+
+test("#12860 hasCodexScopeCooldown short-circuits the snapshot scan when nothing is parked", async () => {
+ const connection = await seedCodexConnection();
+ assert.equal(providersDb.hasCodexScopeCooldown(connection.id, "codex"), false);
+
+ await codexAccount.persistCodexChildCooldown({
+ connectionId: connection.id,
+ model: "gpt-5.5",
+ rateLimitedUntil: futureIso(120_000),
+ });
+
+ assert.equal(providersDb.hasCodexScopeCooldown(connection.id, "codex"), true);
+ assert.equal(providersDb.hasCodexScopeCooldown(connection.id, "spark"), false);
+
+ const glm = await seedGlmConnection();
+ assert.equal(providersDb.hasCodexScopeCooldown(glm.id, "codex"), false);
+ assert.equal(providersDb.hasCodexScopeCooldown("does-not-exist", "codex"), false);
+});
diff --git a/tests/unit/combo-health-empty-snapshot.test.ts b/tests/unit/combo-health-empty-snapshot.test.ts
new file mode 100644
index 0000000000..25ff864935
--- /dev/null
+++ b/tests/unit/combo-health-empty-snapshot.test.ts
@@ -0,0 +1,63 @@
+// tests/unit/combo-health-empty-snapshot.test.ts — pattern db-quota-snapshots.test.ts:7-26 :
+// isolation DB réelle, zéro mock (mock.module indisponible sous tsx/ESM ; sans polyfill+isolateDataDir,
+// DATA_DIR tombe sur ~/.omniroute réel → flaky).
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-combo-health-null-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const coreDb = await import("../../src/lib/db/core.ts");
+const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts");
+const { buildProviderHealth, buildQuotaHealth } =
+ await import("../../src/lib/usage/comboHealth.ts");
+
+async function resetStorage() {
+ coreDb.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(() => {
+ coreDb.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+const SNAP = {
+ provider: "openrouter",
+ connection_id: "conn-1",
+ window_key: "hourly",
+ is_exhausted: 0,
+ next_reset_at: "2026-01-01T01:00:00.000Z",
+ window_duration_ms: 3600000,
+ raw_data: "{}",
+} as const;
+
+test("empty snapshots read null, not 0%, and stay non-exhausted", () => {
+ const h = buildProviderHealth("openrouter", []);
+ assert.equal(h.remainingPct, null);
+ assert.equal(h.isExhausted, false);
+ assert.equal(h.trend, "stable");
+});
+
+test("all-null percentages read null (B3)", () => {
+ quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null });
+ quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null });
+ const q = buildQuotaHealth(["openrouter"], "1970-01-01T00:00:00.000Z");
+ assert.equal(q.providers[0].remainingPct, null);
+ assert.equal(q.worstRemainingPct, null);
+});
+
+test("worstRemainingPct ignores nulls, null when all null", () => {
+ quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null });
+ quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: 42.346 });
+ const q = buildQuotaHealth(["openrouter"], "1970-01-01T00:00:00.000Z");
+ assert.equal(q.worstRemainingPct, 42.35);
+});
diff --git a/tests/unit/db-rate-limit-guard.test.ts b/tests/unit/db-rate-limit-guard.test.ts
new file mode 100644
index 0000000000..69fb9e1e06
--- /dev/null
+++ b/tests/unit/db-rate-limit-guard.test.ts
@@ -0,0 +1,121 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-ratelimit-guard-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts") as typeof import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts") as typeof import("../../src/lib/db/providers.ts");
+const {
+ setConnectionRateLimitUntil,
+ clearConnectionRateLimit,
+ isConnectionRateLimited,
+} = providersDb;
+
+function readRateLimitedUntil(connectionId: string): unknown {
+ const db = (
+ core as unknown as {
+ getDbInstance: () => {
+ prepare: (sql: string) => {
+ get: (id: string) => { rate_limited_until: unknown } | undefined;
+ };
+ };
+ }
+ ).getDbInstance();
+ return db
+ .prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?")
+ .get(connectionId)?.rate_limited_until ?? null;
+}
+
+async function resetStorage() {
+ core.resetDbInstance();
+ // Retry loop copied from db-providers-crud.test.ts:17-30 (Windows EBUSY/EPERM).
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ if (fs.existsSync(TEST_DATA_DIR)) {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ }
+ break;
+ } catch (error: unknown) {
+ const code = (error as { code?: string } | null)?.code;
+ if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
+ await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
+ } else {
+ throw error;
+ }
+ }
+ }
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(async () => {
+ core.resetDbInstance();
+ // No retry here (unlike resetStorage): teardown-only, Linux CI; Windows
+ // EBUSY surfaces in beforeEach retries, not here.
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+async function seedConnection(): Promise {
+ const connection = await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ name: "Guard probe",
+ apiKey: "guard-key",
+ });
+ return (connection as { id: string }).id;
+}
+
+test("ignores NaN (row unchanged)", async () => {
+ const id = await seedConnection();
+ setConnectionRateLimitUntil(id, NaN);
+ assert.equal(readRateLimitedUntil(id), null);
+});
+
+test("ignores Infinity and -Infinity", async () => {
+ const id = await seedConnection();
+ setConnectionRateLimitUntil(id, Infinity);
+ setConnectionRateLimitUntil(id, -Infinity);
+ assert.equal(readRateLimitedUntil(id), null);
+});
+
+test("ignores past timestamps and 0 (documented noop; clear path is null)", async () => {
+ const id = await seedConnection();
+ setConnectionRateLimitUntil(id, Date.now() - 1000);
+ setConnectionRateLimitUntil(id, 0);
+ assert.equal(readRateLimitedUntil(id), null);
+});
+
+test("writes future timestamps", async () => {
+ const id = await seedConnection();
+ const until = Date.now() + 60_000;
+ setConnectionRateLimitUntil(id, until);
+ assert.equal(Number(readRateLimitedUntil(id)), until);
+ assert.equal(isConnectionRateLimited(id), true);
+});
+
+test("preserves null clear (non-regression for clearConnectionRateLimit)", async () => {
+ const id = await seedConnection();
+ setConnectionRateLimitUntil(id, Date.now() + 60_000);
+ clearConnectionRateLimit(id);
+ assert.equal(readRateLimitedUntil(id), null);
+ assert.equal(isConnectionRateLimited(id), false);
+});
+
+test("expired write does not overwrite a live row (future preserved)", async () => {
+ const id = await seedConnection();
+ const future = Date.now() + 60_000;
+ setConnectionRateLimitUntil(id, future);
+ setConnectionRateLimitUntil(id, Date.now() - 1000);
+ assert.equal(Number(readRateLimitedUntil(id)), future);
+ assert.equal(isConnectionRateLimited(id), true);
+});
+
+// Without the guard the TEXT column stores the string "NaN" — rejected by
+// current readers, but hygiene demands never writing it.
diff --git a/tests/unit/db-wal-truncate-scheduler.test.ts b/tests/unit/db-wal-truncate-scheduler.test.ts
index cbd93cde7e..5d2d310b01 100644
--- a/tests/unit/db-wal-truncate-scheduler.test.ts
+++ b/tests/unit/db-wal-truncate-scheduler.test.ts
@@ -17,24 +17,22 @@ function readSource(relativePath: string): string {
}
const CORE_PATH = "src/lib/db/core.ts";
+const MAINTENANCE_PATH = "src/lib/db/walMaintenance.ts";
test("a periodic WAL truncate scheduler is started when the DB instance boots", () => {
const source = readSource(CORE_PATH);
assert.match(
source,
- /startWalTruncateScheduler\(db\)/,
- "getDbInstance() must start the WAL truncate scheduler alongside the DB health-check scheduler"
+ /startWalMaintenance\(db/,
+ "getDbInstance() must start the WAL maintenance scheduler alongside the DB health-check scheduler"
);
});
test("the WAL truncate scheduler runs wal_checkpoint(TRUNCATE), not a lighter mode", () => {
- const source = readSource(CORE_PATH);
- const fnStart = source.indexOf("function startWalTruncateScheduler");
- assert.notEqual(fnStart, -1, "startWalTruncateScheduler must exist");
- const fnBody = source.slice(fnStart, fnStart + 1200);
+ const source = readSource(MAINTENANCE_PATH);
assert.match(
- fnBody,
- /checkpointDb\(db, "TRUNCATE"\)/,
+ source,
+ /wal_checkpoint\(TRUNCATE\)/,
"the scheduled checkpoint must request TRUNCATE mode — a lighter mode would not shrink the WAL file"
);
});
@@ -47,13 +45,13 @@ test("the WAL truncate scheduler is cleared on close, like the health-check sche
assert.match(fnBody, /clearDbHealthCheckScheduler\(\)/);
assert.match(
fnBody,
- /clearWalTruncateScheduler\(\)/,
- "closeDbInstance() must clear the WAL truncate timer so it does not outlive the DB handle"
+ /stopWalMaintenance\(\)/,
+ "closeDbInstance() must stop the WAL maintenance timer so it does not outlive the DB handle"
);
});
test("the truncate interval is overridable via OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS", () => {
- const source = readSource(CORE_PATH);
+ const source = readSource(MAINTENANCE_PATH);
assert.match(
source,
/OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS/,
@@ -62,14 +60,10 @@ test("the truncate interval is overridable via OMNIROUTE_WAL_TRUNCATE_INTERVAL_M
});
test("the scheduler self-gates the same way the DB health-check scheduler does", () => {
- const source = readSource(CORE_PATH);
- const fnStart = source.indexOf("function startWalTruncateScheduler");
- const fnBody = source.slice(fnStart, fnStart + 300);
- assert.match(
- fnBody,
- /isCloud \|\| isBuildPhase \|\| isAutomatedTestProcess\(\)/,
- "must not run during cloud/build/test contexts, same as startDbHealthCheckScheduler"
- );
+ const source = readSource(MAINTENANCE_PATH);
+ assert.match(source, /isCloud/);
+ assert.match(source, /isNextBuildPhase\(\)/);
+ assert.match(source, /isAutomatedTestProcess\(\)/);
});
test("the new env var is documented", () => {
@@ -80,3 +74,47 @@ test("the new env var is documented", () => {
"docs/reference/ENVIRONMENT.md must document the new env var (check:env-doc-sync)"
);
});
+
+test("close carries the busy streak into the checkpoint log", () => {
+ const source = readSource(CORE_PATH);
+ const fnStart = source.indexOf("export function closeDbInstance");
+ assert.notEqual(fnStart, -1, "closeDbInstance must exist");
+ const fnBody = source.slice(fnStart, fnStart + 1200);
+ assert.match(fnBody, /getWalMaintenanceState\(\)\.busyStreak/);
+ assert.match(fnBody, /runCheckpointNow\(db, checkpointMode, \{/);
+ assert.match(fnBody, /logCheckpointOutcome\(outcome, checkpointMode, streakBefore\)/);
+});
+
+test("periodic schedulers log the error path (ok:false, busy:false)", () => {
+ const source = readSource(MAINTENANCE_PATH);
+ const periodic = source.slice(source.indexOf("function schedulePassiveRetry"));
+ const truncateLogs = (
+ periodic.match(/logCheckpointOutcome\(outcome, "TRUNCATE", busyStreak\)/g) ?? []
+ ).length;
+ const passiveLogs = (
+ periodic.match(/logCheckpointOutcome\(outcome, "PASSIVE", busyStreak\)/g) ?? []
+ ).length;
+ assert.ok(
+ truncateLogs >= 2,
+ `periodic TRUNCATE scheduler must log busy AND error outcomes (found ${truncateLogs} log calls)`
+ );
+ assert.ok(
+ passiveLogs >= 2,
+ `PASSIVE retry scheduler must log busy AND error outcomes (found ${passiveLogs} log calls)`
+ );
+});
+
+test("close reads the busy streak BEFORE stopping maintenance (streak otherwise always 0)", () => {
+ const source = readSource(CORE_PATH);
+ const fnStart = source.indexOf("export function closeDbInstance");
+ assert.notEqual(fnStart, -1, "closeDbInstance must exist");
+ const fnBody = source.slice(fnStart, fnStart + 1200);
+ const streakIdx = fnBody.indexOf("getWalMaintenanceState().busyStreak");
+ const stopIdx = fnBody.indexOf("stopWalMaintenance()");
+ assert.notEqual(streakIdx, -1, "closeDbInstance must read busyStreak");
+ assert.notEqual(stopIdx, -1, "closeDbInstance must stop maintenance");
+ assert.ok(
+ streakIdx < stopIdx,
+ "streakBefore must be captured before stopWalMaintenance() resets busyStreak to 0"
+ );
+});
diff --git a/tests/unit/db/call-logs-provider-stats.test.ts b/tests/unit/db/call-logs-provider-stats.test.ts
new file mode 100644
index 0000000000..3f3a503d58
--- /dev/null
+++ b/tests/unit/db/call-logs-provider-stats.test.ts
@@ -0,0 +1,119 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-call-logs-stats-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.NODE_ENV = "test";
+process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
+
+const core = await import("../../../src/lib/db/core.ts");
+const stats = await import("../../../src/lib/db/callLogStats.ts");
+
+function resetDb() {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+test.beforeEach(() => {
+ resetDb();
+});
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+test("getFallbackStats on empty DB returns zeros, not nulls", () => {
+ core.getDbInstance(); // plays SCHEMA + runMigrations on the file DB
+ const row = stats.getFallbackStats("", {});
+ assert.deepEqual(row, {
+ total: 0,
+ with_requested: 0,
+ fallback_eligible: 0,
+ fallbacks: 0,
+ });
+});
+
+test("avgLatencyMs is null when all durations are NULL, and the route propagates null", async () => {
+ const db = core.getDbInstance();
+ const now = new Date().toISOString();
+ db.prepare(
+ `INSERT INTO provider_connections (id, provider, created_at, updated_at)
+ VALUES ('conn-1', 'openai', ?, ?)`
+ ).run(now, now);
+ db.prepare(
+ `INSERT INTO call_logs (id, timestamp, provider, status, duration)
+ VALUES ('log-1', ?, 'openai', 200, NULL)`
+ ).run(now);
+
+ const { toNumberOrNull } = await import("../../../src/shared/utils/numeric.ts");
+ const rows = stats.getProviderMetrics();
+ assert.equal(rows.length, 1);
+ // Lib-level: passes before AND after (the driver already returns null — only
+ // the TS type lied). Kept as documentation, not as red/green proof.
+ assert.equal(toNumberOrNull(rows[0].avgLatencyMs), null);
+
+ const { GET } = await import("../../../src/app/api/provider-metrics/route.ts");
+ const res = await GET();
+ const body = (await res.json()) as {
+ metrics: Record;
+ };
+ // Route-level: THIS is the red/green proof (toNumber(null) → 0 before fix).
+ assert.equal(body.metrics["openai"].avgLatencyMs, null);
+});
+
+test("error_type NULL splits into pre_migration vs unclassified by timestamp", () => {
+ const db = core.getDbInstance();
+ const now = new Date().toISOString();
+ db.prepare(
+ `INSERT INTO provider_connections (id, provider, created_at, updated_at)
+ VALUES ('conn-1', 'openai', ?, ?)`
+ ).run(now, now);
+ db.prepare(
+ `INSERT INTO call_logs (id, timestamp, provider, status, error_type)
+ VALUES ('old-1', '2026-08-01T00:00:00.000Z', 'openai', 500, NULL),
+ ('new-1', ?, 'openai', 500, NULL)`
+ ).run(now);
+
+ const breakdown = stats.getErrorTypeBreakdown("", {});
+ const byType = new Map(breakdown.map((b) => [b.errorType, b.count]));
+ assert.equal(byType.get("pre_migration"), 1);
+ assert.equal(byType.get("unclassified"), 1);
+});
+
+test("migration 175 creates provider GROUP BY indexes used by search stats", () => {
+ const db = core.getDbInstance();
+
+ const names = (
+ db.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all() as Array<{
+ name: string;
+ }>
+ ).map((r) => r.name);
+ assert.ok(
+ names.includes("idx_cl_provider_timestamp"),
+ "idx_cl_provider_timestamp must exist after migrations"
+ );
+ assert.ok(
+ names.includes("idx_cl_request_provider"),
+ "idx_cl_request_provider must exist after migrations"
+ );
+
+ const plan = (
+ db
+ .prepare(
+ "EXPLAIN QUERY PLAN SELECT provider, COUNT(*), AVG(duration) FROM call_logs WHERE request_type = 'search' GROUP BY provider"
+ )
+ .all() as Array<{ detail: string }>
+ )
+ .map((r) => r.detail)
+ .join(" | ");
+ assert.ok(
+ plan.includes("USING INDEX idx_cl_request_provider"),
+ `planner must use idx_cl_request_provider, got: ${plan}`
+ );
+ assert.ok(!plan.includes("SCAN TABLE"), `must not table-scan, got: ${plan}`);
+});
diff --git a/tests/unit/deepseek-pow-slot-leak-13094.test.ts b/tests/unit/deepseek-pow-slot-leak-13094.test.ts
new file mode 100644
index 0000000000..e3d9239791
--- /dev/null
+++ b/tests/unit/deepseek-pow-slot-leak-13094.test.ts
@@ -0,0 +1,85 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { tmpdir } from "node:os";
+import { mkdtempSync, rmSync } from "node:fs";
+import { join } from "node:path";
+
+const { solveDeepSeekPowAsync } = await import("../../open-sse/lib/deepseek-pow.ts");
+
+// A valid challenge: the failure under test happens at worker construction,
+// long after validation, so these values just have to pass validateChallenge().
+const ALGORITHM = "DeepSeekHashV1";
+const CHALLENGE = "a".repeat(64);
+const SALT = "test-salt";
+const DIFFICULTY = 1;
+
+function futureExpiry() {
+ return Date.now() + 60_000;
+}
+
+test("a failed worker spawn does not consume a permanent concurrency slot (#13094)", async () => {
+ // resolveWorkerPath() resolves the worker script against process.cwd(), so
+ // running from a directory without it makes construction throw -- the same
+ // condition users hit when the process starts from an unexpected cwd.
+ const originalCwd = process.cwd();
+ const emptyDir = mkdtempSync(join(tmpdir(), "deepseek-pow-nocwd-"));
+ process.chdir(emptyDir);
+
+ try {
+ // MAX_CONCURRENT_WORKERS is 2, so two leaked slots exhaust the budget.
+ for (let i = 0; i < 2; i++) {
+ await assert.rejects(
+ () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()),
+ /worker script not found/i,
+ `attempt ${i + 1} should fail on the missing worker script`
+ );
+ }
+
+ // The third attempt must still report the real cause. Before the fix the
+ // counter had been incremented twice without ever being released, so this
+ // rejected with "capacity reached" and the solver stayed dead until restart.
+ await assert.rejects(
+ () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()),
+ (error: Error) => {
+ assert.doesNotMatch(
+ error.message,
+ /capacity reached/i,
+ "a spawn failure must not be reported as exhausted capacity"
+ );
+ assert.match(error.message, /worker script not found/i);
+ return true;
+ }
+ );
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(emptyDir, { recursive: true, force: true });
+ }
+});
+
+test("the slot is released again once spawning works (#13094)", async () => {
+ const originalCwd = process.cwd();
+ const emptyDir = mkdtempSync(join(tmpdir(), "deepseek-pow-nocwd-"));
+
+ try {
+ process.chdir(emptyDir);
+ await assert.rejects(
+ () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()),
+ /worker script not found/i
+ );
+
+ // Back in a working directory the solver has to be usable again: a leaked
+ // slot would eventually surface here as a spurious capacity rejection.
+ process.chdir(originalCwd);
+ const answer = await solveDeepSeekPowAsync(
+ ALGORITHM,
+ CHALLENGE,
+ SALT,
+ DIFFICULTY,
+ futureExpiry()
+ );
+ assert.equal(typeof answer, "number", "a real solve should still succeed");
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(emptyDir, { recursive: true, force: true });
+ }
+});
diff --git a/tests/unit/generic-quota-fetcher-volcengine.test.ts b/tests/unit/generic-quota-fetcher-volcengine.test.ts
new file mode 100644
index 0000000000..04dfbba24f
--- /dev/null
+++ b/tests/unit/generic-quota-fetcher-volcengine.test.ts
@@ -0,0 +1,57 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { convertUsageToQuotaInfo } from "../../open-sse/services/genericQuotaFetcher.ts";
+
+test("convertUsageToQuotaInfo extracts window5h and window7d for volcengine canonical keys", () => {
+ const usage = {
+ plan: "Volcano Ark Coding Plan",
+ quotas: {
+ "session (5h)": {
+ used: 40,
+ total: 100,
+ remainingPercentage: 60,
+ resetAt: "2026-09-07T12:00:00.000Z",
+ },
+ "weekly (7d)": {
+ used: 15,
+ total: 100,
+ remainingPercentage: 85,
+ resetAt: "2026-09-14T00:00:00.000Z",
+ },
+ },
+ };
+
+ const info = convertUsageToQuotaInfo(usage, { provider: "volcengine-coding-plan" });
+ assert.ok(info);
+ assert.equal(info.window5h?.percentUsed, 0.4);
+ assert.equal(info.window5h?.resetAt, "2026-09-07T12:00:00.000Z");
+ assert.equal(info.window7d?.percentUsed, 0.15);
+ assert.equal(info.window7d?.resetAt, "2026-09-14T00:00:00.000Z");
+});
+
+test("convertUsageToQuotaInfo extracts window5h and window7d from legacy fallback keys without poisoning modelWindows", () => {
+ const usage = {
+ plan: "Volcano Ark Agent Plan",
+ quotas: {
+ weekly: {
+ used: 200,
+ total: 1000,
+ remainingPercentage: 80,
+ resetAt: "2026-09-14T00:00:00.000Z",
+ },
+ monthly: {
+ used: 500,
+ total: 5000,
+ remainingPercentage: 90,
+ resetAt: "2026-10-01T00:00:00.000Z",
+ },
+ },
+ };
+
+ const info = convertUsageToQuotaInfo(usage, { provider: "volcengine-agent-plan" });
+ assert.ok(info);
+ // Missing 5h window must remain undefined, NOT poisoned by weekly or monthly
+ assert.equal(info.window5h, undefined);
+ assert.equal(info.window7d?.percentUsed, 0.2);
+ assert.equal(info.window7d?.resetAt, "2026-09-14T00:00:00.000Z");
+});
diff --git a/tests/unit/observability-payloads.test.ts b/tests/unit/observability-payloads.test.ts
index 05bc8f6b76..210308f12d 100644
--- a/tests/unit/observability-payloads.test.ts
+++ b/tests/unit/observability-payloads.test.ts
@@ -7,6 +7,7 @@ import {
buildTelemetryPayload,
projectAdaptiveAdmissionSummary,
projectChatAdmissionSummary,
+ projectWalMaintenanceSummary,
} from "../../src/lib/monitoring/observability.ts";
test("buildSessionsSummary returns sticky counts and ordered top sessions", () => {
@@ -416,3 +417,55 @@ test("buildHealthPayload projects allowlisted structural chatAdmission fields on
assert.equal(projectChatAdmissionSummary(null), null);
assert.equal(projectChatAdmissionSummary(undefined), null);
});
+
+test("buildHealthPayload projects allowlisted walMaintenance fields only", () => {
+ const state = {
+ ticks: 4,
+ busyStreak: 1,
+ busyTotal: 2,
+ lastBusyAt: "2026-09-06T10:00:00.000Z",
+ lastOkAt: "2026-09-06T11:00:00.000Z",
+ // Internal keys that must never leak into the public payload.
+ walTimer: { _idleTimeout: 1 },
+ retryTimer: null,
+ } as unknown as import("../../src/lib/monitoring/observability.ts").WalMaintenanceSnapshot;
+
+ const payload = buildHealthPayload({
+ appVersion: "9.9.9",
+ settings: { setupComplete: false },
+ connections: [],
+ circuitBreakers: [],
+ rateLimitStatus: {},
+ learnedLimits: {},
+ lockouts: {},
+ localProviders: {},
+ inflightRequests: 0,
+ quotaMonitorSummary: {
+ active: 0,
+ alerting: 0,
+ exhausted: 0,
+ errors: 0,
+ statusCounts: { starting: 0, idle: 0, healthy: 0, warning: 0, exhausted: 0, error: 0 },
+ byProvider: {},
+ },
+ quotaMonitorMonitors: [],
+ activeSessions: [],
+ walMaintenance: state,
+ });
+
+ assert.deepEqual(payload.walMaintenance, {
+ ticks: 4,
+ busyStreak: 1,
+ busyTotal: 2,
+ lastBusyAt: "2026-09-06T10:00:00.000Z",
+ lastOkAt: "2026-09-06T11:00:00.000Z",
+ });
+
+ const json = JSON.stringify(payload);
+ assert.equal(json.includes("walTimer"), false);
+ assert.equal(json.includes("retryTimer"), false);
+
+ // Absent / null state projects to null (degraded path parity).
+ assert.equal(projectWalMaintenanceSummary(null), null);
+ assert.equal(projectWalMaintenanceSummary(undefined), null);
+});
diff --git a/tests/unit/reconcile-changelog.test.ts b/tests/unit/reconcile-changelog.test.ts
new file mode 100644
index 0000000000..83db5c7c24
--- /dev/null
+++ b/tests/unit/reconcile-changelog.test.ts
@@ -0,0 +1,431 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const mod = await import("../../scripts/release/reconcile-changelog.mjs");
+const {
+ primaryRefs,
+ parseBlocks,
+ addCredit,
+ appendLink,
+ bulletFromSubject,
+ sectionForType,
+ dedupeBullets,
+ computeCoverage,
+ versionSectionRange,
+ otherSectionsText,
+ rankAuthors,
+ reconcile,
+} = mod;
+
+const REPO = "diegosouzapw/OmniRoute";
+const link = (n: number) => `[#${n}](https://github.com/${REPO}/pull/${n})`;
+
+test("primaryRefs: /pull links and the LAST #N count, incidental mentions do not", () => {
+ const b = `- **test:** counts after additions — Opper #11629 + 1min.ai #11631 (${link(11674)}) — thanks @x`;
+ assert.deepEqual([...primaryRefs(b)].sort(), [11674]);
+ assert.deepEqual(
+ [...primaryRefs("- fix(x): thing (#11766) (#11794)")].sort(),
+ [11766, 11794],
+ "explicit (#N) groups count"
+ );
+ assert.deepEqual(
+ [...primaryRefs("- **security:** verbatim,\n across two lines (#12457)")],
+ [12457],
+ "last ref of a multi-line block"
+ );
+ assert.deepEqual([...primaryRefs("- plain sentence, no refs")], []);
+});
+
+test("parseBlocks: sections by heading, continuation lines kept; fragments collapse to one line", () => {
+ const text = `### ✨ New Features
+
+- **feat(a):** one
+ continued here
+
+### 🐛 Bug Fixes
+
+- **fix(b):** two
+- **fix(c):** three
+`;
+ const p = parseBlocks(text);
+ assert.equal(p.features.length, 1);
+ assert.equal(p.features[0], "- **feat(a):** one\n continued here");
+ assert.deepEqual(p.fixes, ["- **fix(b):** two", "- **fix(c):** three"]);
+ const frag = parseBlocks("- **fix(d):** wrapped\n tail line\n", {
+ fixedSection: "fixes",
+ collapse: true,
+ });
+ assert.deepEqual(frag.fixes, ["- **fix(d):** wrapped tail line"]);
+});
+
+test("addCredit: appends, extends a trailing thanks group, skips maintainer and already-mentioned handles", () => {
+ assert.equal(addCredit("- x (#1)", ["alice"]), "- x (#1) — thanks @alice");
+ assert.equal(addCredit("- x (#1) — thanks @alice", ["bob"]), "- x (#1) — thanks @alice / @bob");
+ assert.equal(addCredit("- x (#1 — thanks @alice)", ["bob"]), "- x (#1 — thanks @alice / @bob)");
+ assert.equal(
+ addCredit("- x (#1) — thanks @alice", ["alice", "diegosouzapw"]),
+ "- x (#1) — thanks @alice"
+ );
+ assert.equal(
+ addCredit("- x by @alice-dev", ["alice"]),
+ "- x by @alice-dev — thanks @alice",
+ "handle boundary is exact"
+ );
+});
+
+test("appendLink: adds the PR link before a trailing thanks group and never twice", () => {
+ const once = appendLink("- x — thanks @alice", REPO, 12);
+ assert.equal(once, `- x (${link(12)}) — thanks @alice`);
+ assert.equal(appendLink(once, REPO, 12), once);
+ assert.equal(appendLink("- y", REPO, 3), `- y (${link(3)})`);
+});
+
+test("bulletFromSubject: bolds the conventional prefix, strips (#N) and URGENT, maps specials", () => {
+ assert.deepEqual(bulletFromSubject("fix(cli): thing (#11794)"), {
+ type: "fix",
+ text: "**fix(cli):** thing",
+ });
+ assert.deepEqual(bulletFromSubject("[URGENT] fix(dev): hot (#1)"), {
+ type: "fix",
+ text: "**fix(dev):** hot",
+ });
+ assert.deepEqual(bulletFromSubject("Update README.md (#5)"), {
+ type: "other",
+ text: "Update README.md",
+ });
+ assert.deepEqual(
+ bulletFromSubject("Update README.md (#5)", { "Update README.md": "docs(readme): touch-ups" }),
+ {
+ type: "docs",
+ text: "**docs(readme):** touch-ups",
+ }
+ );
+ assert.equal(sectionForType("feat"), "features");
+ assert.equal(sectionForType("perf"), "fixes");
+ assert.equal(sectionForType("docs"), "maintenance");
+});
+
+test("dedupeBullets: keeps the best-linked twin and merges the other twin's refs", () => {
+ const a = `- **fix(glm):** drop the extra arg (${link(12770)})`;
+ const b = "- **fix(glm):** drop the extra arg";
+ const c = `- **test(check):** escape the fixture (direct commit \`fb7445eaa1\`)`;
+ const d = `- **test(check):** escape the fixture (${link(11942)})`;
+ const { kept, dropped } = dedupeBullets([b, a, c, d]);
+ assert.deepEqual(dropped, [b, c]);
+ assert.equal(kept.length, 2);
+ assert.equal(kept[0], a);
+ assert.ok(
+ kept[1].includes(link(11942)) && kept[1].includes("direct commit `fb7445eaa1`"),
+ "merged the twin's hash"
+ );
+});
+
+test("computeCoverage: own PR must be a primary ref; incidental mention leaves the commit uncovered", () => {
+ const rows = [
+ { hash: "aaaaaaaaa1", pr: 11631, refs: [11631], subject: "feat: 1min (#11631)" },
+ { hash: "bbbbbbbbb2", pr: 11674, refs: [11674], subject: "test: counts (#11674)" },
+ { hash: "ccccccccc3", pr: null, refs: [11449], subject: "fix: direct (#11449)" },
+ { hash: "ddddddddd4", pr: 12000, refs: [12000], subject: "fix: closes issue (#12000)" },
+ { hash: "eeeeeeeee5", pr: null, refs: [], subject: "chore(release): open cycle" },
+ { hash: "f0f0f0f0f0abcdef", pr: null, refs: [], subject: "fix(ui): direct push" },
+ ];
+ const bullets = [
+ `- **test:** counts — 1min.ai #11631 (${link(11674)})`,
+ "- **fix:** direct push fixing #11449",
+ "- **fix:** the issue bullet cites #7000 only",
+ "- **fix(ui):** direct push (direct commit `f0f0f0f0f0`)",
+ ];
+ const { uncovered } = computeCoverage(rows, bullets, {
+ closingPrs: new Set([12000]), // PR 12000 closes issue 7000, which is cited
+ skipHashes: new Set(["eeeeeeeee"]),
+ });
+ assert.deepEqual(
+ uncovered.map((r) => r.hash),
+ ["aaaaaaaaa1"],
+ "only the incidentally-mentioned PR is uncovered"
+ );
+});
+
+test("versionSectionRange / otherSectionsText isolate exactly one version block", () => {
+ const cl = `# Changelog
+
+## [Unreleased]
+
+### ✨ New Features
+
+- old feature (#1)
+
+## [3.8.51] — TBD
+
+### 🐛 Bug Fixes
+
+- **fix:** in section (#2)
+
+---
+
+## [3.8.50] — 2026-08-25
+
+- shipped (#3)
+`;
+ const r = versionSectionRange(cl, "3.8.51");
+ assert.ok(r && cl.slice(r.start, r.end).includes("in section (#2)"));
+ assert.ok(!cl.slice(r.start, r.end).includes("shipped (#3)"));
+ const other = otherSectionsText(cl, "3.8.51");
+ assert.ok(
+ other.includes("old feature (#1)") &&
+ other.includes("shipped (#3)") &&
+ !other.includes("in section (#2)")
+ );
+ assert.equal(versionSectionRange(cl, "9.9.9"), null);
+});
+
+test("rankAuthors: consolidates display-name drift by PR login, excludes bots", () => {
+ const rows = [
+ { authorName: "Webman", prAuthor: "jonlwheat2-gif" },
+ { authorName: "Webman", prAuthor: "jonlwheat2-gif" },
+ { authorName: "WebPerson", prAuthor: "jonlwheat2-gif" },
+ { authorName: "solo", prAuthor: null },
+ { authorName: "dependabot[bot]", prAuthor: null },
+ ];
+ const { top, counts } = rankAuthors(rows);
+ assert.deepEqual(top[0], ["Webman (@jonlwheat2-gif)", 3]);
+ assert.deepEqual(top[1], ["solo", 1]);
+ assert.equal("dependabot[bot]" in counts, false);
+});
+
+test("reconcile: folds fragments under the version section, credits by fragment origin, drops shipped twins, generates the rest", () => {
+ const changelog = `# Changelog
+
+## [Unreleased]
+
+### ✨ New Features
+
+- **feat(old):** shipped last cycle but never moved (#9000)
+
+## [3.8.51] — TBD
+
+### ✨ New Features
+
+### 🐛 Bug Fixes
+
+- **security(streaming):** pre-existing bullet stays verbatim,
+ across two lines (#12457)
+
+### 📝 Maintenance
+
+---
+
+## [3.8.50] — 2026-08-25
+
+### 🐛 Bug Fixes
+
+- **fix(cli):** \`omniroute update\` now finds npm on Windows. It called execFile with no shell (#11335)
+
+---
+`;
+ const rows = [
+ {
+ hash: "a0ceccc6f0aaaa",
+ date: "2026-08-25",
+ authorName: "Burak",
+ authorEmail: "b@x",
+ subject: "feat(search): add Xquik (#11370)",
+ refs: [11370],
+ pr: 11370,
+ coauthors: [],
+ },
+ {
+ hash: "b1b1b1b1b1bbbb",
+ date: "2026-08-26",
+ authorName: "Zero",
+ authorEmail: "z@x",
+ subject: "feat(providers): add 1min.ai provider (#11631)",
+ refs: [11631],
+ pr: 11631,
+ coauthors: [],
+ },
+ {
+ hash: "c2c2c2c2c2cccc",
+ date: "2026-08-26",
+ authorName: "diegosouzapw",
+ authorEmail: "d@x",
+ subject: "feat(models): recreated (#11887)",
+ refs: [11887],
+ pr: 11887,
+ coauthors: [],
+ },
+ {
+ hash: "d3d3d3d3d3dddd",
+ date: "2026-08-27",
+ authorName: "Markus Hartung",
+ authorEmail: "m@x",
+ subject: "fix(x): phantom fragment carrier (#11434)",
+ refs: [11434],
+ pr: 11434,
+ coauthors: [],
+ },
+ {
+ hash: "e4e4e4e4e4eeee",
+ date: "2026-08-27",
+ authorName: "dependabot[bot]",
+ authorEmail: "dep@x",
+ subject: "deps: bump a from 1 to 2 (#11500)",
+ refs: [11500],
+ pr: 11500,
+ coauthors: [],
+ },
+ {
+ hash: "f5f5f5f5f5ffff",
+ date: "2026-08-28",
+ authorName: "diegosouzapw",
+ authorEmail: "d@x",
+ subject: "chore(release): open v3.8.51 development cycle",
+ refs: [],
+ pr: null,
+ coauthors: [],
+ },
+ {
+ hash: "0606060606aaaa",
+ date: "2026-08-29",
+ authorName: "diegosouzapw",
+ authorEmail: "d@x",
+ subject: "fix(streaming): sanitize (#12457)",
+ refs: [12457],
+ pr: 12457,
+ coauthors: [],
+ },
+ ];
+ const prs = [
+ {
+ number: 11631,
+ title: "1min",
+ author: { login: "zero-executioner" },
+ body: "",
+ closingIssuesReferences: [],
+ },
+ {
+ number: 11887,
+ title: "recreated",
+ author: { login: "diegosouzapw" },
+ body: "",
+ closingIssuesReferences: [],
+ },
+ {
+ number: 11434,
+ title: "carrier",
+ author: { login: "hartmark" },
+ body: "",
+ closingIssuesReferences: [],
+ },
+ {
+ number: 11500,
+ title: "bump",
+ author: { login: "app/dependabot" },
+ body: "",
+ closingIssuesReferences: [],
+ },
+ {
+ number: 12457,
+ title: "sanitize",
+ author: { login: "diegosouzapw" },
+ body: "",
+ closingIssuesReferences: [],
+ },
+ ];
+ const fragments = [
+ // no-ref fragment whose origin commit is the contributor's own PR → link + credit from origin
+ {
+ path: "changelog.d/features/xquik.md",
+ section: "features",
+ text: "- **feat(search):** Add Xquik X search\n with typed results\n",
+ originHash: "a0ceccc6f",
+ originPr: 11370,
+ prefixPr: null,
+ },
+ // fragment named after the CLOSED PR (11685) but landed by the recreated PR 11887 → origin wins, extra credit applies
+ {
+ path: "changelog.d/features/11685-antigravity.md",
+ section: "features",
+ text: "- Default Antigravity connections to auto-sync\n",
+ originHash: "c2c2c2c2c",
+ originPr: 11887,
+ prefixPr: 11685,
+ },
+ // phantom fragment: text already shipped in [3.8.50] → dropped
+ {
+ path: "changelog.d/fixes/cli-update-npm-win32.md",
+ section: "fixes",
+ text: "- **fix(cli):** `omniroute update` now finds npm on Windows. It called execFile with no shell\n",
+ originHash: "d3d3d3d3d",
+ originPr: 11434,
+ prefixPr: null,
+ },
+ ];
+ const { changelog: next, report } = reconcile({
+ changelog,
+ version: "3.8.51",
+ repo: REPO,
+ rows,
+ prs,
+ fragments,
+ credits: { 11887: ["MumuTW"] },
+ fragmentCredit: { "changelog.d/features/xquik.md": ["kriptoburak"] },
+ today: "2026-09-07",
+ baseTip: "0915890",
+ headTip: "d6f3150",
+ skipHashes: new Set(["f5f5f5f5f"]),
+ });
+ const section = next.slice(next.indexOf("## [3.8.51]"), next.indexOf("## [3.8.50]"));
+ // structure
+ assert.ok(
+ section.includes("### 📊 Release by the numbers") &&
+ section.includes("### 🏆 Top 25 contributors this cycle")
+ );
+ assert.ok(section.indexOf("### ✨ New Features") < section.indexOf("### 🐛 Bug Fixes"));
+ // [Unreleased] and [3.8.50] untouched
+ assert.ok(next.includes("- **feat(old):** shipped last cycle but never moved (#9000)"));
+ assert.equal(
+ (next.match(/now finds npm on Windows/g) || []).length,
+ 1,
+ "phantom fragment dropped, original kept"
+ );
+ // pre-existing bullet verbatim (two lines)
+ assert.ok(
+ section.includes(
+ "- **security(streaming):** pre-existing bullet stays verbatim,\n across two lines (#12457)"
+ )
+ );
+ // fragment credited by origin + override, collapsed to one line
+ assert.ok(
+ section.includes(
+ `- **feat(search):** Add Xquik X search with typed results — thanks @kriptoburak`
+ ),
+ section
+ );
+ // recreated PR: link to the landing PR, credit to the original author
+ assert.ok(
+ section.includes(
+ `- Default Antigravity connections to auto-sync (${link(11887)}) — thanks @MumuTW`
+ ),
+ section
+ );
+ // uncovered commit generated with author credit; dependabot rolled up; cycle-open skipped
+ assert.ok(
+ section.includes(
+ `- **feat(providers):** add 1min.ai provider (${link(11631)}) — thanks @zero-executioner`
+ )
+ );
+ assert.ok(section.includes("- **deps:** 1 Dependabot bumps — bump a from 1 to 2"));
+ assert.ok(!section.includes("open v3.8.51 development cycle"));
+ // the carrier commit of the dropped phantom fragment is NOT silently covered → it gets its own bullet
+ assert.ok(
+ section.includes(`- **fix(x):** phantom fragment carrier (${link(11434)}) — thanks @hartmark`)
+ );
+ assert.equal(report.dropped.length, 1);
+ assert.equal(report.generated.features, 1);
+ assert.equal(
+ report.generated.fixes,
+ 1,
+ "12457's own commit is covered by the pre-existing multi-line bullet; only 11434 is generated"
+ );
+});
diff --git a/tests/unit/saturation-signals-singleflight.test.ts b/tests/unit/saturation-signals-singleflight.test.ts
new file mode 100644
index 0000000000..268e171906
--- /dev/null
+++ b/tests/unit/saturation-signals-singleflight.test.ts
@@ -0,0 +1,94 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const satMod = await import("../../src/lib/quota/saturationSignals.ts");
+const { getSaturation, _clearSaturationCache, __setGenericUsageFetcherForTests } = satMod;
+
+test.beforeEach(() => {
+ _clearSaturationCache();
+ // NOTE: _clearSaturationCache empties _cache AND _inflight. _inflight must
+ // always be empty between tests anyway because every getSaturation call is
+ // awaited (finally deletes on settle) — never fire-and-forget a call
+ // without awaiting it in tests.
+});
+
+test("concurrent same-key calls resolve to the same value (singleflight)", async () => {
+ const dim = { unit: "tokens", window: "hourly" } as const;
+ const [a, b] = await Promise.all([
+ getSaturation("conn-dedup", "unknown_xyz_dedup", dim),
+ getSaturation("conn-dedup", "unknown_xyz_dedup", dim),
+ ]);
+ assert.equal(a, b);
+ assert.equal(a, 0); // fail-open; both callers shared the single miss
+});
+
+test("_inflight entry is cleaned up after resolve (no leak across keys)", async () => {
+ const dim = { unit: "tokens", window: "hourly" } as const;
+ await getSaturation("conn-a", "unknown_xyz_a", dim);
+ await getSaturation("conn-b", "unknown_xyz_b", dim);
+ const [a, b] = await Promise.all([
+ getSaturation("conn-a", "unknown_xyz_a", dim),
+ getSaturation("conn-b", "unknown_xyz_b", dim),
+ ]);
+ assert.equal(a, 0);
+ assert.equal(b, 0);
+});
+
+test("error path serves fail-open 0 and poisons _cache (no refetch before TTL)", async () => {
+ const dim = { unit: "tokens", window: "hourly" } as const;
+ const first = await getSaturation("conn-rej", "unknown_xyz_rej", dim);
+ assert.equal(first, 0);
+ const second = await getSaturation("conn-rej", "unknown_xyz_rej", dim);
+ assert.equal(second, 0); // _cache hit, not a refetch
+});
+
+test("cache hit does not create _inflight state", async () => {
+ const dim = { unit: "tokens", window: "hourly" } as const;
+ await getSaturation("conn-hit", "unknown_xyz_hit", dim);
+ const again = await getSaturation("conn-hit", "unknown_xyz_hit", dim);
+ assert.equal(again, 0);
+});
+
+test("concurrent same-key calls start ONE upstream fetch (singleflight)", async () => {
+ const dim = { unit: "tokens", window: "hourly" } as const;
+ let calls = 0;
+ __setGenericUsageFetcherForTests(async () => { calls++; return { quotas: {} }; });
+ try {
+ _clearSaturationCache();
+ const [a, b] = await Promise.all([
+ getSaturation("conn-dedup-count", "unknown_xyz_count", dim),
+ getSaturation("conn-dedup-count", "unknown_xyz_count", dim),
+ ]);
+ assert.equal(a, b);
+ assert.equal(calls, 1, "second concurrent caller must share the inflight fetch");
+ } finally {
+ __setGenericUsageFetcherForTests(null);
+ }
+});
+
+test("reject path fails open to 0, serves _cache without refetch, cleans _inflight", async () => {
+ const dim = { unit: "tokens", window: "hourly" } as const;
+ let calls = 0;
+ __setGenericUsageFetcherForTests(async () => { calls++; throw new Error("boom"); });
+ try {
+ _clearSaturationCache();
+ const [a, b] = await Promise.all([
+ getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim),
+ getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim),
+ ]);
+ assert.equal(a, 0); // fail-open
+ assert.equal(b, 0); // shared inflight reject
+ assert.equal(calls, 1, "concurrent reject must share the single inflight fetch");
+ const second = await getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim);
+ assert.equal(second, 0); // _cache poisoned to 0 for CACHE_TTL_MS
+ assert.equal(calls, 1, "immediate call must hit _cache, not refetch");
+ // _inflight was cleaned by the finally delete: after clearing the poisoned
+ // _cache entry a new fetch is possible again.
+ _clearSaturationCache();
+ const third = await getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim);
+ assert.equal(third, 0);
+ assert.equal(calls, 2, "after clear a refetch must be possible");
+ } finally {
+ __setGenericUsageFetcherForTests(null);
+ }
+});
diff --git a/tests/unit/ui/combo-health-null-quota.test.tsx b/tests/unit/ui/combo-health-null-quota.test.tsx
new file mode 100644
index 0000000000..4b781083a4
--- /dev/null
+++ b/tests/unit/ui/combo-health-null-quota.test.tsx
@@ -0,0 +1,77 @@
+// @vitest-environment jsdom
+// ComboHealthTab fetch "/api/usage/combo-health-dashboard?range=${range}&horizon=${horizon}" (ComboHealthTab.tsx:817-820) au mount ;
+// on intercepte global.fetch et on monte le composant réel.
+import React from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const translate = (key: string, params?: Record) =>
+ params && "value" in params ? String(params.value) : key;
+vi.mock("next-intl", () => ({ useTranslations: () => translate }));
+
+const ComboHealthTab = (
+ await import("../../../src/app/(dashboard)/dashboard/analytics/ComboHealthTab")
+).default;
+
+const NULL_COMBO = {
+ comboId: "c1",
+ comboName: "null-quota",
+ strategy: "auto",
+ models: ["m1"],
+ quotaHealth: {
+ providers: [
+ { provider: "openrouter", remainingPct: null, isExhausted: false, trend: "stable" },
+ ],
+ worstRemainingPct: null,
+ },
+ usageSkew: { modelDistribution: [], giniCoefficient: 0 },
+ performance: { avgLatencyMs: 0, successRate: 0, totalRequests: 0 },
+};
+// Enveloppe réelle lue par ComboHealthTab.tsx:828-836 : result.health + result.errors
+// (pas {combos:[…]} — sinon setData(undefined), liste vide, jamais de n/a).
+const NULL_PAYLOAD = {
+ health: { timeRange: "24h", combos: [NULL_COMBO] },
+ forecast: null,
+ autopilot: null,
+ scoring: null,
+ errors: {},
+};
+
+function mount() {
+ const el = document.createElement("div");
+ document.body.appendChild(el);
+ const root = createRoot(el);
+ return { el, root };
+}
+
+describe("combo health null quota", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: unknown) => {
+ expect(String(url)).toContain("/api/usage/combo-health-dashboard");
+ return new Response(JSON.stringify(NULL_PAYLOAD), { status: 200 });
+ })
+ );
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ document.body.innerHTML = "";
+ });
+
+ it("renders n/a in the quota section, bar width 0%", async () => {
+ const { el, root } = mount();
+ await act(async () => {
+ root.render( );
+ });
+ // Assertion scopée à la section quota (C1) : le bloc perf rend "0.0%" légitime
+ // via formatPercent(successRate*100) même post-fix — un not.toContain("0%") global
+ // serait un faux-positif permanent.
+ const quotaSection = el.querySelector("section") as HTMLElement | null;
+ const quotaText = quotaSection?.textContent ?? "";
+ expect(quotaText).toContain("n/a");
+ const bar = quotaSection?.querySelector('[style*="width"]') as HTMLElement | null;
+ expect(bar?.style.width).toBe("0%");
+ });
+});
diff --git a/tests/unit/volcengine-plan-binding-upsert.test.ts b/tests/unit/volcengine-plan-binding-upsert.test.ts
new file mode 100644
index 0000000000..51a3ca840e
--- /dev/null
+++ b/tests/unit/volcengine-plan-binding-upsert.test.ts
@@ -0,0 +1,203 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { resetDbInstance } from "../../src/lib/db/core.ts";
+import {
+ createProviderConnection,
+ getProviderConnectionById,
+} from "../../src/models/index.ts";
+import { detectPlan, __testing as bindingTesting } from "../../src/lib/providers/volcenginePlanBinding.ts";
+
+test("detectPlan returns available: false when account has no active quota windows (unsubscribed)", async (t) => {
+ const originalFetch = globalThis.fetch;
+ t.after(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ // Mock console response with empty QuotaUsage
+ globalThis.fetch = async () =>
+ new Response(
+ JSON.stringify({
+ ResponseMetadata: {},
+ Result: {
+ QuotaUsage: [],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+
+ const coding = await detectPlan("coding", "cookie=1", "csrf=1");
+ assert.equal(coding.available, false);
+
+ // Mock console response for Agent with empty quota object
+ globalThis.fetch = async () =>
+ new Response(
+ JSON.stringify({
+ ResponseMetadata: {},
+ Result: {},
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+
+ const agent = await detectPlan("agent", "cookie=1", "csrf=1");
+ assert.equal(agent.available, false);
+});
+
+test("volcenginePlanBinding upsert rules with SQLite temp isolation", async (t) => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "or-volc-test-"));
+ const prevDataDir = process.env.DATA_DIR;
+ process.env.DATA_DIR = tempDir;
+
+ t.after(() => {
+ resetDbInstance();
+ if (prevDataDir) process.env.DATA_DIR = prevDataDir;
+ else delete process.env.DATA_DIR;
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ resetDbInstance();
+
+ // T-1: Single connection named 'main' is adopted and updated, preserving name and autoFetchModels
+ const conn1 = await createProviderConnection({
+ provider: "volcengine-coding-plan",
+ name: "main",
+ apiKey: "ark-original-key-1",
+ providerSpecificData: { autoFetchModels: true, customTag: "keep-me" },
+ });
+
+ const updated1 = await (bindingTesting as any).upsertConnection(
+ "coding",
+ "ark-new-key-1",
+ "new-cookie-1",
+ "new-csrf-1",
+ 123,
+ { dummy: 1 },
+ undefined
+ );
+
+ assert.equal(updated1.id, conn1.id);
+ assert.equal(updated1.name, "main", "Preserves original custom name 'main'");
+ assert.equal(updated1.apiKey, "ark-new-key-1");
+ assert.equal(updated1.providerSpecificData.autoFetchModels, true, "Preserves existing PSD autoFetchModels");
+ assert.equal(updated1.providerSpecificData.customTag, "keep-me", "Preserves existing PSD customTag");
+ assert.equal(updated1.providerSpecificData.volcConsoleCookie, "new-cookie-1");
+ assert.equal(updated1.providerSpecificData.volcApiKeyId, 123);
+
+ // T-4: Multiple connections exist without match -> safely creates new connection without clobbering
+ await createProviderConnection({
+ provider: "volcengine-coding-plan",
+ name: "secondary",
+ apiKey: "ark-other-key-2",
+ providerSpecificData: {},
+ });
+
+ const createdNew = await (bindingTesting as any).upsertConnection(
+ "coding",
+ "ark-brand-new-key-3",
+ "new-cookie-3",
+ "new-csrf-3",
+ 999,
+ {},
+ undefined
+ );
+
+ assert.notEqual(createdNew.id, conn1.id);
+ assert.equal(createdNew.name, "Volcano Ark Coding Plan");
+ const preserved = await getProviderConnectionById(conn1.id as string);
+ assert.equal(preserved.apiKey, "ark-new-key-1", "Original connection was NOT clobbered");
+
+ // T-5: targetConnectionId with cross-provider guard
+ const agentConn = await createProviderConnection({
+ provider: "volcengine-agent-plan",
+ name: "agent-main",
+ apiKey: "ark-agent-key",
+ providerSpecificData: {},
+ });
+
+ // Passing conn1.id (which is coding-plan) into agent upsert must NOT match conn1
+ const agentUpsertResult = await (bindingTesting as any).upsertConnection(
+ "agent",
+ "ark-agent-new-key",
+ "agent-cookie",
+ "agent-csrf",
+ 888,
+ {},
+ conn1.id as string // Mismatched provider
+ );
+ assert.notEqual(agentUpsertResult.id, conn1.id);
+ assert.equal(agentUpsertResult.id, agentConn.id, "Matched the single agent connection instead");
+});
+
+test("findTargetConnection pure matching logic and single-connection adoption intent", () => {
+ const criteria = {
+ provider: "volcengine-coding-plan",
+ defaultName: "Volcano Ark Coding Plan",
+ apiKey: "ark-key-match",
+ };
+
+ // 1. targetConnectionId priority match (strictly same provider)
+ const list1 = [
+ { id: "c1", provider: "volcengine-coding-plan", name: "any" },
+ { id: "c2", provider: "volcengine-coding-plan", name: "other" },
+ ];
+ assert.equal(
+ bindingTesting.findTargetConnection(list1, { ...criteria, targetConnectionId: "c2" })?.id,
+ "c2"
+ );
+ // targetConnectionId with mismatched provider does not match
+ assert.equal(
+ bindingTesting.findTargetConnection(
+ [{ id: "c-other", provider: "different-provider", name: "any" }],
+ { ...criteria, targetConnectionId: "c-other" }
+ ),
+ undefined
+ );
+
+ // 2. ApiKey match
+ const list2 = [
+ { id: "c1", provider: "volcengine-coding-plan", apiKey: "ark-key-match" },
+ { id: "c2", provider: "volcengine-coding-plan", apiKey: "ark-diff-key" },
+ ];
+ assert.equal(bindingTesting.findTargetConnection(list2, criteria)?.id, "c1");
+
+ // 3. VolcApiKeyId match
+ const list3 = [
+ { id: "c1", provider: "volcengine-coding-plan", providerSpecificData: { volcApiKeyId: 777 } },
+ { id: "c2", provider: "volcengine-coding-plan", providerSpecificData: { volcApiKeyId: 888 } },
+ ];
+ assert.equal(
+ bindingTesting.findTargetConnection(list3, { ...criteria, apiKey: undefined, apiKeyId: 888 })?.id,
+ "c2"
+ );
+
+ // 4. Default name match
+ const list4 = [
+ { id: "c1", provider: "volcengine-coding-plan", name: "custom-name" },
+ { id: "c2", provider: "volcengine-coding-plan", name: "Volcano Ark Coding Plan" },
+ ];
+ assert.equal(
+ bindingTesting.findTargetConnection(list4, { ...criteria, apiKey: undefined })?.id,
+ "c2"
+ );
+
+ // 5. Intentional fallback: single existing connection adoption
+ const list5 = [{ id: "c-single", provider: "volcengine-coding-plan", name: "main" }];
+ assert.equal(
+ bindingTesting.findTargetConnection(list5, { ...criteria, apiKey: undefined })?.id,
+ "c-single",
+ "Adopts sole existing connection for provider"
+ );
+
+ // 6. Multiple connections without match -> undefined (new connection will be created)
+ const list6 = [
+ { id: "c1", provider: "volcengine-coding-plan", name: "account-a" },
+ { id: "c2", provider: "volcengine-coding-plan", name: "account-b" },
+ ];
+ assert.equal(
+ bindingTesting.findTargetConnection(list6, { ...criteria, apiKey: undefined }),
+ undefined,
+ "Does not clobber when multiple connections exist"
+ );
+});
diff --git a/tests/unit/volcengine-plan-quota-windows.test.ts b/tests/unit/volcengine-plan-quota-windows.test.ts
new file mode 100644
index 0000000000..64ada8fd33
--- /dev/null
+++ b/tests/unit/volcengine-plan-quota-windows.test.ts
@@ -0,0 +1,83 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { __testing } from "../../open-sse/services/usage/volcenginePlan.ts";
+
+test("mapCodingPlanUsage maps windows to canonical names and handles cap edge cases", () => {
+ const sampleResult = {
+ QuotaUsage: [
+ {
+ Level: "session",
+ Cap: 100,
+ Percent: 35,
+ ResetTimestamp: 1725686400,
+ },
+ {
+ Level: "weekly",
+ Cap: 100,
+ Percent: 10,
+ ResetTimestamp: 1726204800,
+ },
+ {
+ Level: "daily",
+ Cap: 0,
+ Percent: 0,
+ ResetTimestamp: 0,
+ },
+ ],
+ };
+
+ const quotas = __testing.mapCodingPlanUsage(sampleResult);
+ assert.ok(quotas["session (5h)"]);
+ assert.equal(quotas["session (5h)"].used, 35);
+ assert.equal(quotas["session (5h)"].total, 100);
+ assert.equal(quotas["session (5h)"].remainingPercentage, 65);
+ assert.ok(quotas["session (5h)"].resetAt);
+
+ assert.ok(quotas["weekly (7d)"]);
+ assert.equal(quotas["weekly (7d)"].used, 10);
+ assert.equal(quotas["weekly (7d)"].remainingPercentage, 90);
+
+ assert.ok(quotas["daily"]);
+ assert.equal(quotas["daily"].resetAt, null);
+ assert.equal("session" in quotas, false);
+ assert.equal("weekly" in quotas, false);
+});
+
+test("mapAgentPlanUsage maps windows to canonical names and guards zero total", () => {
+ const sampleResult = {
+ AFPFiveHour: {
+ Quota: 1000,
+ Used: 250,
+ ResetTime: 1725686400000,
+ },
+ AFPWeekly: {
+ Quota: 5000,
+ Used: 500,
+ ResetTime: 1726204800000,
+ },
+ AFPDaily: {
+ Quota: 0,
+ Used: 0,
+ ResetTime: 0,
+ },
+ };
+
+ const quotas = __testing.mapAgentPlanUsage(sampleResult);
+ assert.ok(quotas["session (5h)"]);
+ assert.equal(quotas["session (5h)"].used, 250);
+ assert.equal(quotas["session (5h)"].total, 1000);
+ assert.equal(quotas["session (5h)"].remainingPercentage, 75);
+
+ assert.ok(quotas["weekly (7d)"]);
+ assert.equal(quotas["weekly (7d)"].used, 500);
+ assert.equal(quotas["weekly (7d)"].total, 5000);
+ assert.equal(quotas["weekly (7d)"].remainingPercentage, 90);
+
+ assert.ok(quotas["daily"]);
+ assert.equal(quotas["daily"].total, 0);
+ // Total 0 must result in remainingPercentage: 0, NOT 100 (which poisons routing priority)
+ assert.equal(quotas["daily"].remainingPercentage, 0);
+
+ assert.equal("AFPFiveHour" in quotas, false);
+ assert.equal("AFPWeekly" in quotas, false);
+});
diff --git a/tests/unit/volcengine-plan-request-sanitization.test.ts b/tests/unit/volcengine-plan-request-sanitization.test.ts
new file mode 100644
index 0000000000..b62f510eca
--- /dev/null
+++ b/tests/unit/volcengine-plan-request-sanitization.test.ts
@@ -0,0 +1,25 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { sanitizeProviderSpecificDataForResponse } from "../../src/lib/providers/requestDefaults.ts";
+
+test("sanitizeProviderSpecificDataForResponse strips volcConsoleCookie and volcCsrfToken", () => {
+ const input = {
+ autoSync: true,
+ autoFetchModels: true,
+ volcConsoleCookie: "session=secret123; AccountID=acc456",
+ volcCsrfToken: "csrf-token-xyz",
+ volcApiKeyId: 1001,
+ volcPlanKind: "coding",
+ };
+
+ const sanitized = sanitizeProviderSpecificDataForResponse(input);
+ assert.ok(sanitized);
+ assert.equal(sanitized.autoSync, true);
+ assert.equal(sanitized.autoFetchModels, true);
+ assert.equal(sanitized.volcApiKeyId, 1001);
+ assert.equal(sanitized.volcPlanKind, "coding");
+ assert.equal(sanitized.volcConsoleCookie, undefined);
+ assert.equal(sanitized.volcCsrfToken, undefined);
+ assert.equal("volcConsoleCookie" in sanitized, false);
+ assert.equal("volcCsrfToken" in sanitized, false);
+});
diff --git a/tests/unit/wal-maintenance.test.ts b/tests/unit/wal-maintenance.test.ts
new file mode 100644
index 0000000000..8447de2829
--- /dev/null
+++ b/tests/unit/wal-maintenance.test.ts
@@ -0,0 +1,166 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { runCheckpointNow, logCheckpointOutcome } from "../../src/lib/db/walMaintenance.ts";
+
+function fakeDb(result: unknown, throws?: string) {
+ return {
+ pragma: (_s: string) => {
+ if (throws) throw new Error(throws);
+ return result;
+ },
+ };
+}
+
+test("busy row reports busy, not ok", () => {
+ const out = runCheckpointNow(fakeDb([{ busy: 1, log: 5, checkpointed: 5 }]) as never);
+ assert.equal(out.ok, false);
+ assert.equal(out.busy, true);
+});
+
+test("clean row reports ok", () => {
+ const out = runCheckpointNow(fakeDb([{ busy: 0, log: 0, checkpointed: 12 }]) as never);
+ assert.equal(out.ok, true);
+ assert.equal(out.busy, false);
+ assert.equal(out.logFrames, 0);
+ assert.equal(out.checkpointedFrames, 12);
+});
+
+test("sentinel -1 row is success, not busy", () => {
+ const out = runCheckpointNow(fakeDb([{ busy: 0, log: -1, checkpointed: -1 }]) as never);
+ assert.equal(out.ok, true);
+ assert.equal(out.busy, false);
+});
+
+test("bare object tolerated", () => {
+ const out = runCheckpointNow(fakeDb({ busy: 0, log: 0, checkpointed: 3 }) as never);
+ assert.equal(out.ok, true);
+});
+
+test("undefined, null, [] fail open", () => {
+ for (const shape of [undefined, null, []]) {
+ const out = runCheckpointNow(fakeDb(shape) as never);
+ assert.equal(out.ok, true);
+ assert.equal(out.busy, false);
+ }
+});
+
+test("bun:sqlite checkpoint shape parses (array of one row)", async (t) => {
+ if (!process.versions.bun) {
+ t.skip("bun:sqlite is only available under Bun");
+ return;
+ }
+ const { Database } = await import("bun:sqlite");
+ const { createBunSqliteAdapter } = await import("../../src/lib/db/adapters/bunSqliteAdapter.ts");
+ const adapter = createBunSqliteAdapter(new Database(":memory:"), ":memory:");
+ t.after(() => adapter.close());
+ const out = runCheckpointNow(adapter, "TRUNCATE");
+ assert.equal(out.ok, true);
+ assert.equal(out.busy, false);
+});
+
+test("pragma throw never propagates", () => {
+ const out = runCheckpointNow(fakeDb(undefined, "database is locked") as never);
+ assert.equal(out.ok, false);
+ assert.match(out.error ?? "", /database is locked/);
+});
+
+test("error outcome (ok:false, busy:false) is logged as a failure, not swallowed", () => {
+ const warnings: string[] = [];
+ const origWarn = console.warn;
+ console.warn = (...args: unknown[]) => {
+ warnings.push(args.map(String).join(" "));
+ };
+ try {
+ logCheckpointOutcome(
+ {
+ ok: false,
+ busy: false,
+ skipped: false,
+ logFrames: null,
+ checkpointedFrames: null,
+ error: "boom",
+ },
+ "TRUNCATE",
+ 0
+ );
+ assert.ok(
+ warnings.some((line) => line.includes("WAL checkpoint failed")),
+ `expected a "WAL checkpoint failed" warn, got: ${JSON.stringify(warnings)}`
+ );
+ } finally {
+ console.warn = origWarn;
+ }
+});
+
+test("guarded ctx skips without calling pragma", () => {
+ let called = 0;
+ const db = {
+ pragma: (_s: string) => {
+ called++;
+ return [{ busy: 0, log: 0, checkpointed: 0 }];
+ },
+ };
+ const out = runCheckpointNow(db as never, "TRUNCATE", { sqliteFile: null });
+ assert.equal(out.skipped, true);
+ assert.equal(called, 0);
+});
+
+test("interval defaults to 6h, rejects garbage, honors 0", async () => {
+ const { getWalMaintenanceIntervalMs } = await import("../../src/lib/db/walMaintenance.ts");
+ assert.equal(getWalMaintenanceIntervalMs({} as NodeJS.ProcessEnv), 6 * 60 * 60 * 1000);
+ assert.equal(
+ getWalMaintenanceIntervalMs({
+ OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "nope",
+ } as NodeJS.ProcessEnv),
+ 6 * 60 * 60 * 1000
+ );
+ assert.equal(
+ getWalMaintenanceIntervalMs({
+ OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "60000",
+ } as NodeJS.ProcessEnv),
+ 60000
+ );
+ assert.equal(
+ getWalMaintenanceIntervalMs({ OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "0" } as NodeJS.ProcessEnv),
+ 0
+ );
+});
+
+test("__resetForTests zeroes state", async () => {
+ const mod = await import("../../src/lib/db/walMaintenance.ts");
+ mod.__resetForTests();
+ assert.deepEqual(mod.getWalMaintenanceState(), {
+ ticks: 0,
+ busyStreak: 0,
+ busyTotal: 0,
+ lastBusyAt: null,
+ lastOkAt: null,
+ });
+});
+
+test("start is silent and stateless under the test-process gate", async () => {
+ const mod = await import("../../src/lib/db/walMaintenance.ts");
+ mod.__resetForTests();
+ const db = { open: true, pragma: (_s: string) => [{ busy: 0, log: 0, checkpointed: 0 }] };
+ mod.startWalMaintenance(db as never, "/tmp/fake.sqlite");
+ assert.deepEqual(mod.getWalMaintenanceState(), {
+ ticks: 0,
+ busyStreak: 0,
+ busyTotal: 0,
+ lastBusyAt: null,
+ lastOkAt: null,
+ });
+ mod.__resetForTests();
+ mod.__resetForTests();
+ assert.deepEqual(mod.getWalMaintenanceState(), {
+ ticks: 0,
+ busyStreak: 0,
+ busyTotal: 0,
+ lastBusyAt: null,
+ lastOkAt: null,
+ });
+});
+
+test.beforeEach(async () => {
+ (await import("../../src/lib/db/walMaintenance.ts")).__resetForTests();
+});