Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
a22ae440b4 refactor(db): flatten alias-sync provenance check to clear the new-code complexity ratchet (#11836)
- Extract dropManagedAlias/pruneMissingManagedAliases/pruneHiddenModelAliases/
  assignManagedAlias helpers out of syncManagedAvailableModelAliases so its cyclomatic
  complexity drops from 17 back under the 15 ratchet ceiling. No behavior change: same
  provenance rules, same prune/assign order, same return shape.
- Align tests/unit/model-sync-route.test.ts's 'stale-model' alias fixture: it now marks
  the alias as OmniRoute-managed (markManagedModelAlias) to simulate a prior sync, since
  the #11836 fix correctly stopped pruning hand-created aliases that were never touched by
  the managed sync — the test previously relied on the old (now-corrected) blanket-prune
  behavior.
2026-09-22 00:57:56 -03:00
diegosouzapw
1dde285c72 fix(db): stop syncManagedAvailableModelAliases from pruning hand-created custom aliases (#11836) 2026-09-21 20:46:15 -03:00
6 changed files with 215 additions and 41 deletions

View File

@@ -0,0 +1 @@
- fix(db): stop syncManagedAvailableModelAliases from silently adopting and later pruning hand-created custom model aliases (#11836)

View File

@@ -52,6 +52,9 @@ export {
setModelAlias,
deleteModelAlias,
deleteModelAliasesForProvider,
getManagedModelAliasNames,
markManagedModelAlias,
unmarkManagedModelAlias,
} from "./models/aliases";
export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias";
export type { SyncedAvailableModel } from "./models/synced";

View File

@@ -22,7 +22,7 @@ export async function getModelAliases() {
export async function setModelAlias(alias: string, model: unknown) {
const db = getDbInstance();
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)",
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)"
).run(alias, JSON.stringify(model));
finishModelCatalogWriteWithBackup();
}
@@ -30,9 +30,66 @@ export async function setModelAlias(alias: string, model: unknown) {
export async function deleteModelAlias(alias: string) {
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias);
await unmarkManagedModelAlias(alias);
finishModelCatalogWriteWithBackup();
}
// ──────── Managed-alias provenance marker (#11836) ────────
// Distinguishes an alias OmniRoute itself generated during a provider model sync from one a
// person typed in the dashboard. Only alias names recorded here are eligible for the
// sync's prune passes — resolveManagedModelAlias() and syncManagedAvailableModelAliases()
// must never delete or "adopt" an alias unless its name is present in this set, so a
// hand-created custom alias that happens to already point at a model's full id is never
// silently claimed (and later pruned) as if OmniRoute had generated it.
const MANAGED_ALIAS_NAMES_KEY = "names";
async function getManagedModelAliasNamesRow(): Promise<string[]> {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'managedModelAliasNames' AND key = ?")
.get(MANAGED_ALIAS_NAMES_KEY);
const parsed = getKeyValue(row).value;
if (!parsed) return [];
try {
const v = JSON.parse(parsed);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
/**
* The set of alias names OmniRoute has generated/adopted itself during a managed
* provider model sync (OpenRouter + OpenAI/Anthropic-compatible custom providers).
* Only these are eligible for sync-triggered pruning.
*/
export async function getManagedModelAliasNames(): Promise<Set<string>> {
return new Set(await getManagedModelAliasNamesRow());
}
async function writeManagedModelAliasNames(names: Set<string>): Promise<void> {
const db = getDbInstance();
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('managedModelAliasNames', ?, ?)"
).run(MANAGED_ALIAS_NAMES_KEY, JSON.stringify(Array.from(names)));
}
/** Record that `alias` was generated/adopted by OmniRoute's own sync logic. */
export async function markManagedModelAlias(alias: string): Promise<void> {
const names = await getManagedModelAliasNames();
if (names.has(alias)) return;
names.add(alias);
await writeManagedModelAliasNames(names);
}
/** Clear the managed marker for `alias` (called whenever an alias row is deleted). */
export async function unmarkManagedModelAlias(alias: string): Promise<void> {
const names = await getManagedModelAliasNames();
if (!names.has(alias)) return;
names.delete(alias);
await writeManagedModelAliasNames(names);
}
/**
* Cascade-delete every model-alias row that resolves to the given provider.
*
@@ -96,7 +153,9 @@ export function removeProviderAlias(providerId: string, alias: string): void {
delete current[alias];
const db = getDbInstance();
if (Object.keys(current).length === 0) {
db.prepare("DELETE FROM key_value WHERE namespace = 'providerAliases' AND key = ?").run(providerId);
db.prepare("DELETE FROM key_value WHERE namespace = 'providerAliases' AND key = ?").run(
providerId
);
} else {
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('providerAliases', ?, ?)"

View File

@@ -1,7 +1,9 @@
import {
deleteModelAlias,
getManagedModelAliasNames,
getModelAliases,
getModelIsHidden,
markManagedModelAlias,
setModelAlias,
} from "@/lib/db/models";
import { getProviderNodeById } from "@/lib/db/providers";
@@ -87,6 +89,79 @@ export async function deleteManagedAvailableModelAliasesForProvider(
return removedAliases;
}
type AliasSyncState = {
workingAliases: Record<string, string>;
managedAliasNames: Set<string>;
removedAliases: string[];
};
// Deletes one managed alias and keeps the in-memory sync state (workingAliases,
// managedAliasNames, removedAliases) consistent with the deletion.
async function dropManagedAlias(state: AliasSyncState, alias: string): Promise<void> {
await deleteModelAlias(alias);
delete state.workingAliases[alias];
state.managedAliasNames.delete(alias);
state.removedAliases.push(alias);
}
// Prune pass (#11836): only alias names OmniRoute itself generated/adopted
// (`managedAliasNames`) are eligible for deletion here — a hand-created custom alias that
// happens to already point at a model's full id must never be deleted just because that
// model transiently drops out of the sync's target set.
async function pruneMissingManagedAliases(
state: AliasSyncState,
storagePrefix: string,
targetFullModels: Set<string>
): Promise<void> {
for (const [alias, value] of Object.entries(state.workingAliases)) {
if (!state.managedAliasNames.has(alias)) continue;
if (!value.startsWith(`${storagePrefix}/`)) continue;
if (targetFullModels.has(value)) continue;
await dropManagedAlias(state, alias);
}
}
// A hidden model keeps no managed alias — drop any managed alias still pointing at it.
async function pruneHiddenModelAliases(state: AliasSyncState, fullModel: string): Promise<void> {
for (const [alias, value] of Object.entries(state.workingAliases)) {
if (value !== fullModel || !state.managedAliasNames.has(alias)) continue;
await dropManagedAlias(state, alias);
}
}
// Assigns (or adopts) the alias for one visible model. Only an alias OmniRoute itself
// writes here is eligible for the prune passes above (#11836) — an alias that already
// carries the right value (whether it is a genuinely managed alias from a prior sync, or a
// hand-created custom alias that happens to coincide with `fullModel`) is left exactly
// as-is and its provenance is never changed.
async function assignManagedAlias(
state: AliasSyncState,
modelId: string,
fullModel: string,
displayPrefix: string
): Promise<string | null> {
const alias = resolveManagedModelAlias({
modelId,
fullModel,
providerDisplayAlias: displayPrefix,
existingAliases: state.workingAliases,
});
if (!alias) return null;
if (state.workingAliases[alias] !== fullModel) {
await setModelAlias(alias, fullModel);
state.workingAliases[alias] = fullModel;
if (!state.managedAliasNames.has(alias)) {
await markManagedModelAlias(alias);
state.managedAliasNames.add(alias);
}
}
return alias;
}
export async function syncManagedAvailableModelAliases(
providerId: string,
modelIds: string[],
@@ -103,63 +178,45 @@ export async function syncManagedAvailableModelAliases(
const storagePrefix = getProviderStoragePrefix(providerId);
const displayPrefix = await getProviderDisplayPrefix(providerId);
const existingAliasesRaw = await getModelAliases();
const workingAliases = Object.fromEntries(
Object.entries(existingAliasesRaw).filter((entry): entry is [string, string] => {
const [, value] = entry;
return typeof value === "string";
})
);
const state: AliasSyncState = {
workingAliases: Object.fromEntries(
Object.entries(existingAliasesRaw).filter((entry): entry is [string, string] => {
const [, value] = entry;
return typeof value === "string";
})
),
// Provenance marker (#11836): only alias names OmniRoute itself generated/adopted are
// eligible for the prune passes below — a hand-created custom alias that happens to
// already point at a model's full id must never be deleted just because that model
// transiently drops out of the sync's target set.
managedAliasNames: await getManagedModelAliasNames(),
removedAliases: [],
};
const targetModelIds = normalizeModelIds(modelIds);
const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`));
const removedAliases: string[] = [];
if (pruneMissing) {
for (const [alias, value] of Object.entries(workingAliases)) {
if (!value.startsWith(`${storagePrefix}/`)) continue;
if (targetFullModels.has(value)) continue;
await deleteModelAlias(alias);
delete workingAliases[alias];
removedAliases.push(alias);
}
await pruneMissingManagedAliases(state, storagePrefix, targetFullModels);
}
const assignedAliases: string[] = [];
for (const modelId of targetModelIds) {
const fullModel = `${storagePrefix}/${modelId}`;
if (getModelIsHidden(providerId, modelId)) {
const fullModel = `${storagePrefix}/${modelId}`;
for (const [alias, value] of Object.entries(workingAliases)) {
if (value !== fullModel) continue;
await deleteModelAlias(alias);
delete workingAliases[alias];
removedAliases.push(alias);
}
await pruneHiddenModelAliases(state, fullModel);
continue;
}
const fullModel = `${storagePrefix}/${modelId}`;
const alias = resolveManagedModelAlias({
modelId,
fullModel,
providerDisplayAlias: displayPrefix,
existingAliases: workingAliases,
});
if (!alias) continue;
if (workingAliases[alias] !== fullModel) {
await setModelAlias(alias, fullModel);
workingAliases[alias] = fullModel;
}
assignedAliases.push(alias);
const alias = await assignManagedAlias(state, modelId, fullModel, displayPrefix);
if (alias) assignedAliases.push(alias);
}
return {
assignedAliases,
removedAliases,
removedAliases: state.removedAliases,
storagePrefix,
};
}

View File

@@ -0,0 +1,50 @@
// Repro for GitHub issue #11836: a hand-created custom model alias gets silently
// deleted by syncManagedAvailableModelAliases({ pruneMissing: true }) because
// resolveManagedModelAlias() has no provenance marker distinguishing an
// auto-generated managed alias from a manually created one — once the custom
// alias happens to already point at a model's `<storagePrefix>/<modelId>` full
// value, the next sync "adopts" it as the managed alias for that model, and the
// prune pass deletes it the moment the model transiently drops out of the
// target set (provider rotation, connection swap, sync merge, etc.).
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-11836-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { getModelAliases, setModelAlias } = await import("../../src/lib/db/models.ts");
const { syncManagedAvailableModelAliases } =
await import("../../src/lib/providerModels/managedAvailableModels.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("#11836: a hand-created custom alias survives a sync where its model rotates out of the target set", async () => {
await setModelAlias("my-custom-alias", "openrouter/vendor/model-a");
const firstSync = await syncManagedAvailableModelAliases("openrouter", ["vendor/model-a"]);
const afterFirstSync = await getModelAliases();
assert.equal(
afterFirstSync["my-custom-alias"],
"openrouter/vendor/model-a",
"custom alias should still be intact right after being adopted"
);
assert.deepEqual(firstSync.removedAliases, []);
const secondSync = await syncManagedAvailableModelAliases("openrouter", []);
const afterSecondSync = await getModelAliases();
assert.equal(
afterSecondSync["my-custom-alias"],
"openrouter/vendor/model-a",
"custom alias must NOT be pruned just because it was adopted by a prior sync"
);
assert.deepEqual(secondSync.removedAliases, []);
});

View File

@@ -775,6 +775,10 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo
});
await localDb.setModelAlias("stale-model", "openrouter/stale-model");
// Mark it as OmniRoute-managed (simulating it was assigned by a prior sync) so the
// #11836 provenance check still prunes it below — an alias would only survive a prune
// pass if it were hand-created and never touched by the managed sync.
await modelsDb.markManagedModelAlias("stale-model");
await localDb.setModelAlias("router-v2", "other-provider/router-v2");
globalThis.fetch = async (url, init = {}) => {