mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-24 16:42:35 +03:00
Compare commits
2 Commits
fix/14081-
...
fix/11836-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a22ae440b4 | ||
|
|
1dde285c72 |
1
changelog.d/fixes/11836-custom-alias-pruned-by-sync.md
Normal file
1
changelog.d/fixes/11836-custom-alias-pruned-by-sync.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): stop syncManagedAvailableModelAliases from silently adopting and later pruning hand-created custom model aliases (#11836)
|
||||
@@ -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";
|
||||
|
||||
@@ -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', ?, ?)"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
50
tests/unit/11836-custom-alias-pruned-by-sync.test.ts
Normal file
50
tests/unit/11836-custom-alias-pruned-by-sync.test.ts
Normal 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, []);
|
||||
});
|
||||
@@ -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 = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user