mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
Merge remote-tracking branch 'origin/release/v3.8.51' into feat/orch-fase3-d
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -1244,7 +1244,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 171 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
|
||||
1
changelog.d/fixes/12787-saturation-singleflight.md
Normal file
1
changelog.d/fixes/12787-saturation-singleflight.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/12788-ratelimit-write-guard.md
Normal file
1
changelog.d/fixes/12788-ratelimit-write-guard.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/12832-call-logs-provider-stats.md
Normal file
1
changelog.d/fixes/12832-call-logs-provider-stats.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/12853-wal-maintenance-extraction.md
Normal file
1
changelog.d/fixes/12853-wal-maintenance-extraction.md
Normal file
@@ -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))
|
||||
1
changelog.d/fixes/12857-empty-quota-unknown.md
Normal file
1
changelog.d/fixes/12857-empty-quota-unknown.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/12951-codex-scope-cooldown-clear.md
Normal file
1
changelog.d/fixes/12951-codex-scope-cooldown-clear.md
Normal file
@@ -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.
|
||||
1
changelog.d/fixes/13094-deepseek-pow-slot-leak.md
Normal file
1
changelog.d/fixes/13094-deepseek-pow-slot-leak.md
Normal file
@@ -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.
|
||||
1
changelog.d/fixes/chatcore-legresult-narrowing.md
Normal file
1
changelog.d/fixes/chatcore-legresult-narrowing.md
Normal file
@@ -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`.
|
||||
1
changelog.d/fixes/chatcore-nonstreaming-regressions.md
Normal file
1
changelog.d/fixes/chatcore-nonstreaming-regressions.md
Normal file
@@ -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.
|
||||
1
changelog.d/fixes/google-key-redaction-length.md
Normal file
1
changelog.d/fixes/google-key-redaction-length.md
Normal file
@@ -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.
|
||||
1
changelog.d/fixes/volcengine-quota-binding.md
Normal file
1
changelog.d/fixes/volcengine-quota-binding.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(volcengine): resolve quota window mapping gaps, safe multi-connection console binding, and response cookie sanitization
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 356 providers, 150+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 110 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 42 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
|
||||
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 356 providers, 150+ free providers built-in, 20 routing strategies, 12-engine token compression, a built-in MCP server with 110 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 42 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
|
||||
<desc>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.</desc>
|
||||
<defs>
|
||||
<pattern id="gC" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1"/></pattern>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 356 AI providers, 150+ free tiers, about 1.47B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
|
||||
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 356 AI providers, 150+ free tiers, about 1.47B free tokens per month, 15 to 95 percent token savings, 20 routing strategies, zero dollars to start.">
|
||||
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
|
||||
<defs>
|
||||
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -90,7 +90,7 @@
|
||||
</circle>
|
||||
</g>
|
||||
<text x="364" y="216" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="20" font-weight="800" fill="url(#gradBrand)">OmniRoute — Smart Router</text>
|
||||
<text x="364" y="243" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa"><tspan fill="#8b5cf6">RTK + Caveman compression</tspan> · <tspan fill="#8b5cf6">19 routing strategies</tspan></text>
|
||||
<text x="364" y="243" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa"><tspan fill="#8b5cf6">RTK + Caveman compression</tspan> · <tspan fill="#8b5cf6">20 routing strategies</tspan></text>
|
||||
<text x="364" y="264" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">Circuit breakers · TLS stealth · MCP · A2A · Guardrails</text>
|
||||
|
||||
<!-- fan-out topology (always visible) -->
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
8
llm.txt
8
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.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]: <reason>`, 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, {
|
||||
|
||||
@@ -106,7 +106,18 @@ function solveInWorker(
|
||||
activeWorkerCount += 1;
|
||||
|
||||
return new Promise<number>((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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, { percentUsed: number; resetAt: string | null }>,
|
||||
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)
|
||||
|
||||
@@ -101,9 +101,16 @@ function tsToIso(seconds: number): string | null {
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
const CODING_LEVEL_TO_CANONICAL: Record<string, string> = {
|
||||
session: "session (5h)",
|
||||
weekly: "weekly (7d)",
|
||||
daily: "daily",
|
||||
monthly: "monthly",
|
||||
};
|
||||
|
||||
const CODING_WINDOW_LABEL: Record<string, string> = {
|
||||
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<string, UsageQuota> {
|
||||
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<string, UsageQuota> {
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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]" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
831
scripts/release/reconcile-changelog.mjs
Normal file
831
scripts/release/reconcile-changelog.mjs
Normal file
@@ -0,0 +1,831 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/release/reconcile-changelog.mjs
|
||||
//
|
||||
// Reconcile the living `## [<version>]` 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 -- <version> --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 <ref>] [--head <ref>]
|
||||
// [--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 <cached gh json>] [--dry-run] [--report <path.json>]
|
||||
//
|
||||
// Defaults: version = package.json; release branch = release/v<version>; 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<prev>` 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 \`<hash>\`)`. */
|
||||
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} <prev> && npm run check:changelog-integrity`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -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 <type>`
|
||||
|
||||
@@ -526,7 +526,7 @@ function ComboHealthCard({
|
||||
<MetricBlock
|
||||
icon="battery_status_good"
|
||||
label={t("comboHealthWorstQuotaLeft")}
|
||||
value={formatPercent(combo.quotaHealth.worstRemainingPct)}
|
||||
value={formatPercentOrDash(combo.quotaHealth.worstRemainingPct)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="balance"
|
||||
@@ -560,7 +560,8 @@ function ComboHealthCard({
|
||||
<div className="flex flex-col gap-3">
|
||||
{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 (
|
||||
<div
|
||||
@@ -574,7 +575,7 @@ function ComboHealthCard({
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
{t("comboHealthRemainingQuota", {
|
||||
value: formatPercent(provider.remainingPct, 1),
|
||||
value: formatPercentOrDash(provider.remainingPct, 1),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<unknown> {
|
||||
credentialHealth,
|
||||
adaptiveAdmission,
|
||||
chatAdmission,
|
||||
walMaintenance,
|
||||
});
|
||||
|
||||
if (generation === healthPayloadCacheGeneration) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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<boolean> {
|
||||
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";
|
||||
|
||||
@@ -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<T extends JsonRecord | undefined>(
|
||||
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,
|
||||
|
||||
@@ -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<TRow = unknown> {
|
||||
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<boolean> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -45,6 +50,61 @@ export function saveQuotaSnapshot(snapshot: Omit<QuotaSnapshotRow, "id" | "creat
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// #12860: When a new snapshot demonstrates headroom on a Codex connection,
|
||||
// lift any fallback-sourced scope cooldown parked by quota preflight.
|
||||
maybeLiftCodexCooldownOnHeadroom(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* `rowToCamel` leaves snake_case keys intact on some driver paths, so accept
|
||||
* either casing rather than trusting one shape.
|
||||
*/
|
||||
type SnapshotShape = Partial<QuotaSnapshotRow> & {
|
||||
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<QuotaSnapshotRow, "id" | "created_at">
|
||||
): 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: {
|
||||
|
||||
236
src/lib/db/walMaintenance.ts
Normal file
236
src/lib/db/walMaintenance.ts
Normal file
@@ -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<string, unknown>;
|
||||
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);
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,10 @@ const CACHE_TTL_MS = 30_000; // 30 seconds
|
||||
|
||||
const _cache = new Map<string, CacheEntry>();
|
||||
|
||||
// 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<string, Promise<number>>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<number> => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
24
tests/unit/api-health-version-source.test.ts
Normal file
24
tests/unit/api-health-version-source.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
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");
|
||||
});
|
||||
@@ -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)"));
|
||||
});
|
||||
|
||||
@@ -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<string, { used?: unknown } | undefined>;
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
449
tests/unit/codex-scope-cooldown-clear-12817.test.ts
Normal file
449
tests/unit/codex-scope-cooldown-clear-12817.test.ts
Normal file
@@ -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<void> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexConnection(): Promise<SeededConnection> {
|
||||
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<SeededConnection>;
|
||||
}
|
||||
|
||||
async function seedGlmConnection(): Promise<SeededConnection> {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: "glm-clear-12817",
|
||||
apiKey: "glm-clear-12817-key",
|
||||
providerSpecificData: {
|
||||
leftover: "keep-me",
|
||||
},
|
||||
}) as unknown as Promise<SeededConnection>;
|
||||
}
|
||||
|
||||
async function readConnection(id: string): Promise<Record<string, unknown>> {
|
||||
const connection = await providersDb.getProviderConnectionById(id);
|
||||
assert.ok(connection);
|
||||
return connection as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function psd(connection: Record<string, unknown>): Record<string, unknown> {
|
||||
return (connection.providerSpecificData ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> | undefined;
|
||||
const exhausted = data.codexExhaustedWindowByScope as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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);
|
||||
});
|
||||
63
tests/unit/combo-health-empty-snapshot.test.ts
Normal file
63
tests/unit/combo-health-empty-snapshot.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
121
tests/unit/db-rate-limit-guard.test.ts
Normal file
121
tests/unit/db-rate-limit-guard.test.ts
Normal file
@@ -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<string> {
|
||||
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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user