fix: sync managed model aliases with visibility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
墨林ObsidianGrove
2026-05-14 18:49:05 +08:00
parent ebed308fbb
commit bcbc095798
4 changed files with 247 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;
@@ -2729,8 +2731,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 {
@@ -2756,7 +2760,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 {
@@ -2824,6 +2831,7 @@ export default function ProviderDetailPage() {
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
fallbackModels={compatibleFallbackModels}
description={description}
@@ -2883,6 +2891,7 @@ export default function ProviderDetailPage() {
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
copied={copied}
onCopy={copy}
@@ -4171,6 +4180,7 @@ function ModelVisibilityToolbar({
function PassthroughModelsSection({
providerAlias,
modelAliases,
availableModels = [],
customModels = [],
copied,
onCopy,
@@ -4196,24 +4206,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,
@@ -4312,7 +4376,7 @@ function PassthroughModelsSection({
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)}
onDeleteAlias={alias ? () => onDeleteAlias(alias) : undefined}
t={t}
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
@@ -4446,13 +4510,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>
);
@@ -4981,6 +5047,7 @@ function CompatibleModelsSection({
providerStorageAlias,
providerDisplayAlias,
modelAliases,
availableModels = [],
customModels = [],
fallbackModels = [],
description,
@@ -5026,35 +5093,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,8 @@ 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 } });
}
if (!modelId) {
@@ -369,7 +386,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 } });
} 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;