/** * db/models.ts — Custom models, synced available models, and model-flag queries. * Compat overrides, model aliases, and MITM aliases have moved to sub-modules under * models/; this file re-exports their public APIs for backward compatibility. */ import { isRetiredGitHubCopilotModelId } from "@omniroute/open-sse/config/providers/registry/github/retiredModels.ts"; import { getDbInstance } from "./core"; import { getProviderConnectionsCount, touchConnectionSyncedModelsAt } from "./providers"; import { type JsonRecord, getKeyValue } from "./models/shared"; import { normalizeSyncedAvailableModels, type SyncedAvailableModel, type SyncedAvailableModelInput, } from "./models/synced"; import { finishSyncedAvailableModelsWrite, persistCanonicalSyncedAvailableModels, } from "./models/syncedAvailableModelPersistence"; import { finishModelCatalogWriteWithBackup } from "./models/modelCatalogWriteSignals"; import { readCompatList, writeCompatList, deepMergeCompatByProtocol, compatByProtocolHasEntries, isCompatProtocolKey, sanitizeUpstreamHeadersMap, removeModelCompatOverride, mergeModelCompatOverride, isOverrideHiddenForModality, type CompatByProtocolMap, type ModelCompatProtocolKey, type ModelCompatOverride, type ModelCompatPerProtocol, } from "./models/compat"; export { sanitizeUpstreamHeadersMap, getModelCompatOverrides, mergeModelCompatOverride, removeModelCompatOverride, MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey, type ModelCompatPerProtocol, type ModelCompatOverride, type ModelCompatPatch, } from "./models/compat"; export { getModelAliases, setModelAlias, deleteModelAlias, deleteModelAliasesForProvider, } from "./models/aliases"; export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias"; export type { SyncedAvailableModel } from "./models/synced"; export { getCustomModelVisionOverride, listCustomModelVisionOverrides, type CustomModelVisionOverrideMap, type CustomModelVisionDatabase, type CustomModelVisionOverrideReadOptions, } from "./models/customVisionOverride"; // ──────────────── Custom Models ──────────────── export async function getCustomModels(providerId?: string) { const db = getDbInstance(); if (providerId) { const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); const value = getKeyValue(row).value; return value ? JSON.parse(value) : []; } const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") .all(); const result: Record = {}; for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; result[key] = JSON.parse(value); } return result; } export async function getAllCustomModels() { const db = getDbInstance(); const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") .all(); const result: Record = {}; for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; result[key] = JSON.parse(value); } return result; } export async function addCustomModel( providerId: string, modelId: string, modelName?: string, source = "manual", apiFormat: | "chat-completions" | "responses" | "embeddings" | "rerank" | "audio-transcriptions" | "audio-speech" | "images-generations" | "video" = "chat-completions", supportedEndpoints: string[] = ["chat"], // #2905: optional per-model wire format override (e.g. "claude" for an // opencode-go custom model). When unset, routing falls back to the provider // default format. targetFormat?: string, // #1294: optional per-model token limits supplied from the "add custom model" // form. Persisted under the same keys the /v1/models catalog reads back. tokenLimits: { inputTokenLimit?: number; outputTokenLimit?: number } = {}, // #1904: optional manual vision-capability override for the "add custom model" // form — read back by getCustomVisionCapabilityFields() in the /v1/models catalog. supportsVision?: boolean, // #9820: optional video-generation job preset (e.g. "agnes-video-job") for // custom OpenAI-compatible video models. Persisted on the model row; the // /v1/videos/generations handler reads it back to pick the job/poll path. generationConfig?: { preset: string }, isFree?: boolean ) { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); const value = getKeyValue(row).value; const models = value ? JSON.parse(value) : []; const exists = models.find((m: JsonRecord) => m.id === modelId); if (exists) return exists; const model = { id: modelId, name: modelName || modelId, source, apiFormat, supportedEndpoints, ...(targetFormat ? { targetFormat } : {}), ...(tokenLimits.inputTokenLimit != null ? { inputTokenLimit: tokenLimits.inputTokenLimit } : {}), ...(tokenLimits.outputTokenLimit != null ? { outputTokenLimit: tokenLimits.outputTokenLimit } : {}), ...(typeof supportsVision === "boolean" ? { supportsVision } : {}), ...(typeof isFree === "boolean" ? { isFree } : {}), ...(generationConfig && generationConfig.preset ? { generationConfig } : {}), }; models.push(model); db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" ).run(providerId, JSON.stringify(models)); finishModelCatalogWriteWithBackup(); return model; } /** * Replace the entire custom models list for a provider. * Preserves per-model compatibility overrides for models that still exist. */ export async function replaceCustomModels( providerId: string, models: Array<{ id: string; name?: string; source?: string; apiFormat?: string; supportedEndpoints?: string[]; inputTokenLimit?: number; outputTokenLimit?: number; description?: string; supportsThinking?: boolean; targetFormat?: string; generationConfig?: { preset?: string }; isFree?: boolean; }>, { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { // Guard: skip destructive clear when the caller hasn't explicitly opted in. // This prevents callers from wiping manually added models when the // upstream /models endpoint fails, times out, or returns an empty list. if (models.length === 0 && !allowEmpty) { const existing = await getCustomModels(providerId); return Array.isArray(existing) ? existing : []; } const db = getDbInstance(); const existing = await getCustomModels(providerId); const existingMap = new Map(); if (Array.isArray(existing)) { for (const m of existing) { if (m && typeof m === "object" && m.id) existingMap.set(m.id, m); } } // Merge: keep existing per-model compat flags if model still exists const merged = models.map((m) => { const prev = existingMap.get(m.id); // `customModels` is also the user-owned metadata overlay for a same-id // synced model. Preserve every defined field instead of maintaining a // lossy allowlist here (for example supportsVision and future capabilities). const definedModelMetadata = Object.fromEntries( Object.entries(m).filter(([, value]) => value !== undefined) ); return { ...(prev || {}), ...definedModelMetadata, id: m.id, name: m.name || m.id, source: m.source || "auto-sync", apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions", supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"], // #2905: preserve a per-model targetFormat override (new value wins, else prev). ...(m.targetFormat ? { targetFormat: m.targetFormat } : (prev as any)?.targetFormat ? { targetFormat: (prev as any).targetFormat } : {}), // #9820: preserve a video job preset across auto-sync (new value wins, // else prev — so sync overwrites don't drop a job-config model). ...(m.generationConfig?.preset ? { generationConfig: { preset: m.generationConfig.preset } } : (prev as any)?.generationConfig?.preset ? { generationConfig: { preset: (prev as any).generationConfig.preset } } : {}), // Preserve metadata from provider API (or previous sync) ...(m.inputTokenLimit != null ? { inputTokenLimit: m.inputTokenLimit } : (prev as any)?.inputTokenLimit != null ? { inputTokenLimit: (prev as any).inputTokenLimit } : {}), ...(m.outputTokenLimit != null ? { outputTokenLimit: m.outputTokenLimit } : (prev as any)?.outputTokenLimit != null ? { outputTokenLimit: (prev as any).outputTokenLimit } : {}), ...(m.description != null ? { description: m.description } : (prev as any)?.description != null ? { description: (prev as any).description } : {}), ...(m.supportsThinking != null ? { supportsThinking: m.supportsThinking } : (prev as any)?.supportsThinking != null ? { supportsThinking: (prev as any).supportsThinking } : {}), // Preserve existing compat flags ...(prev && (prev as any).normalizeToolCallId !== undefined ? { normalizeToolCallId: (prev as any).normalizeToolCallId } : {}), ...(prev && (prev as any).preserveOpenAIDeveloperRole !== undefined ? { preserveOpenAIDeveloperRole: (prev as any).preserveOpenAIDeveloperRole } : {}), ...(prev && (prev as any).compatByProtocol ? { compatByProtocol: (prev as any).compatByProtocol } : {}), ...(prev && (prev as any).upstreamHeaders ? { upstreamHeaders: (prev as any).upstreamHeaders } : {}), }; }); if (merged.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( providerId ); } else { db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" ).run(providerId, JSON.stringify(merged)); } finishModelCatalogWriteWithBackup(); return merged; } /** * Remove provider-level models created by discovery/import while preserving * models the user added manually. Returns the removed model IDs. */ export async function deleteImportedCustomModels(providerId: string): Promise { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); const value = getKeyValue(row).value; if (!value) return []; const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return []; const removed: JsonRecord[] = []; const retained = parsed.filter((model: JsonRecord) => { const source = typeof model?.source === "string" ? model.source.trim().toLowerCase() : "manual"; const imported = source === "imported" || source === "api-sync" || source === "auto-sync"; if (imported) removed.push(model); return !imported; }); if (removed.length === 0) return []; if (retained.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( providerId ); } else { db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(retained), providerId ); } const removedIds = removed.flatMap((model) => typeof model.id === "string" && model.id ? [model.id] : [] ); for (const modelId of removedIds) removeModelCompatOverride(providerId, modelId); finishModelCatalogWriteWithBackup(); return removedIds; } export async function removeCustomModel(providerId: string, modelId: string) { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); if (!row) return false; const value = getKeyValue(row).value; if (!value) return false; const models = JSON.parse(value); const before = models.length; const filtered = models.filter((m: JsonRecord) => m.id !== modelId); if (filtered.length === before) return false; if (filtered.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( providerId ); } else { db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(filtered), providerId ); } removeModelCompatOverride(providerId, modelId); finishModelCatalogWriteWithBackup(); return true; } // ──────────────── Synced Available Models ──────────────── // Storage: namespace = 'syncedAvailableModels', key = ':' // Each connection stores its own model list. Reads union across all connections // for a provider. Deleting a connection removes only its models. /** * Get synced available models for a specific provider connection. */ export async function getSyncedAvailableModelsForConnection( providerId: string, connectionId: string ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?") .get(key); const value = getKeyValue(row).value; if (!value) return []; try { const models = JSON.parse(value); return normalizeSyncedAvailableModels(models, providerId); } catch { return []; } } /** * Get all synced available models for a provider, unioned across all connections. */ export async function getSyncedAvailableModels( providerId: string ): Promise { const db = getDbInstance(); const rows = db .prepare( "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" ) .all(`${providerId}:%`); const map = new Map(); for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId); for (const m of models) { if (m.id) map.set(m.id, m); } } return Array.from(map.values()); } export const SYNCED_AVAILABLE_MODELS_MALFORMED = Symbol("syncedAvailableModelsMalformed"); export type SyncedAvailableModelsByConnection = Record & { [SYNCED_AVAILABLE_MODELS_MALFORMED]?: true; }; /** * Get synced available models for a provider grouped by connection id. * A non-enumerable symbol marks malformed persisted rows so strict callers can * fail closed without changing the existing Record-shaped API. */ export async function getSyncedAvailableModelsByConnection( providerId: string ): Promise { const db = getDbInstance(); const prefix = `${providerId}:`; const rows = db .prepare( "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" ) .all(`${prefix}%`); const result: SyncedAvailableModelsByConnection = {}; for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null || !key.startsWith(prefix)) continue; try { const connectionId = key.slice(prefix.length); result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value), providerId); } catch { Object.defineProperty(result, SYNCED_AVAILABLE_MODELS_MALFORMED, { value: true, enumerable: false, }); } } return result; } /** * Get all synced available models across all providers. */ export async function getAllSyncedAvailableModels(): Promise< Record > { const db = getDbInstance(); const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels'") .all(); // Group by providerId (before the colon) const byProvider = new Map>(); for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; const providerId = key.split(":")[0]; if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId); const map = byProvider.get(providerId)!; for (const m of models) { if (m.id) map.set(m.id, m); } } const result: Record = {}; for (const [providerId, map] of byProvider) { result[providerId] = Array.from(map.values()); } return result; } /** * Find active providers whose synchronized catalog contains an exact model ID. * * This keeps request-time inference aligned with the same connection-scoped * syncedAvailableModels data used by /v1/models without loading every model list * into application memory for each request. */ export async function getActiveProvidersWithSyncedModel(modelId: string): Promise { if (!modelId) return []; const db = getDbInstance(); const rows = db .prepare( `SELECT DISTINCT pc.provider AS provider FROM provider_connections pc JOIN key_value kv ON kv.namespace = 'syncedAvailableModels' AND kv.key = pc.provider || ':' || pc.id JOIN json_each(CASE WHEN json_valid(kv.value) THEN kv.value ELSE '[]' END) synced_model WHERE pc.is_active = 1 AND COALESCE( json_extract(synced_model.value, '$.id'), json_extract(synced_model.value, '$.name'), json_extract(synced_model.value, '$.model') ) = ? UNION SELECT DISTINCT pc.provider AS provider FROM provider_connections pc JOIN key_value kv ON kv.namespace = 'customModels' AND kv.key = pc.provider JOIN json_each(CASE WHEN json_valid(kv.value) THEN kv.value ELSE '[]' END) custom_model WHERE pc.is_active = 1 AND COALESCE( json_extract(custom_model.value, '$.id'), json_extract(custom_model.value, '$.name'), json_extract(custom_model.value, '$.model') ) = ?` ) .all(modelId, modelId) as Array<{ provider?: unknown }>; return rows .map((row) => row.provider) .filter((provider): provider is string => typeof provider === "string" && provider.length > 0) .filter((provider) => !isRetiredGitHubCopilotModelId(provider, modelId)); } /** * Replace the model list for a specific connection. * Key format: ':' */ export async function replaceSyncedAvailableModelsForConnection( providerId: string, connectionId: string, models: SyncedAvailableModelInput[] ): Promise { const key = `${providerId}:${connectionId}`; const normalizedModels = normalizeSyncedAvailableModels(models, providerId); persistCanonicalSyncedAvailableModels(key, normalizedModels, normalizeSyncedAvailableModels); // #12849: stamp the sync time on every successful sync — even a re-sync that // returns an unchanged list proves the catalog is still current, so staleness // gating in getActiveSyncedCatalog must not treat it as aging regardless. if (connectionId) await touchConnectionSyncedModelsAt(connectionId); // Return the full unioned list for the provider return getSyncedAvailableModels(providerId); } /** * Remove a single synced available model from all connections of a provider. * Returns true if the model was found and removed from at least one connection. */ export async function removeSyncedAvailableModel( providerId: string, modelId: string ): Promise { const db = getDbInstance(); const prefix = `${providerId}:`; const rows = db .prepare( "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" ) .all(`${prefix}%`); let removedAny = false; const removeModel = db.transaction(() => { for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; let parsedModels: unknown; try { parsedModels = JSON.parse(value); } catch (error) { console.warn(`[DB] Skipping malformed syncedAvailableModels entry for key ${key}:`, error); continue; } const models = normalizeSyncedAvailableModels(parsedModels, providerId); const filtered = models.filter((m) => m.id !== modelId); if (filtered.length !== models.length) { removedAny = true; if (filtered.length === 0) { db.prepare( "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?" ).run(key); } else { db.prepare( "UPDATE key_value SET value = ? WHERE namespace = 'syncedAvailableModels' AND key = ?" ).run(JSON.stringify(filtered), key); } } } }); removeModel(); if (removedAny) finishSyncedAvailableModelsWrite(); return removedAny; } /** * Delete all synced models for a specific connection. * Returns the remaining unioned list for the provider. */ export async function deleteSyncedAvailableModelsForConnection( providerId: string, connectionId: string ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; const result = db .prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?") .run(key); if (result.changes > 0) finishSyncedAvailableModelsWrite(); return getSyncedAvailableModels(providerId); } /** * Clean model state after a provider connection has been deleted. Imported * provider-level models are removed only when no sibling connection remains. */ export async function cleanupProviderModelsAfterConnectionDelete( providerId: string, connectionId: string ): Promise<{ remainingConnections: number; removedImportedModelIds: string[]; remainingSyncedModels: SyncedAvailableModel[]; }> { const remainingSyncedModels = await deleteSyncedAvailableModelsForConnection( providerId, connectionId ); const remainingConnections = getProviderConnectionsCount({ provider: providerId }); const removedImportedModelIds = remainingConnections === 0 ? await deleteImportedCustomModels(providerId) : []; return { remainingConnections, removedImportedModelIds, remainingSyncedModels }; } /** * Delete all synced models for every connection belonging to a provider. * Returns the number of connection-scoped synced model lists removed. */ export async function deleteSyncedAvailableModelsForProvider(providerId: string): Promise { const db = getDbInstance(); const keyPrefix = `${providerId}:`; const result = db .prepare( "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND substr(key, 1, ?) = ?" ) .run(keyPrefix.length, keyPrefix); const changes = Number(result.changes || 0); if (changes > 0) finishSyncedAvailableModelsWrite(); return changes; } /** * Prune stale synced available models for a provider, keeping only the specified allowed connection IDs. * Returns the number of keys deleted. */ export async function pruneStaleSyncedAvailableModelsForProvider( providerId: string, allowedConnectionIds: string[] ): Promise { const db = getDbInstance(); if (allowedConnectionIds.length === 0) { return deleteSyncedAvailableModelsForProvider(providerId); } const placeholders = allowedConnectionIds.map(() => "?").join(","); const keyPrefix = `${providerId}:`; const allowedKeys = allowedConnectionIds.map((id) => `${providerId}:${id}`); const result = db .prepare( `DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ? AND key NOT IN (${placeholders})` ) .run(`${keyPrefix}%`, ...allowedKeys); const changes = Number(result.changes || 0); if (changes > 0) finishSyncedAvailableModelsWrite(); return changes; } /** * Apply a tri-state boolean override from `updates` onto `next`: * field absent → keep whatever `next` already carries; explicit `null` → clear * the override (callers fall back to their heuristic); anything else → persist * the coerced boolean. */ function applyTriStateBooleanOverride( next: JsonRecord, updates: Record, field: string ): void { if (!Object.prototype.hasOwnProperty.call(updates, field)) return; if (updates[field] === null) { delete next[field]; return; } next[field] = Boolean(updates[field]); } export async function updateCustomModel( providerId: string, modelId: string, updates: Record = {} ) { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); if (!row) return null; const value = getKeyValue(row).value; if (!value) return null; const models = JSON.parse(value); const index = models.findIndex((m: JsonRecord) => m.id === modelId); if (index === -1) return null; const current = models[index]; const currentCompat = (current as JsonRecord).compatByProtocol as CompatByProtocolMap | undefined; let mergedCompat: CompatByProtocolMap | undefined = currentCompat; if ( updates.compatByProtocol !== undefined && typeof updates.compatByProtocol === "object" && updates.compatByProtocol !== null && !Array.isArray(updates.compatByProtocol) ) { mergedCompat = deepMergeCompatByProtocol( currentCompat, updates.compatByProtocol as Partial< Record> > ); if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined; } const next: JsonRecord = { ...current, ...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}), ...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}), ...(updates.targetFormat !== undefined ? { targetFormat: updates.targetFormat } : {}), ...(updates.supportedEndpoints !== undefined ? { supportedEndpoints: updates.supportedEndpoints } : {}), ...(updates.normalizeToolCallId !== undefined ? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) } : {}), ...(updates.isHidden !== undefined ? { isHidden: Boolean(updates.isHidden) } : {}), }; applyTriStateBooleanOverride(next, updates, "preserveOpenAIDeveloperRole"); // #1904: manual vision-capability override — `null` clears back to the // id-based heuristic in getCustomVisionCapabilityFields(). applyTriStateBooleanOverride(next, updates, "supportsVision"); applyTriStateBooleanOverride(next, updates, "isFree"); if (updates.compatByProtocol !== undefined) { if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) { next.compatByProtocol = mergedCompat; } else { delete next.compatByProtocol; } } if (Object.prototype.hasOwnProperty.call(updates, "upstreamHeaders")) { const uh = updates.upstreamHeaders; if (uh === null || uh === undefined) { delete next.upstreamHeaders; } else if (typeof uh === "object" && !Array.isArray(uh)) { const s = sanitizeUpstreamHeadersMap(uh as Record); if (Object.keys(s).length === 0) delete next.upstreamHeaders; else next.upstreamHeaders = s; } } // #9820: optional video-generation job preset. Mirrors the upstreamHeaders // pattern: `null`/`undefined` clears a previously set preset; a well-formed // object replaces it verbatim. if (Object.prototype.hasOwnProperty.call(updates, "generationConfig")) { const gc = updates.generationConfig; if (gc === null || gc === undefined) { delete next.generationConfig; } else if (typeof gc === "object" && !Array.isArray(gc)) { next.generationConfig = gc; } } models[index] = next; db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(models), providerId ); finishModelCatalogWriteWithBackup(); return next; } /** Single custom model row from key_value customModels, or null */ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | null { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); const value = getKeyValue(row).value; if (!value) return null; try { const models = JSON.parse(value) as unknown; if (!Array.isArray(models)) return null; const isIdMatch = (x: unknown, id: string): boolean => { if (!x || typeof x !== "object" || Array.isArray(x)) return false; return (x as { id?: string }).id === id; }; // #7364: exact match first; case-insensitive fallback so "glm-4.6V" resolves a // custom model saved as "glm-4.6v" (see lookupCustomModelMeta in // src/sse/services/model.ts for the sibling lookup this mirrors). const m = (models.find((x: unknown) => isIdMatch(x, modelId)) ?? models.find( (x: unknown) => x && typeof x === "object" && !Array.isArray(x) && typeof (x as { id?: string }).id === "string" && ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase() )) as JsonRecord | undefined; return m ?? null; } catch { return null; } } /** * Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled. * Custom model row wins; otherwise {@link getModelCompatOverrides}. * When `sourceFormat` is one of `openai` | `openai-responses` | `claude`, per-protocol * `compatByProtocol[sourceFormat].normalizeToolCallId` overrides the legacy top-level flag. */ export function getModelNormalizeToolCallId( providerId: string, modelId: string, sourceFormat?: string | null ): boolean { const m = getCustomModelRow(providerId, modelId); const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; if (m) { if (protocol) { const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol]; if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) { return Boolean(pc.normalizeToolCallId); } } return Boolean(m.normalizeToolCallId); } const co = readCompatList(providerId).find((e) => e.id === modelId); if (protocol && co?.compatByProtocol?.[protocol]) { const pc = co.compatByProtocol[protocol]!; if (Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) { return Boolean(pc.normalizeToolCallId); } } return Boolean(co?.normalizeToolCallId); } /** * Explicit preserve-openai-developer preference for this provider/model. * `undefined` = unset → routing keeps legacy default (preserve developer for OpenAI format). * `false` = map developer → system (e.g. MiniMax). `true` = keep developer. * Per-protocol overrides live under `compatByProtocol[sourceFormat]` when `sourceFormat` matches. */ export function getModelPreserveOpenAIDeveloperRole( providerId: string, modelId: string, sourceFormat?: string | null ): boolean | undefined { const m = getCustomModelRow(providerId, modelId); const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; if (m) { if (protocol) { const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol]; if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) { return Boolean(pc.preserveOpenAIDeveloperRole); } } if (Object.prototype.hasOwnProperty.call(m, "preserveOpenAIDeveloperRole")) { return Boolean(m.preserveOpenAIDeveloperRole); } return undefined; } const co = readCompatList(providerId).find((e) => e.id === modelId); if (protocol && co?.compatByProtocol?.[protocol]) { const pc = co.compatByProtocol[protocol]!; if (Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) { return Boolean(pc.preserveOpenAIDeveloperRole); } } if (co && Object.prototype.hasOwnProperty.call(co, "preserveOpenAIDeveloperRole")) { return Boolean(co.preserveOpenAIDeveloperRole); } return undefined; } /** * Check if the model is flagged as hidden from the public catalog. * `modality` (default "chat") scopes the check to one endpoint/registry — see * {@link isOverrideHiddenForModality} — so an identically-ID'd model in a different * modality's registry (e.g. Chat vs Image, #12172) is not silently suppressed too. */ export function getModelIsHidden( providerId: string, modelId: string, modality: string = "chat" ): boolean { const m = getCustomModelRow(providerId, modelId); if (m && Object.prototype.hasOwnProperty.call(m, "isHidden")) { return Boolean(m.isHidden); } const co = readCompatList(providerId).find((e) => e.id === modelId); return isOverrideHiddenForModality(co, modality); } /** * Get a map of provider ID → set of hidden model IDs from all modelCompatOverrides * and customModels, scoped to one `modality` (default "chat", matching every * pre-#12172 caller's original chat-only intent). Used by auto-combo candidate * building to skip user-hidden models. Single bulk DB query — not N+1 per model. */ export function getHiddenModelsByProvider(modality: string = "chat"): Map> { const db = getDbInstance(); const visibilityByProvider = new Map>(); const rows = db .prepare( "SELECT namespace, key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')" ) .all() as Array<{ namespace: string; key: string; value: string | null }>; for (const namespace of ["modelCompatOverrides", "customModels"]) { for (const row of rows) { if (row.namespace !== namespace || !row.value) continue; try { const parsed = JSON.parse(row.value); if (!Array.isArray(parsed)) continue; for (const entry of parsed) { if (!entry || typeof entry !== "object") continue; const modelId = (entry as { id?: unknown }).id; if (typeof modelId !== "string" || modelId.length === 0) continue; const record = entry as { isHidden?: unknown; hiddenModalities?: unknown }; const hasHiddenInfo = Object.prototype.hasOwnProperty.call(record, "isHidden") || (namespace === "modelCompatOverrides" && record.hiddenModalities && typeof record.hiddenModalities === "object"); if (!hasHiddenInfo) continue; // #12172: customModels rows have no modality scope (single user-managed // entry) — legacy global isHidden applies to every modality unchanged. const isHidden = namespace === "modelCompatOverrides" ? isOverrideHiddenForModality( { isHidden: Boolean(record.isHidden), hiddenModalities: record.hiddenModalities as | Record | undefined, }, modality ) : Boolean(record.isHidden); let visibility = visibilityByProvider.get(row.key); if (!visibility) { visibility = new Map(); visibilityByProvider.set(row.key, visibility); } visibility.set(modelId, isHidden); } } catch { // Skip malformed entries } } } return new Map( [...visibilityByProvider].flatMap(([providerId, visibility]) => { const hiddenModels = [...visibility].flatMap(([modelId, isHidden]) => isHidden ? [modelId] : [] ); return hiddenModels.length > 0 ? [[providerId, new Set(hiddenModels)] as const] : []; }) ); } /** * Persist the hidden flag for a model. Stores the override on the custom-model * row when one exists, otherwise on the compat-override list. Setting * `hidden = false` is a no-op when the model is already visible. */ export function setModelIsHidden( providerId: string, modelId: string, hidden: boolean, modality?: string ): void { const customRow = getCustomModelRow(providerId, modelId); if (customRow) { if (hidden) { updateCustomModel(providerId, modelId, { isHidden: true }); } else if (Object.prototype.hasOwnProperty.call(customRow, "isHidden")) { updateCustomModel(providerId, modelId, { isHidden: false }); } return; } // #12172: a modality-scoped write never touches the legacy all-modalities // `isHidden` flag — it only sets/clears that one modality's override, so an // identically-ID'd model in a different modality's registry is unaffected. if (modality) { mergeModelCompatOverride(providerId, modelId, { isHidden: hidden, modality }); return; } const list = readCompatList(providerId); const idx = list.findIndex((e) => e.id === modelId); if (hidden) { const prev = idx >= 0 ? list[idx] : { id: modelId }; const next: ModelCompatOverride = { ...prev, id: modelId, isHidden: true }; if (idx >= 0) list[idx] = next; else list.push(next); writeCompatList(providerId, list); return; } if (idx < 0) return; if (Object.keys(list[idx]).length <= 1) { // Only `id` left; drop the entry entirely. const filtered = list.filter((_, i) => i !== idx); writeCompatList(providerId, filtered); return; } delete list[idx].isHidden; writeCompatList(providerId, list); } function readUpstreamFromJsonRecord( row: JsonRecord | null | undefined, key: "upstreamHeaders" ): Record | undefined { if (!row) return undefined; const raw = row[key]; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const s = sanitizeUpstreamHeadersMap(raw as Record); return Object.keys(s).length > 0 ? s : undefined; } /** * Extra HTTP headers to send to the upstream provider for this model (after executor auth headers). * Order: top-level `upstreamHeaders` on the custom model row (override list merged under custom), * then per-protocol `compatByProtocol[sourceFormat].upstreamHeaders` (wins on key conflict). * Use for gateways that expect `Authentication`, `X-API-Key`, etc. alongside Bearer. * * `modelId` should be the **canonical** model id when known. Callers that accept client aliases * (e.g. chat proxy) should merge results for both alias and `resolveModelAlias(alias)` so UI * config on the resolved id still applies — see `chatCore` merge. */ export function getModelUpstreamExtraHeaders( providerId: string, modelId: string, sourceFormat?: string | null ): Record { const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; const m = getCustomModelRow(providerId, modelId); const base: Record = {}; if (m) { const fromModel = readUpstreamFromJsonRecord(m, "upstreamHeaders"); if (fromModel) Object.assign(base, fromModel); if (protocol) { const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol]; const fromProto = pc?.upstreamHeaders; if (fromProto && typeof fromProto === "object") { Object.assign(base, sanitizeUpstreamHeadersMap(fromProto as Record)); } } return base; } const co = readCompatList(providerId).find((e) => e.id === modelId); if (co?.upstreamHeaders) { Object.assign(base, sanitizeUpstreamHeadersMap(co.upstreamHeaders as Record)); } if (protocol && co?.compatByProtocol?.[protocol]?.upstreamHeaders) { Object.assign( base, sanitizeUpstreamHeadersMap( co.compatByProtocol[protocol]!.upstreamHeaders as Record ) ); } return base; }