fix(api): self-hydrate model aliases from DB on GET after restart (#5777)

* 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 <marcelpeterson@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
This commit is contained in:
jleonar2
2026-07-01 14:11:50 +07:00
committed by GitHub
parent ee5ee8b785
commit e2d2c2759b
3 changed files with 142 additions and 0 deletions

View File

@@ -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))

View File

@@ -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<string, string>);
}
} catch {
// Best-effort hydration — fall through with empty custom aliases
}
}
return NextResponse.json({
builtIn: getBuiltInAliases(),
custom: getCustomAliases(),

View File

@@ -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 #<TBD> — 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);
});