mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
* fix(models): preserve eye-hidden models across auto-sync (#3782) * chore(quality): re-baseline file-size for models.ts growth (#3782)
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import {
|
|
getCustomModels,
|
|
getModelIsHidden,
|
|
getSyncedAvailableModelsForConnection,
|
|
mergeModelCompatOverride,
|
|
replaceCustomModels,
|
|
replaceSyncedAvailableModelsForConnection,
|
|
pruneStaleSyncedAvailableModelsForProvider,
|
|
setMitmAliasAll,
|
|
getSyncedAvailableModels,
|
|
type ModelCompatPatch,
|
|
type SyncedAvailableModel,
|
|
} from "@/lib/db/models";
|
|
import { getProviderConnections } from "@/lib/db/providers";
|
|
import {
|
|
syncManagedAvailableModelAliases,
|
|
usesManagedAvailableModels,
|
|
} from "@/lib/providerModels/managedAvailableModels";
|
|
import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery";
|
|
import {
|
|
ANTIGRAVITY_MODEL_ALIASES,
|
|
ANTIGRAVITY_REVERSE_MODEL_ALIASES,
|
|
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
export type ManagedModelImportMode = "merge" | "sync";
|
|
|
|
export type ManagedImportedModel = {
|
|
id: string;
|
|
name: string;
|
|
source: "imported";
|
|
apiFormat: string;
|
|
supportedEndpoints?: string[];
|
|
inputTokenLimit?: number;
|
|
outputTokenLimit?: number;
|
|
description?: string;
|
|
supportsThinking?: boolean;
|
|
};
|
|
|
|
function toNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function normalizeManagedSource(source: unknown): string {
|
|
const normalized = toNonEmptyString(source)?.toLowerCase();
|
|
if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") {
|
|
return "imported";
|
|
}
|
|
return normalized || "manual";
|
|
}
|
|
|
|
function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] {
|
|
const discovered = normalizeDiscoveredModels(fetchedModels);
|
|
|
|
return discovered.map((model) => ({
|
|
id: model.id,
|
|
name: model.name || model.id,
|
|
source: "imported",
|
|
apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions",
|
|
...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0
|
|
? { supportedEndpoints: model.supportedEndpoints }
|
|
: {}),
|
|
...(typeof model.inputTokenLimit === "number"
|
|
? { inputTokenLimit: model.inputTokenLimit }
|
|
: {}),
|
|
...(typeof model.outputTokenLimit === "number"
|
|
? { outputTokenLimit: model.outputTokenLimit }
|
|
: {}),
|
|
...(typeof model.description === "string" ? { description: model.description } : {}),
|
|
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
|
|
}));
|
|
}
|
|
|
|
function isImportedSource(source: unknown): boolean {
|
|
return normalizeManagedSource(source) === "imported";
|
|
}
|
|
|
|
function getModelId(model: JsonRecord): string | null {
|
|
return toNonEmptyString(model.id);
|
|
}
|
|
|
|
function summarizeImportedChanges(
|
|
previousModels: JsonRecord[],
|
|
nextModels: JsonRecord[],
|
|
importedIds: Set<string>
|
|
) {
|
|
let added = 0;
|
|
let updated = 0;
|
|
let unchanged = 0;
|
|
|
|
const previousMap = new Map(previousModels.map((model) => [String(model.id), model]));
|
|
const nextMap = new Map(nextModels.map((model) => [String(model.id), model]));
|
|
|
|
const toComparable = (model: JsonRecord | undefined) => {
|
|
if (!model) return null;
|
|
const id = toNonEmptyString(model.id) || "";
|
|
const supportedEndpoints = Array.isArray(model.supportedEndpoints)
|
|
? Array.from(
|
|
new Set(
|
|
model.supportedEndpoints
|
|
.map((endpoint) => toNonEmptyString(endpoint))
|
|
.filter((endpoint): endpoint is string => Boolean(endpoint))
|
|
)
|
|
).sort()
|
|
: ["chat"];
|
|
return {
|
|
id,
|
|
name: toNonEmptyString(model.name) || id,
|
|
source: normalizeManagedSource(model.source),
|
|
apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions",
|
|
supportedEndpoints,
|
|
...(typeof model.inputTokenLimit === "number"
|
|
? { inputTokenLimit: model.inputTokenLimit }
|
|
: {}),
|
|
...(typeof model.outputTokenLimit === "number"
|
|
? { outputTokenLimit: model.outputTokenLimit }
|
|
: {}),
|
|
...(typeof model.description === "string" ? { description: model.description } : {}),
|
|
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
|
|
};
|
|
};
|
|
|
|
for (const id of importedIds) {
|
|
const previous = previousMap.get(id);
|
|
const next = nextMap.get(id);
|
|
if (!next) continue;
|
|
if (!previous) {
|
|
added += 1;
|
|
continue;
|
|
}
|
|
if (JSON.stringify(toComparable(previous)) === JSON.stringify(toComparable(next))) {
|
|
unchanged += 1;
|
|
continue;
|
|
}
|
|
updated += 1;
|
|
}
|
|
|
|
return {
|
|
added,
|
|
updated,
|
|
unchanged,
|
|
total: added + updated,
|
|
};
|
|
}
|
|
|
|
function collectAddedImportedModels(
|
|
previousModels: JsonRecord[],
|
|
importedModels: ManagedImportedModel[]
|
|
): ManagedImportedModel[] {
|
|
const previousIds = new Set(
|
|
previousModels.map((model) => toNonEmptyString(model.id)).filter(Boolean)
|
|
);
|
|
return importedModels.filter((model) => !previousIds.has(model.id));
|
|
}
|
|
|
|
function getCompatPatchFromCustomModel(model: JsonRecord): ModelCompatPatch | null {
|
|
const patch: ModelCompatPatch = {};
|
|
|
|
if (typeof model.normalizeToolCallId === "boolean") {
|
|
patch.normalizeToolCallId = model.normalizeToolCallId;
|
|
}
|
|
if (typeof model.preserveOpenAIDeveloperRole === "boolean") {
|
|
patch.preserveOpenAIDeveloperRole = model.preserveOpenAIDeveloperRole;
|
|
}
|
|
if (typeof model.isHidden === "boolean") {
|
|
patch.isHidden = model.isHidden;
|
|
}
|
|
if (model.compatByProtocol && typeof model.compatByProtocol === "object") {
|
|
patch.compatByProtocol = model.compatByProtocol as ModelCompatPatch["compatByProtocol"];
|
|
}
|
|
if (model.upstreamHeaders && typeof model.upstreamHeaders === "object") {
|
|
patch.upstreamHeaders = model.upstreamHeaders as Record<string, string>;
|
|
}
|
|
|
|
return Object.keys(patch).length > 0 ? patch : null;
|
|
}
|
|
|
|
function preserveRemovedCustomModelCompat(providerId: string, removedModels: JsonRecord[]) {
|
|
for (const model of removedModels) {
|
|
const modelId = getModelId(model);
|
|
if (!modelId) continue;
|
|
const patch = getCompatPatchFromCustomModel(model);
|
|
if (!patch) continue;
|
|
mergeModelCompatOverride(providerId, modelId, patch);
|
|
}
|
|
}
|
|
|
|
export async function importManagedModels({
|
|
providerId,
|
|
connectionId,
|
|
fetchedModels,
|
|
mode,
|
|
previousSyncedAvailableModels: previousSyncedAvailableModelsInput,
|
|
}: {
|
|
providerId: string;
|
|
connectionId: string;
|
|
fetchedModels: unknown;
|
|
mode: ManagedModelImportMode;
|
|
previousSyncedAvailableModels?: SyncedAvailableModel[];
|
|
}) {
|
|
const previousModels = (await getCustomModels(providerId)) as JsonRecord[];
|
|
const previousSyncedAvailableModels =
|
|
previousSyncedAvailableModelsInput ??
|
|
(await getSyncedAvailableModelsForConnection(providerId, connectionId));
|
|
const discoveredModels = normalizeDiscoveredModels(fetchedModels);
|
|
const candidateImportedModels = normalizeImportedModels(fetchedModels);
|
|
const importedIds = new Set(candidateImportedModels.map((model) => model.id));
|
|
const discoveredIds = new Set(discoveredModels.map((model) => model.id));
|
|
|
|
const nextModelsMap = new Map<string, JsonRecord>();
|
|
const removedCustomModels: JsonRecord[] = [];
|
|
|
|
for (const model of previousModels) {
|
|
const modelId = getModelId(model);
|
|
if (!modelId) continue;
|
|
if (isImportedSource(model.source) || discoveredIds.has(modelId)) {
|
|
removedCustomModels.push(model);
|
|
continue;
|
|
}
|
|
nextModelsMap.set(modelId, model);
|
|
}
|
|
|
|
const persistedModels = (await replaceCustomModels(
|
|
providerId,
|
|
Array.from(nextModelsMap.values()) as Array<{
|
|
id: string;
|
|
name?: string;
|
|
source?: string;
|
|
apiFormat?: string;
|
|
supportedEndpoints?: string[];
|
|
inputTokenLimit?: number;
|
|
outputTokenLimit?: number;
|
|
description?: string;
|
|
supportsThinking?: boolean;
|
|
}>,
|
|
{ allowEmpty: true }
|
|
)) as JsonRecord[];
|
|
preserveRemovedCustomModelCompat(providerId, removedCustomModels);
|
|
|
|
let syncedAvailableModels: SyncedAvailableModel[] = previousSyncedAvailableModels;
|
|
if (discoveredModels.length > 0) {
|
|
syncedAvailableModels = await replaceSyncedAvailableModelsForConnection(
|
|
providerId,
|
|
connectionId,
|
|
discoveredModels
|
|
);
|
|
}
|
|
|
|
// Prune stale/inactive connection caches for this provider
|
|
const activeConnections = await getProviderConnections({ provider: providerId, isActive: true });
|
|
const allowedConnectionIds = Array.from(
|
|
new Set([...activeConnections.map((c) => String(c.id)), connectionId])
|
|
);
|
|
await pruneStaleSyncedAvailableModelsForProvider(providerId, allowedConnectionIds);
|
|
|
|
// If this is the "antigravity" provider, dynamically regenerate and persist the mitmAlias mapping for "antigravity"
|
|
if (providerId === "antigravity") {
|
|
const allAntigravityModels = await getSyncedAvailableModels("antigravity");
|
|
const syncedIds = new Set(allAntigravityModels.map((m) => m.id).filter(Boolean) as string[]);
|
|
|
|
// Transitive/recursive resolution helper
|
|
const resolveTransitively = (name: string): string => {
|
|
let current = name;
|
|
const visited = new Set<string>();
|
|
while (current && !visited.has(current)) {
|
|
if (syncedIds.has(current)) {
|
|
return current;
|
|
}
|
|
visited.add(current);
|
|
if (ANTIGRAVITY_MODEL_ALIASES && (ANTIGRAVITY_MODEL_ALIASES as any)[current]) {
|
|
current = (ANTIGRAVITY_MODEL_ALIASES as any)[current];
|
|
continue;
|
|
}
|
|
if (
|
|
ANTIGRAVITY_REVERSE_MODEL_ALIASES &&
|
|
(ANTIGRAVITY_REVERSE_MODEL_ALIASES as any)[current]
|
|
) {
|
|
current = (ANTIGRAVITY_REVERSE_MODEL_ALIASES as any)[current];
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return current;
|
|
};
|
|
|
|
// Gather all candidate alias names to check
|
|
const candidates = new Set<string>();
|
|
for (const id of syncedIds) {
|
|
candidates.add(id);
|
|
}
|
|
for (const [k, v] of Object.entries(ANTIGRAVITY_MODEL_ALIASES || {})) {
|
|
candidates.add(k);
|
|
candidates.add(v);
|
|
}
|
|
for (const [k, v] of Object.entries(ANTIGRAVITY_REVERSE_MODEL_ALIASES || {})) {
|
|
candidates.add(k);
|
|
candidates.add(v);
|
|
}
|
|
|
|
// Build the dynamic mapping dictionary
|
|
const mappings: Record<string, string> = {};
|
|
for (const alias of candidates) {
|
|
const resolvedId = resolveTransitively(alias);
|
|
if (syncedIds.has(resolvedId)) {
|
|
mappings[alias] = `antigravity/${resolvedId}`;
|
|
}
|
|
}
|
|
|
|
await setMitmAliasAll("antigravity", mappings);
|
|
}
|
|
|
|
let syncedAliases = 0;
|
|
if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) {
|
|
const aliasModelIds = mode === "sync" ? syncedAvailableModels : discoveredModels;
|
|
// #3782: eye-hidden models now survive in `syncedAvailableModels` (so they stay
|
|
// listed-but-hidden), but they must NOT be re-assigned a routable managed alias
|
|
// — otherwise auto-sync silently re-enables routing for a model the operator hid.
|
|
// Exclude `isHidden` ids from the alias assignment. `pruneMissing` (sync mode)
|
|
// then drops any stale alias an eye-hidden model previously held.
|
|
const aliasSync = await syncManagedAvailableModelAliases(
|
|
providerId,
|
|
aliasModelIds.map((model) => model.id).filter((id) => !getModelIsHidden(providerId, id)),
|
|
{ pruneMissing: mode === "sync" }
|
|
);
|
|
syncedAliases = aliasSync.assignedAliases.length;
|
|
}
|
|
|
|
const importedChanges = summarizeImportedChanges(
|
|
previousSyncedAvailableModels as JsonRecord[],
|
|
discoveredModels as JsonRecord[],
|
|
importedIds
|
|
);
|
|
const importedModels = collectAddedImportedModels(
|
|
previousSyncedAvailableModels as JsonRecord[],
|
|
candidateImportedModels
|
|
);
|
|
|
|
return {
|
|
previousModels,
|
|
previousSyncedAvailableModels,
|
|
persistedModels,
|
|
importedModels,
|
|
discoveredModels,
|
|
syncedAvailableModels,
|
|
syncedAliases,
|
|
importedChanges,
|
|
};
|
|
}
|