From e2d2c2759b4337851a27da6199bf2a605f8b8a4f Mon Sep 17 00:00:00 2001 From: jleonar2 <92810914+jleonar2@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:11:50 +0700 Subject: [PATCH] fix(api): self-hydrate model aliases from DB on GET after restart (#5777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix grammatical errors in readme (#5738) * fix(api): self-hydrate model aliases from DB on GET when in-memory state is empty In the standalone production build, webpack creates two separate copies of modelDeprecation.ts β€” one hydrated by the startup path (used for request routing) and one used by the /api/settings/model-aliases API route. The API route's copy starts with an empty _customAliases after each server restart, causing the Settings β†’ Routing UI to show 'No exact-match aliases configured' even though the aliases are persisted in the DB. The GET handler now detects an empty _customAliases state and reads the modelAliases key from the settings blob in the DB, calling setCustomAliases() to hydrate this module instance. This is a best-effort fallback β€” when _customAliases is already populated (e.g. by the startup path in dev mode), no DB read occurs. Regression test: tests/unit/model-aliases-settings-route-selfheal.test.ts - Verifies hydration from DB when in-memory state is empty - Verifies no hydration when in-memory state is already populated - Verifies graceful handling when no modelAliases exist in DB --------- Co-authored-by: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Co-authored-by: marcelpeterson Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 2 + src/app/api/settings/model-aliases/route.ts | 18 +++ ...el-aliases-settings-route-selfheal.test.ts | 122 ++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 tests/unit/model-aliases-settings-route-selfheal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9562065b66..9433791c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ ### πŸ”§ Bug Fixes +- **settings (model aliases β€” self-heal after restart):** the Settings β†’ Routing page showed "No exact-match aliases configured" after a server restart even though the aliases were persisted in the DB. Aliases are held in a module-local `_customAliases` map in `modelDeprecation.ts` that the boot path hydrates, but Next.js compiles the app-route module graph separately from the startup graph (the same webpack chunk-splitting class as #5312), so the `GET /api/settings/model-aliases` handler read a different, un-hydrated copy. The handler now self-heals: when its in-memory alias map is empty it reads `settings.modelAliases` from the DB (via the existing `getSettings()` db module β€” no raw SQL in the route) and repopulates the map, so the UI reflects the persisted aliases on the first GET after a restart. Regression guard: `tests/unit/model-aliases-settings-route-selfheal.test.ts`. ([#5777](https://github.com/diegosouzapw/OmniRoute/pull/5777) β€” thanks [@jleonar2](https://github.com/jleonar2)) + - **providers (grok-cli token auto-refresh):** grok-cli OAuth tokens were never proactively refreshed before their real expiry. `mapTokens` hardcoded `expiresIn: 21600` (6 h) regardless of the token's actual lifetime, so the persisted `expiresAt` was always "now + 6 h" and the proactive `tokenHealthCheck` sweep (refresh when `expiresAt - now < 5 min`) fired 6 h after import instead of shortly before the token really expired. `mapTokens` now computes `expiresIn` from the authoritative `expires_at` field in `~/.grok/auth.json` (ISO β†’ epoch-seconds) with a fallback to the JWT `exp` claim (payload-only decode, no signature trust); the hardcoded `21600` is kept only when neither is present. Regression guards: 3 new cases in `tests/unit/grok-cli-oauth.test.ts` (JWT `exp`, JSON `expires_at`, and the fallback). ([#5775](https://github.com/diegosouzapw/OmniRoute/pull/5775) β€” thanks [@Chewji9875](https://github.com/Chewji9875)) - **compression (CCR retrieve via MCP HTTP):** the `omniroute_ccr_retrieve` MCP tool returned `"CCR block not found"` for blocks stored earlier in the **same** session when called over the MCP HTTP transports (SSE / Streamable HTTP), e.g. from OpenCode in a Docker deployment. Compression stores each block keyed by the API-key principal (`String(apiKeyInfo.id)`), but the tool resolved the caller via `extra.authInfo.clientId` β€” which the MCP SDK never populates for API-key auth β€” so it fell back to `"anonymous"` and the compound store-key never matched. The retrieve tool now resolves the caller's API-key id from the MCP HTTP auth context (`httpAuthContext`) using the **same** `getApiKeyMetadata` lookup used at storage time, so retrieval matches storage. Cross-tenant IDOR isolation is preserved: a different key resolves to a different id β†’ miss; no key β†’ the anonymous bucket only. Regression guard: `tests/unit/compression/ccr-mcp-principal-5649.test.ts` (extraction, distinct-principal isolation, fail-closed, end-to-end storeβ†’retrieve). ([#5649](https://github.com/diegosouzapw/OmniRoute/issues/5649)) diff --git a/src/app/api/settings/model-aliases/route.ts b/src/app/api/settings/model-aliases/route.ts index 412fa61eef..3477f012cc 100644 --- a/src/app/api/settings/model-aliases/route.ts +++ b/src/app/api/settings/model-aliases/route.ts @@ -19,11 +19,29 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; /** * GET /api/settings/model-aliases * Returns the full alias map, separated into built-in and custom. + * + * Self-healing: if `_customAliases` is empty (e.g. after a server restart + * where the webpack-bundled module instance used by this route was not + * hydrated by the startup path), read `modelAliases` from the settings + * blob in the DB and hydrate this module instance. This bridges the + * webpack chunk-splitting gap in the standalone production build. */ export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; try { + const custom = getCustomAliases(); + if (Object.keys(custom).length === 0) { + try { + const settings = await getSettings(); + const stored = settings.modelAliases; + if (stored && typeof stored === "object" && Object.keys(stored).length > 0) { + setCustomAliases(stored as Record); + } + } catch { + // Best-effort hydration β€” fall through with empty custom aliases + } + } return NextResponse.json({ builtIn: getBuiltInAliases(), custom: getCustomAliases(), diff --git a/tests/unit/model-aliases-settings-route-selfheal.test.ts b/tests/unit/model-aliases-settings-route-selfheal.test.ts new file mode 100644 index 0000000000..30167fdce0 --- /dev/null +++ b/tests/unit/model-aliases-settings-route-selfheal.test.ts @@ -0,0 +1,122 @@ +/** + * Regression test: GET /api/settings/model-aliases self-heals when the + * webpack-bundled module instance used by this route was not hydrated at + * startup (standalone production build chunk-splitting issue). + * + * In the standalone build, webpack creates two separate copies of + * `modelDeprecation.ts` β€” one hydrated by startup (used for routing), + * one used by this API route (starts empty). The GET handler detects + * empty `_customAliases` and reads from the DB settings blob. + * + * @see PR # β€” fix/model-aliases-startup-persistence + */ +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"; +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-aliases-selfheal-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = process.env.JWT_SECRET || "model-aliases-selfheal-jwt"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const modelDeprecation = await import("../../open-sse/services/modelDeprecation.ts"); +const route = await import("../../src/app/api/settings/model-aliases/route.ts"); + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/settings/model-aliases hydrates custom aliases from DB when in-memory state is empty", async () => { + // Simulate: aliases were persisted to the DB settings blob (by a previous + // session or startup path) but the current module instance's _customAliases + // is empty (webpack chunk-splitting in standalone build). + await localDb.updateSettings({ + modelAliases: { + "claude-opus-4-8": "mimo/mimo-v2.5-pro", + "claude-sonnet-5": "mimo/mimo-v2.5-pro", + }, + }); + + // Verify the module instance used by the route starts empty. + // (In production this is the webpack-duplicated copy; here we force it.) + modelDeprecation.setCustomAliases({}); + assert.deepEqual(modelDeprecation.getCustomAliases(), {}); + + // GET should detect empty state, read from DB, and return the aliases. + const response = await route.GET( + await makeManagementSessionRequest("http://localhost/api/settings/model-aliases") + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.custom["claude-opus-4-8"], "mimo/mimo-v2.5-pro"); + assert.equal(body.custom["claude-sonnet-5"], "mimo/mimo-v2.5-pro"); + assert.equal( + body.all["claude-opus-4-8"], + "mimo/mimo-v2.5-pro", + "all should include custom aliases merged with built-in" + ); + + // Subsequent calls should return the same data (hydration was persisted + // in-memory, no redundant DB read needed). + const response2 = await route.GET( + await makeManagementSessionRequest("http://localhost/api/settings/model-aliases") + ); + const body2 = (await response2.json()) as any; + assert.equal(body2.custom["claude-opus-4-8"], "mimo/mimo-v2.5-pro"); +}); + +test("GET /api/settings/model-aliases skips hydration when custom aliases are already populated", async () => { + // Pre-populate the in-memory state (as the startup path normally does). + modelDeprecation.setCustomAliases({ + "old-model": "new-model", + }); + + // Write different data directly to DB (bypass updateSettings to avoid + // triggering applyRuntimeSettings, which would overwrite _customAliases). + const db = core.getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'modelAliases', ?)" + ).run(JSON.stringify({ "db-only-model": "db-target" })); + + const response = await route.GET( + await makeManagementSessionRequest("http://localhost/api/settings/model-aliases") + ); + const body = (await response.json()) as any; + + // The in-memory aliases take precedence; DB was not read because state + // was already populated. + assert.equal(body.custom["old-model"], "new-model"); + assert.equal(body.custom["db-only-model"], undefined); +}); + +test("GET /api/settings/model-aliases handles missing modelAliases in DB gracefully", async () => { + // No modelAliases in DB β€” the settings blob has no such key. + modelDeprecation.setCustomAliases({}); + + const response = await route.GET( + await makeManagementSessionRequest("http://localhost/api/settings/model-aliases") + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.deepEqual(body.custom, {}); + // Built-in aliases should still be present. + assert.ok(Object.keys(body.builtIn).length > 0); +});