Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 18 additions & 0 deletions src/app/api/settings/model-aliases/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/model-aliases-settings-route-selfheal.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});