mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
122
tests/unit/model-aliases-settings-route-selfheal.test.ts
Normal file
122
tests/unit/model-aliases-settings-route-selfheal.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user