Merge PR #2250: fix: sync managed model aliases with visibility (thanks @InkshadeWoods)

This commit is contained in:
diegosouzapw
2026-05-14 13:42:14 -03:00
6 changed files with 412 additions and 53 deletions

View File

@@ -363,7 +363,7 @@ interface PassthroughModelRowProps {
isHidden?: boolean;
copied?: string;
onCopy: (text: string, key: string) => void;
onDeleteAlias: () => void;
onDeleteAlias?: () => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
@@ -381,6 +381,7 @@ interface PassthroughModelRowProps {
interface PassthroughModelsSectionProps {
providerAlias: string;
modelAliases: Record<string, string>;
availableModels?: CompatModelRow[];
customModels?: CompatModelRow[];
copied?: string;
onCopy: (text: string, key: string) => void;
@@ -421,6 +422,7 @@ interface CompatibleModelsSectionProps {
providerStorageAlias: string;
providerDisplayAlias: string;
modelAliases: Record<string, string>;
availableModels?: CompatModelRow[];
customModels?: CompatModelRow[];
fallbackModels?: CompatModelRow[];
allowImport: boolean;
@@ -2733,8 +2735,10 @@ export default function ProviderDetailPage() {
notify.error(detail || t("failedSaveCustomModel"));
return;
}
// Optimistic update: refresh model meta
await fetchProviderModelMeta().catch(() => {});
await Promise.all([
fetchProviderModelMeta().catch(() => {}),
fetchAliases().catch(() => {}),
]);
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
@@ -2760,7 +2764,10 @@ export default function ProviderDetailPage() {
notify.error(detail || t("failedSaveCustomModel"));
return;
}
await fetchProviderModelMeta().catch(() => {});
await Promise.all([
fetchProviderModelMeta().catch(() => {}),
fetchAliases().catch(() => {}),
]);
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
@@ -2828,6 +2835,7 @@ export default function ProviderDetailPage() {
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
fallbackModels={compatibleFallbackModels}
description={description}
@@ -2887,6 +2895,7 @@ export default function ProviderDetailPage() {
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
copied={copied}
onCopy={copy}
@@ -4175,6 +4184,7 @@ function ModelVisibilityToolbar({
function PassthroughModelsSection({
providerAlias,
modelAliases,
availableModels = [],
customModels = [],
copied,
onCopy,
@@ -4200,24 +4210,78 @@ function PassthroughModelsSection({
const [modelFilter, setModelFilter] = useState("");
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerAlias}/`)
const providerAliases = useMemo(
() =>
Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerAlias}/`)
),
[modelAliases, providerAlias]
);
const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => {
const fmStr = fullModel as string;
const allModels = useMemo(() => {
const prefix = `${providerAlias}/`;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
const customModel = customModelMap.get(modelId);
return {
modelId,
fullModel,
alias,
displayName: alias,
source: customModel ? customModel.source || "custom" : "alias",
isHidden: isModelHidden(modelId),
const aliasByModelId = new Map<string, string>();
const fullModelByModelId = new Map<string, string>();
const rows: Array<{
modelId: string;
fullModel: string;
alias: string | null;
displayName: string;
source: string;
isHidden: boolean;
}> = [];
const seenModelIds = new Set<string>();
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
aliasByModelId.set(modelId, alias as string);
fullModelByModelId.set(modelId, fmStr);
}
const addModel = (model: CompatModelRow, source: string) => {
if (!model?.id || seenModelIds.has(model.id)) return;
const fullModel = fullModelByModelId.get(model.id) || `${providerAlias}/${model.id}`;
rows.push({
modelId: model.id,
fullModel,
alias: aliasByModelId.get(model.id) || null,
displayName: model.name || model.id,
source,
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
};
});
for (const model of availableModels) {
addModel(model, "imported");
}
for (const model of customModels) {
addModel(
model,
normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom"
);
}
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
if (!modelId || seenModelIds.has(modelId)) continue;
const customModel = customModelMap.get(modelId);
rows.push({
modelId,
fullModel: fmStr,
alias: alias as string,
displayName: alias as string,
source: customModel ? customModel.source || "custom" : "alias",
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
}
return rows;
}, [availableModels, customModelMap, customModels, isModelHidden, providerAlias, providerAliases]);
const filteredModels = allModels.filter((model) =>
matchesModelCatalogQuery(modelFilter, {
modelId: model.modelId,
@@ -4316,7 +4380,7 @@ function PassthroughModelsSection({
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)}
onDeleteAlias={alias ? () => onDeleteAlias(alias) : undefined}
t={t}
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
@@ -4450,13 +4514,15 @@ function PassthroughModelRow({
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
<button
onClick={onDeleteAlias}
className="rounded p-1 text-red-500 hover:bg-red-50"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
{onDeleteAlias && (
<button
onClick={onDeleteAlias}
className="rounded p-1 text-red-500 hover:bg-red-50"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
)}
</div>
</div>
);
@@ -4985,6 +5051,7 @@ function CompatibleModelsSection({
providerStorageAlias,
providerDisplayAlias,
modelAliases,
availableModels = [],
customModels = [],
fallbackModels = [],
description,
@@ -5030,35 +5097,75 @@ function CompatibleModelsSection({
);
const allModels = useMemo(() => {
const rows = providerAliases.map(([alias, fullModel]: [string, any]) => {
const fmStr = fullModel as string;
const prefix = `${providerStorageAlias}/`;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
const customModel = customModelMap.get(modelId);
return {
modelId,
alias,
displayName: alias,
source: customModel ? customModel.source || "custom" : "alias",
isHidden: isModelHidden(modelId),
};
});
const prefix = `${providerStorageAlias}/`;
const aliasByModelId = new Map<string, string>();
const rows: Array<{
modelId: string;
alias: string | null;
displayName: string;
source: string;
isHidden: boolean;
}> = [];
const seenModelIds = new Set<string>();
const seenModelIds = new Set(rows.map((row) => row.modelId));
for (const model of fallbackModels) {
if (!model?.id || seenModelIds.has(model.id)) continue;
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
aliasByModelId.set(modelId, alias as string);
}
const addModel = (model: CompatModelRow, source: string) => {
if (!model?.id || seenModelIds.has(model.id)) return;
rows.push({
modelId: model.id,
alias: null,
alias: aliasByModelId.get(model.id) || null,
displayName: model.name || model.id,
source: "fallback",
source,
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
};
for (const model of availableModels) {
addModel(model, "imported");
}
for (const model of customModels) {
addModel(
model,
normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom"
);
}
for (const model of fallbackModels) {
addModel(model, "fallback");
}
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
if (!modelId || seenModelIds.has(modelId)) continue;
const customModel = customModelMap.get(modelId);
rows.push({
modelId,
alias: alias as string,
displayName: alias as string,
source: customModel ? customModel.source || "custom" : "alias",
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
}
return rows;
}, [customModelMap, fallbackModels, isModelHidden, providerAliases, providerStorageAlias]);
}, [
availableModels,
customModelMap,
customModels,
fallbackModels,
isModelHidden,
providerAliases,
providerStorageAlias,
]);
const filteredModels = allModels.filter((model) =>
matchesModelCatalogQuery(modelFilter, {
modelId: model.modelId,

View File

@@ -9,6 +9,11 @@ import {
mergeModelCompatOverride,
type ModelCompatPatch,
} from "@/lib/localDb";
import {
deleteManagedAvailableModelAliases,
deleteManagedAvailableModelAliasesForProvider,
syncManagedAvailableModelAliases,
} from "@/lib/providerModels/managedAvailableModels";
import {
AI_PROVIDERS,
isOpenAICompatibleProvider,
@@ -305,9 +310,20 @@ export async function PATCH(request) {
}
}
const aliasChanges =
body.isHidden === true
? { removed: await deleteManagedAvailableModelAliases(provider, modelIds), assigned: [] }
: {
removed: [],
assigned: (
await syncManagedAvailableModelAliases(provider, modelIds, { pruneMissing: false })
).assignedAliases,
};
return Response.json({
ok: true,
updated: modelIds.length,
aliasChanges,
models: await getCustomModels(provider),
modelCompatOverrides: getModelCompatOverrides(provider),
});
@@ -353,7 +369,11 @@ export async function DELETE(request) {
const all = searchParams.get("all");
if (all === "true") {
await replaceCustomModels(provider, [], { allowEmpty: true });
return Response.json({ cleared: true });
const removedAliases = await deleteManagedAvailableModelAliasesForProvider(provider);
return Response.json({
cleared: true,
aliasChanges: { removed: removedAliases, assigned: [] },
});
}
if (!modelId) {
@@ -369,7 +389,8 @@ export async function DELETE(request) {
}
const removed = await removeCustomModel(provider, modelId);
return Response.json({ removed });
const removedAliases = await deleteManagedAvailableModelAliases(provider, [modelId]);
return Response.json({ removed, aliasChanges: { removed: removedAliases, assigned: [] } });
} catch (error) {
console.error("Error removing provider model:", error);
return Response.json(

View File

@@ -1,6 +1,7 @@
import {
deleteModelAlias,
getModelAliases,
getModelIsHidden,
getProviderNodeById,
setModelAlias,
} from "@/lib/localDb";
@@ -34,11 +35,71 @@ async function getProviderDisplayPrefix(providerId: string): Promise<string> {
return typeof prefix === "string" && prefix.trim().length > 0 ? prefix.trim() : providerId;
}
function normalizeModelIds(modelIds: string[]): string[] {
return Array.from(
new Set(
modelIds.map((modelId) => (typeof modelId === "string" ? modelId.trim() : "")).filter(Boolean)
)
);
}
function getManagedFullModelSet(providerId: string, modelIds: string[]): Set<string> {
const storagePrefix = getProviderStoragePrefix(providerId);
return new Set(normalizeModelIds(modelIds).map((modelId) => `${storagePrefix}/${modelId}`));
}
export async function deleteManagedAvailableModelAliases(
providerId: string,
modelIds: string[]
): Promise<string[]> {
if (!usesManagedAvailableModels(providerId)) return [];
const targetFullModels = getManagedFullModelSet(providerId, modelIds);
if (targetFullModels.size === 0) return [];
const existingAliasesRaw = await getModelAliases();
const removedAliases: string[] = [];
for (const [alias, value] of Object.entries(existingAliasesRaw)) {
if (typeof value !== "string" || !targetFullModels.has(value)) continue;
await deleteModelAlias(alias);
removedAliases.push(alias);
}
return removedAliases;
}
export async function deleteManagedAvailableModelAliasesForProvider(
providerId: string
): Promise<string[]> {
if (!usesManagedAvailableModels(providerId)) return [];
const storagePrefix = getProviderStoragePrefix(providerId);
const existingAliasesRaw = await getModelAliases();
const removedAliases: string[] = [];
for (const [alias, value] of Object.entries(existingAliasesRaw)) {
if (typeof value !== "string" || !value.startsWith(`${storagePrefix}/`)) continue;
await deleteModelAlias(alias);
removedAliases.push(alias);
}
return removedAliases;
}
export async function syncManagedAvailableModelAliases(
providerId: string,
modelIds: string[],
{ pruneMissing = true }: { pruneMissing?: boolean } = {}
) {
if (!usesManagedAvailableModels(providerId)) {
return {
assignedAliases: [],
removedAliases: [],
storagePrefix: getProviderStoragePrefix(providerId),
};
}
const storagePrefix = getProviderStoragePrefix(providerId);
const displayPrefix = await getProviderDisplayPrefix(providerId);
const existingAliasesRaw = await getModelAliases();
@@ -49,11 +110,7 @@ export async function syncManagedAvailableModelAliases(
})
);
const targetModelIds = Array.from(
new Set(
modelIds.map((modelId) => (typeof modelId === "string" ? modelId.trim() : "")).filter(Boolean)
)
);
const targetModelIds = normalizeModelIds(modelIds);
const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`));
const removedAliases: string[] = [];
@@ -71,6 +128,17 @@ export async function syncManagedAvailableModelAliases(
const assignedAliases: string[] = [];
for (const modelId of targetModelIds) {
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);
}
continue;
}
const fullModel = `${storagePrefix}/${modelId}`;
const alias = resolveManagedModelAlias({
modelId,

View File

@@ -240,9 +240,10 @@ export async function importManagedModels({
let syncedAliases = 0;
if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) {
const aliasModelIds = mode === "sync" ? syncedAvailableModels : discoveredModels;
const aliasSync = await syncManagedAvailableModelAliases(
providerId,
discoveredModels.map((model) => model.id),
aliasModelIds.map((model) => model.id),
{ pruneMissing: mode === "sync" }
);
syncedAliases = aliasSync.assignedAliases.length;

View File

@@ -1,10 +1,39 @@
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-managed-available-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { compatibleProviderSupportsModelImport, getCompatibleFallbackModels } =
await import("../../src/lib/providers/managedAvailableModels.ts");
const {
deleteManagedAvailableModelAliases,
deleteManagedAvailableModelAliasesForProvider,
syncManagedAvailableModelAliases,
} = await import("../../src/lib/providerModels/managedAvailableModels.ts");
const { getModelsByProviderId } = await import("../../src/shared/constants/models.ts");
async function resetStorage() {
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(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("CC compatible fallback models mirror the OAuth Claude Code registry list", () => {
assert.deepEqual(
getCompatibleFallbackModels("anthropic-compatible-cc-demo"),
@@ -20,3 +49,70 @@ test("OpenRouter keeps imported fallback models as its managed list source", ()
const fallbackModels = [{ id: "openai/gpt-5" }, { id: "anthropic/claude-sonnet-4-6" }];
assert.deepEqual(getCompatibleFallbackModels("openrouter", fallbackModels), fallbackModels);
});
test("hidden managed models do not create aliases during sync", async () => {
modelsDb.mergeModelCompatOverride("openrouter", "hidden/model", { isHidden: true });
const result = await syncManagedAvailableModelAliases("openrouter", ["hidden/model"]);
const aliases = await localDb.getModelAliases();
assert.deepEqual(result.assignedAliases, []);
assert.deepEqual(aliases, {});
});
test("deleteManagedAvailableModelAliases removes only aliases matching target full models", async () => {
await localDb.setModelAlias("target", "openrouter/xiaomi/mimo-v2.5-pro");
await localDb.setModelAlias("same-provider-other-model", "openrouter/xiaomi/mimo-v2.5-max");
await localDb.setModelAlias("other-provider", "openai/xiaomi/mimo-v2.5-pro");
const removed = await deleteManagedAvailableModelAliases("openrouter", ["xiaomi/mimo-v2.5-pro"]);
const aliases = await localDb.getModelAliases();
assert.deepEqual(removed, ["target"]);
assert.equal(aliases.target, undefined);
assert.equal(aliases["same-provider-other-model"], "openrouter/xiaomi/mimo-v2.5-max");
assert.equal(aliases["other-provider"], "openai/xiaomi/mimo-v2.5-pro");
});
test("deleteManagedAvailableModelAliasesForProvider removes provider-scoped aliases only", async () => {
await localDb.setModelAlias("router-a", "openrouter/provider/model-a");
await localDb.setModelAlias("router-b", "openrouter/provider/model-b");
await localDb.setModelAlias("openai-a", "openai/provider/model-a");
await localDb.setModelAlias("bare", "model-a");
const removed = await deleteManagedAvailableModelAliasesForProvider("openrouter");
const aliases = await localDb.getModelAliases();
assert.deepEqual(new Set(removed), new Set(["router-a", "router-b"]));
assert.equal(aliases["router-a"], undefined);
assert.equal(aliases["router-b"], undefined);
assert.equal(aliases["openai-a"], "openai/provider/model-a");
assert.equal(aliases.bare, "model-a");
});
test("unhidden managed models receive a fresh alias when sync reruns", async () => {
modelsDb.mergeModelCompatOverride("openrouter", "vendor/model", { isHidden: true });
await syncManagedAvailableModelAliases("openrouter", ["vendor/model"]);
modelsDb.mergeModelCompatOverride("openrouter", "vendor/model", { isHidden: false });
const result = await syncManagedAvailableModelAliases("openrouter", ["vendor/model"]);
const aliases = await localDb.getModelAliases();
assert.deepEqual(result.assignedAliases, ["model"]);
assert.equal(aliases.model, "openrouter/vendor/model");
});
test("managed alias helpers are no-ops for non-managed providers", async () => {
await localDb.setModelAlias("manual", "claude/custom-model");
const removedOne = await deleteManagedAvailableModelAliases("claude", ["custom-model"]);
const removedAll = await deleteManagedAvailableModelAliasesForProvider("claude");
const syncResult = await syncManagedAvailableModelAliases("claude", ["custom-model"]);
const aliases = await localDb.getModelAliases();
assert.deepEqual(removedOne, []);
assert.deepEqual(removedAll, []);
assert.deepEqual(syncResult.assignedAliases, []);
assert.deepEqual(syncResult.removedAliases, []);
assert.equal(aliases.manual, "claude/custom-model");
});

View File

@@ -0,0 +1,66 @@
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-managed-model-import-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { importManagedModels } = await import("../../src/lib/providerModels/managedModelImport.ts");
async function resetStorage() {
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(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("sync mode builds aliases from provider-level synced available models", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", "conn-a", [
{ id: "shared/model-a", name: "Model A", source: "imported" },
]);
await importManagedModels({
providerId: "openrouter",
connectionId: "conn-b",
mode: "sync",
fetchedModels: [{ id: "shared/model-b", name: "Model B" }],
});
const aliases = await localDb.getModelAliases();
assert.equal(aliases["model-a"], "openrouter/shared/model-a");
assert.equal(aliases["model-b"], "openrouter/shared/model-b");
});
test("merge mode builds aliases from discovered models without pruning missing provider aliases", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", "conn-a", [
{ id: "shared/model-a", name: "Model A", source: "imported" },
]);
await localDb.setModelAlias("existing", "openrouter/shared/existing");
await importManagedModels({
providerId: "openrouter",
connectionId: "conn-b",
mode: "merge",
fetchedModels: [{ id: "shared/model-b", name: "Model B" }],
});
const aliases = await localDb.getModelAliases();
assert.equal(aliases.existing, "openrouter/shared/existing");
assert.equal(aliases["model-a"], undefined);
assert.equal(aliases["model-b"], "openrouter/shared/model-b");
});