Files
OmniRoute/src/lib/providerModels/managedModelImport.ts
2026-08-29 08:10:37 -03:00

448 lines
17 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,
isDiscoverableAntigravityModelId,
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { isDiscoverableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts";
import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
type JsonRecord = Record<string, unknown>;
export type ManagedModelImportMode = "merge" | "sync";
export type ManagedImportedModel = {
id: string;
name: string;
source: "imported";
apiFormat: string;
targetFormat?: string;
upstreamProtocol?: string;
supportedEndpoints?: string[];
supportedThinkingEfforts?: string[];
defaultThinkingEffort?: string;
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
alwaysThinking?: boolean;
supportsTools?: boolean;
supportsVideo?: 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 copyImportedModelMetadata(target: ManagedImportedModel, model: JsonRecord): void {
if (toNonEmptyString(model.targetFormat)) target.targetFormat = model.targetFormat as string;
if (toNonEmptyString(model.upstreamProtocol)) {
target.upstreamProtocol = model.upstreamProtocol as string;
}
if (Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0) {
target.supportedEndpoints = model.supportedEndpoints as string[];
}
if (Array.isArray(model.supportedThinkingEfforts) && model.supportedThinkingEfforts.length > 0) {
target.supportedThinkingEfforts = model.supportedThinkingEfforts as string[];
}
if (toNonEmptyString(model.defaultThinkingEffort)) {
target.defaultThinkingEffort = model.defaultThinkingEffort as string;
}
if (typeof model.inputTokenLimit === "number") target.inputTokenLimit = model.inputTokenLimit;
if (typeof model.outputTokenLimit === "number") {
target.outputTokenLimit = model.outputTokenLimit;
}
if (typeof model.description === "string") target.description = model.description;
if (typeof model.supportsThinking === "boolean") {
target.supportsThinking = model.supportsThinking;
}
if (model.alwaysThinking === true) target.alwaysThinking = true;
if (typeof model.supportsTools === "boolean") target.supportsTools = model.supportsTools;
if (typeof model.supportsVideo === "boolean") target.supportsVideo = model.supportsVideo;
}
function normalizeImportedModel(model: JsonRecord): ManagedImportedModel {
const normalized: ManagedImportedModel = {
id: model.id as string,
name: (model.name as string) || (model.id as string),
source: "imported",
apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions",
};
copyImportedModelMetadata(normalized, model);
return normalized;
}
function normalizeImportedModels(
discoveredModels: readonly SyncedAvailableModel[]
): ManagedImportedModel[] {
return discoveredModels.map((model) => normalizeImportedModel(model as JsonRecord));
}
function isImportedSource(source: unknown): boolean {
return normalizeManagedSource(source) === "imported";
}
function getModelId(model: JsonRecord): string | null {
return toNonEmptyString(model.id);
}
function copyComparableModelMetadata(target: JsonRecord, model: JsonRecord): void {
if (toNonEmptyString(model.targetFormat)) target.targetFormat = model.targetFormat;
if (toNonEmptyString(model.upstreamProtocol)) target.upstreamProtocol = model.upstreamProtocol;
if (Array.isArray(model.supportedThinkingEfforts)) {
target.supportedThinkingEfforts = model.supportedThinkingEfforts;
}
if (toNonEmptyString(model.defaultThinkingEffort)) {
target.defaultThinkingEffort = model.defaultThinkingEffort;
}
if (typeof model.inputTokenLimit === "number") target.inputTokenLimit = model.inputTokenLimit;
if (typeof model.outputTokenLimit === "number") {
target.outputTokenLimit = model.outputTokenLimit;
}
if (typeof model.description === "string") target.description = model.description;
if (typeof model.supportsThinking === "boolean") {
target.supportsThinking = model.supportsThinking;
}
if (model.alwaysThinking === true) target.alwaysThinking = true;
if (typeof model.supportsTools === "boolean") target.supportsTools = model.supportsTools;
if (typeof model.supportsVideo === "boolean") target.supportsVideo = model.supportsVideo;
}
function toComparableManagedModel(model: JsonRecord | undefined): JsonRecord | null {
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"];
const comparable: JsonRecord = {
id,
name: toNonEmptyString(model.name) || id,
source: normalizeManagedSource(model.source),
apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions",
supportedEndpoints,
};
copyComparableModelMetadata(comparable, model);
return comparable;
}
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]));
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(toComparableManagedModel(previous)) ===
JSON.stringify(toComparableManagedModel(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 normalizedDiscoveredModels = normalizeDiscoveredModels(fetchedModels, providerId);
// Gemini 3.5 Flash elimination (ddf1bb760, carried from #11259): antigravity/
// agy discovery is restricted to each family's discoverable ids BEFORE any
// chat-selection filtering.
const providerFilteredModels =
providerId === "antigravity"
? normalizedDiscoveredModels.filter((model) => isDiscoverableAntigravityModelId(model.id))
: providerId === "agy"
? normalizedDiscoveredModels.filter((model) => isDiscoverableAgyModelId(model.id))
: normalizedDiscoveredModels;
// #11088 (option 1): self-hosted providers keep their non-chat models — chat
// filtering happens at read time (resolveLocalSyncedEndpointRoute). Every other
// provider keeps the import-time chat filter: the read-time path is gated on
// isSelfHostedChatProvider, so dropping it globally leaked image/video models
// into OpenAI chat selections (#11271).
const selectableModels = filterSelectableModels(providerId, providerFilteredModels);
const discoveredModels = isSelfHostedChatProvider(providerId)
? selectableModels
: filterChatSelectableModels(providerId, selectableModels);
const candidateImportedModels = normalizeImportedModels(discoveredModels);
const importedIds = new Set(candidateImportedModels.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;
// A manually configured row is the provider's user-owned metadata overlay.
// It may share an id with an upstream model, in which case list and runtime
// resolution merge it over the synced base. Only replace prior import rows.
if (isImportedSource(model.source)) {
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;
targetFormat?: string;
upstreamProtocol?: string;
supportedEndpoints?: string[];
supportedThinkingEfforts?: string[];
defaultThinkingEffort?: string;
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
alwaysThinking?: boolean;
supportsTools?: boolean;
supportsVideo?: 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}`;
}
}
// #11824/#11651: `syncedIds` is a UNION across every connection of this provider
// (getSyncedAvailableModels), so an identity mapping derived above can route a
// display id to the literal tier-suffixed upstream id (e.g. "gemini-3.7-flash-high")
// just because ONE connected account's own discovery happens to list it directly.
// Google's Cloud Code Assist backend only allows those tier-suffixed ids on
// accounts/projects it specifically provisioned for them — every other account can
// only call the shared "-tiered" endpoint id. Since this mitmAlias table is global
// (not scoped per connection) and consulted first/authoritatively by
// cleanModelName(), letting one account's discovery win here silently 404s every
// sibling account. Force every display id that the static ANTIGRAVITY_MODEL_ALIASES
// table already knows only has a safe "-tiered" target to always resolve there,
// regardless of what any single connection's discovery reported.
for (const [displayId, safeTarget] of Object.entries(ANTIGRAVITY_MODEL_ALIASES)) {
if (safeTarget === "gemini-3.7-flash-tiered") {
mappings[displayId] = `antigravity/${safeTarget}`;
}
}
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,
};
}