From 69d6e59ef9aa2fad376795ff71a458dfee392c49 Mon Sep 17 00:00:00 2001 From: Erick Kinnee Date: Fri, 31 Jul 2026 10:28:45 -0500 Subject: [PATCH] fix(api): invalidate model catalog mutation paths --- src/lib/catalog/openrouterCatalog.ts | 2 + src/lib/db/apiKeyGroups.ts | 68 ++++-- src/lib/db/apiKeys.ts | 100 ++++---- src/lib/db/apiKeys/modelPermissionCache.ts | 62 +++++ src/lib/db/ccDiscoveryAliases.ts | 11 +- src/lib/db/featureFlags.ts | 25 +- src/lib/db/models.ts | 98 ++++---- src/lib/db/models/aliases.ts | 8 +- src/lib/db/models/compat.ts | 14 +- src/lib/db/models/modelCatalogWriteSignals.ts | 11 + src/lib/db/quotaGroups.ts | 9 +- src/lib/db/quotaPools.ts | 61 +++-- ...l-catalog-policy-invalidation-8728.test.ts | 178 +++++++++++++++ ...l-catalog-source-invalidation-8728.test.ts | 214 ++++++++++++++++++ 14 files changed, 699 insertions(+), 162 deletions(-) create mode 100644 src/lib/db/apiKeys/modelPermissionCache.ts create mode 100644 src/lib/db/models/modelCatalogWriteSignals.ts create mode 100644 tests/unit/model-catalog-policy-invalidation-8728.test.ts create mode 100644 tests/unit/model-catalog-source-invalidation-8728.test.ts diff --git a/src/lib/catalog/openrouterCatalog.ts b/src/lib/catalog/openrouterCatalog.ts index cb60ec0f96..47a0891eb0 100644 --- a/src/lib/catalog/openrouterCatalog.ts +++ b/src/lib/catalog/openrouterCatalog.ts @@ -9,6 +9,7 @@ import fs from "fs"; import path from "path"; +import { invalidateModelCatalogCache } from "@/lib/db/readCache"; const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"; const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -169,6 +170,7 @@ export async function refreshOpenRouterCatalog(): Promise<{ try { const data = await fetchFromAPI(); writeCache(data); + invalidateModelCatalogCache(); return { data, ok: true }; } catch (err) { const error = err instanceof Error ? err.message : String(err); diff --git a/src/lib/db/apiKeyGroups.ts b/src/lib/db/apiKeyGroups.ts index d14d69974b..22a85ac3ef 100644 --- a/src/lib/db/apiKeyGroups.ts +++ b/src/lib/db/apiKeyGroups.ts @@ -9,6 +9,7 @@ import { getDbInstance } from "@/lib/db/core"; import { randomUUID } from "crypto"; +import { invalidateModelCatalogCache } from "./readCache"; // ── Types ──────────────────────────────────────────────────────────────── @@ -71,7 +72,7 @@ export function createKeyGroup(name: string, description = ""): KeyGroup { const now = new Date().toISOString(); db.prepare( - "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", ).run(id, name, description, now, now); return getKeyGroup(id)!; @@ -79,7 +80,7 @@ export function createKeyGroup(name: string, description = ""): KeyGroup { export function updateKeyGroup( id: string, - updates: { name?: string; description?: string; isActive?: boolean } + updates: { name?: string; description?: string; isActive?: boolean }, ): KeyGroup | undefined { const existing = getKeyGroup(id); if (!existing) return undefined; @@ -102,17 +103,27 @@ export function updateKeyGroup( } if (sets.length === 0) return existing; + const catalogInvalidationNeeded = + updates.isActive !== undefined && updates.isActive !== existing.isActive; + sets.push("updated_at = datetime('now')"); - db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params); - return getKeyGroup(id); + const result = db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params); + if (catalogInvalidationNeeded && result.changes > 0) { + invalidateModelCatalogCache(); + } + return result.changes > 0 ? getKeyGroup(id) : existing; } export function deleteKeyGroup(id: string): boolean { const db = getDbInstance() as any; // CASCADE deletes permissions and members const result = db.prepare("DELETE FROM key_groups WHERE id = ?").run(id); - return result.changes > 0; + if (result.changes > 0) { + invalidateModelCatalogCache(); + return true; + } + return false; } // ── Group Permissions ──────────────────────────────────────────────────── @@ -121,7 +132,7 @@ export function getGroupPermissions(groupId: string): GroupModelPermission[] { const db = getDbInstance() as any; const rows = db .prepare( - "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC" + "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC", ) .all(groupId) as any[]; return rows.map(rowToPermission); @@ -131,15 +142,21 @@ export function addGroupPermission( groupId: string, modelPattern: string, accessType: "allow" | "deny", - provider?: string + provider?: string, ): GroupModelPermission { const db = getDbInstance() as any; const id = randomUUID(); const now = new Date().toISOString(); - db.prepare( - "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)" - ).run(id, groupId, modelPattern, provider || null, accessType, now); + const result = db + .prepare( + "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run(id, groupId, modelPattern, provider || null, accessType, now); + + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return getGroupPermissions(groupId).find((p) => p.id === id)!; } @@ -147,12 +164,18 @@ export function addGroupPermission( export function removeGroupPermission(permissionId: string): boolean { const db = getDbInstance() as any; const result = db.prepare("DELETE FROM group_model_permissions WHERE id = ?").run(permissionId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return result.changes > 0; } export function clearGroupPermissions(groupId: string): void { const db = getDbInstance() as any; - db.prepare("DELETE FROM group_model_permissions WHERE group_id = ?").run(groupId); + const result = db.prepare("DELETE FROM group_model_permissions WHERE group_id = ?").run(groupId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } } // ── Key Group Members ──────────────────────────────────────────────────── @@ -174,7 +197,7 @@ export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] { INNER JOIN key_group_members m ON g.id = m.group_id WHERE m.key_id = ? AND g.is_active = 1 ORDER BY g.name ASC - ` + `, ) .all(keyId) as any[]; return rows.map(rowToGroup); @@ -183,10 +206,12 @@ export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] { export function addKeyToGroup(keyId: string, groupId: string): boolean { const db = getDbInstance() as any; try { - db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run( - keyId, - groupId - ); + const result = db + .prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)") + .run(keyId, groupId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return true; } catch { return false; @@ -198,6 +223,9 @@ export function removeKeyFromGroup(keyId: string, groupId: string): boolean { const result = db .prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?") .run(keyId, groupId); + if (result.changes > 0) { + invalidateModelCatalogCache(); + } return result.changes > 0; } @@ -224,7 +252,7 @@ export interface ModelAccessCheck { export function checkKeyModelAccess( keyId: string, model: string, - provider?: string + provider?: string, ): ModelAccessCheck { const groups = getKeyGroupsForApiKey(keyId); if (groups.length === 0) { @@ -242,7 +270,7 @@ export function checkKeyModelAccess( SELECT * FROM group_model_permissions WHERE group_id IN (${placeholders}) ORDER BY access_type ASC - ` + `, ) .all(...groupIds) as any[]; @@ -253,7 +281,7 @@ export function checkKeyModelAccess( (p) => p.accessType === "deny" && matchesModelPattern(p.modelPattern, model) && - (!p.provider || p.provider === provider) + (!p.provider || p.provider === provider), ); if (denyRules.length > 0) { @@ -265,7 +293,7 @@ export function checkKeyModelAccess( (p) => p.accessType === "allow" && matchesModelPattern(p.modelPattern, model) && - (!p.provider || p.provider === provider) + (!p.provider || p.provider === provider), ); if (allowRules.length > 0) { diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 2fd4114622..32a0c68de7 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -48,6 +48,13 @@ import { parseStreamDefaultMode, parseChaosModeEnabled, } from "./apiKeys/rowParsers"; +import { + clearModelPermissionCache, + getCachedModelPermission, + setCachedModelPermission, + evictModelPermissionCache, +} from "./apiKeys/modelPermissionCache"; +import { getModelCatalogCacheVersion, invalidateModelCatalogCache } from "./readCache"; import type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; // ──────────────── Performance Optimizations ──────────────── @@ -62,7 +69,6 @@ interface CacheEntry { value: TValue; } -// Re-exported for the historical public surface (moved to ./apiKeys/types). export type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; interface ApiKeyMetadata { @@ -82,9 +88,7 @@ interface ApiKeyMetadata { maxRequestsPerMinute: number | null; throttleDelayMs: number | null; rateLimits: RateLimitRule[] | null; - // T08: Per-key max concurrent sticky sessions (0 = unlimited) maxSessions: number; - // Phase 3 lifecycle/policy fields revokedAt: string | null; expiresAt: string | null; ipAllowlist: string[]; @@ -198,12 +202,6 @@ const CACHE_TTL = 60 * 1000; // 1 minute TTL const LAST_USED_UPDATE_TTL = 5 * 60 * 1000; const MAX_CACHE_SIZE = 1000; -// Wildcard scope matching is now handled by `matchesWildcardPattern` -// (deterministic, no RegExp from dynamic strings). - -// Cache for model permission checks -const _modelPermissionCache = new Map(); - // Prepared statements cache let _stmtGetAllKeys: ApiKeysStatements["getAllKeys"] | null = null; let _stmtGetKeyById: ApiKeysStatements["getKeyById"] | null = null; @@ -218,7 +216,7 @@ let _stmtDeleteKey: ApiKeysStatements["deleteKey"] | null = null; function invalidateCaches() { _keyValidationCache.clear(); _keyMetadataCache.clear(); - _modelPermissionCache.clear(); + clearModelPermissionCache(); _lastUsedUpdateCache.clear(); } @@ -278,12 +276,8 @@ function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void { _lastUsedUpdateCache.set(id, now); } -/** - * LRU eviction for cache - */ function evictIfNeeded(cache: Map) { if (cache.size > MAX_CACHE_SIZE) { - // Remove oldest 20% of entries const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2); let i = 0; for (const key of cache.keys()) { @@ -315,7 +309,7 @@ async function getModelPermissionCandidates(modelId: string): Promise providerOrAlias, providerScopedModel, resolveProviderId, - getProviderAlias + getProviderAlias, ); } return Array.from(candidates); @@ -333,7 +327,7 @@ async function getModelPermissionCandidates(modelId: string): Promise } async function getPublishedModelLookupTarget( - modelId: string + modelId: string, ): Promise<{ providerId: string; modelId: string } | null> { const cleanModelId = stripExtendedContextSuffix(modelId.trim()); if (!cleanModelId) return null; @@ -362,14 +356,13 @@ async function getPublishedModelLookupTarget( function ensureApiKeyColumn( db: ApiKeysDbLike, columnNames: Set, - column: (typeof API_KEY_COLUMN_FALLBACKS)[number] + column: (typeof API_KEY_COLUMN_FALLBACKS)[number], ): void { if (columnNames.has(column.name)) return; db.exec(`ALTER TABLE api_keys ADD COLUMN ${column.definition}`); console.log(`[DB] Added api_keys.${column.name} column`); } -// Ensure api_keys extension columns exist (memoized) function ensureApiKeysColumns(db: ApiKeysDbLike) { if (_schemaChecked) return; @@ -386,10 +379,6 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) { } } -/** - * Initialize prepared statements (lazy initialization) - * Re-creates statements if the underlying DB connection changed (HMR, backup restore). - */ let _stmtDb: ApiKeysDbLike | null = null; function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { ensureApiKeysColumns(db); @@ -407,13 +396,13 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { _stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at"); _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); _stmtValidateKey = db.prepare( - "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?", ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?", ); _stmtInsertKey = db.prepare( - "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); } @@ -466,7 +455,7 @@ export async function getApiKeys(limit?: number, offset?: number) { camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels( - (camelRow as JsonRecord).disableNonPublicModels + (camelRow as JsonRecord).disableNonPublicModels, ); camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand); camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled); @@ -509,7 +498,7 @@ export function getApiKeysCount(): number { * inactive, or banned key, and it never widens a key's allowedModels. */ export async function pickApiKeyForInternalUse( - purpose: "combo-health-check" | "cloud-sync-verify" | "internal-probe" = "internal-probe" + purpose: "combo-health-check" | "cloud-sync-verify" | "internal-probe" = "internal-probe", ): Promise { try { const keys = (await getApiKeys()) as Array<{ @@ -527,13 +516,13 @@ export async function pickApiKeyForInternalUse( // 1. Management-scoped key (preferred for any internal probe). const manageKey = keys.find( - (k) => isUsable(k) && Array.isArray(k.scopes) && k.scopes.includes("manage") + (k) => isUsable(k) && Array.isArray(k.scopes) && k.scopes.includes("manage"), ); if (manageKey?.key) return manageKey.key; // 2. Allow-all key (empty allowedModels means no model restrictions). const allowAllKey = keys.find( - (k) => isUsable(k) && Array.isArray(k.allowedModels) && k.allowedModels.length === 0 + (k) => isUsable(k) && Array.isArray(k.allowedModels) && k.allowedModels.length === 0, ); if (allowAllKey?.key) return allowAllKey.key; @@ -576,7 +565,7 @@ export async function getApiKeyById(id: string) { camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels( - (camelRow as JsonRecord).disableNonPublicModels + (camelRow as JsonRecord).disableNonPublicModels, ); camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand); camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled); @@ -633,7 +622,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri apiKey.createdAt, apiKey.key.slice(0, 12), await hashKey(apiKey.key), - JSON.stringify(scopes) + JSON.stringify(scopes), ); setNoLog(apiKey.id, false); @@ -655,7 +644,7 @@ export async function regenerateApiKey(id: string) { // Update in DB const updateStmt = db.prepare( - "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?" + "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?", ); updateStmt.run(newKey, newHash, newPrefix, id); @@ -707,7 +696,7 @@ export async function updateApiKeyPermissions( dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; chaosModeEnabled?: boolean; - } + }, ) { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); @@ -1001,6 +990,16 @@ export async function updateApiKeyPermissions( if (changedRows === 0) return false; + const invalidatesModelCatalogCache = + normalized.allowedModels !== undefined || + normalized.blockedModels !== undefined || + allowedQuotasUpdate !== undefined || + normalized.disableNonPublicModels !== undefined; + + if (invalidatesModelCatalogCache) { + invalidateModelCatalogCache(); + } + const { logAuditEvent } = await import("@/lib/compliance"); if (normalized.isBanned !== undefined) { @@ -1094,7 +1093,7 @@ export async function revokeApiKey(id: string): Promise { const result = db .prepare( - "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id" + "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id", ) .run({ id, ts: new Date().toISOString() }); @@ -1223,7 +1222,7 @@ export async function validateApiKey(key: string | null | undefined) { revokedAt: row.revoked_at, }), "EX", - 3600 // 1 hour cache + 3600, // 1 hour cache ); } } catch { @@ -1240,7 +1239,7 @@ export async function validateApiKey(key: string | null | undefined) { * Get API key metadata with caching for performance */ export async function getApiKeyMetadata( - key: string | null | undefined + key: string | null | undefined, ): Promise { if (!key || typeof key !== "string") return null; @@ -1339,10 +1338,10 @@ export async function getApiKeyMetadata( blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels), allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos), allowedConnections: parseAllowedConnections( - record.allowed_connections ?? record.allowedConnections + record.allowed_connections ?? record.allowedConnections, ), allowedQuotas: parseAllowedQuotas( - (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas + (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas, ), noLog: parseNoLog(record.no_log ?? record.noLog), autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve), @@ -1364,20 +1363,20 @@ export async function getApiKeyMetadata( proxyId: typeof record.proxy_id === "string" && record.proxy_id.trim() !== "" ? record.proxy_id : null, allowedEndpoints: parseStringList( - (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints + (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints, ), streamDefaultMode: parseStreamDefaultMode( - (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode + (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode, ), disableNonPublicModels: parseDisableNonPublicModels( (record as JsonRecord).disable_non_public_models ?? - (record as JsonRecord).disableNonPublicModels + (record as JsonRecord).disableNonPublicModels, ), allowUsageCommand: parseAllowUsageCommand( - (record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand + (record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand, ), chaosModeEnabled: parseChaosModeEnabled( - (record as JsonRecord).chaos_mode_enabled ?? (record as JsonRecord).chaosModeEnabled + (record as JsonRecord).chaos_mode_enabled ?? (record as JsonRecord).chaosModeEnabled, ), ...parseApiKeyUsageLimitFields(record as JsonRecord), }; @@ -1403,7 +1402,7 @@ export async function getApiKeyMetadata( */ export async function isModelAllowedForKey( key: string | null | undefined, - modelId: string | null | undefined + modelId: string | null | undefined, ) { // If no key provided, allow (request may be using different auth method like JWT) // If no modelId provided, deny (invalid request) @@ -1413,12 +1412,13 @@ export async function isModelAllowedForKey( // Create cache key const cacheKey = `${key}:${modelId}`; const now = Date.now(); + const catalogGeneration = getModelCatalogCacheVersion(); const usesSettingDependentClaudeRouting = isPotentialUnprefixedClaudeCodeModel(modelId); // Check permission cache - const cached = _modelPermissionCache.get(cacheKey); - if (!usesSettingDependentClaudeRouting && cached && now - cached.timestamp < CACHE_TTL) { - return cached.allowed; + const cached = getCachedModelPermission(cacheKey, now, catalogGeneration); + if (!usesSettingDependentClaudeRouting && cached !== undefined) { + return cached; } const metadata = await getApiKeyMetadata(key); @@ -1485,8 +1485,8 @@ export async function isModelAllowedForKey( } // Cache the result if (!usesSettingDependentClaudeRouting) { - evictIfNeeded(_modelPermissionCache); - _modelPermissionCache.set(cacheKey, { allowed, timestamp: now }); + evictModelPermissionCache(); + setCachedModelPermission(cacheKey, allowed, now, catalogGeneration); } return allowed; @@ -1512,8 +1512,6 @@ function clearPreparedStatementCache() { */ export function clearApiKeyCaches() { invalidateCaches(); - _lastUsedUpdateCache.clear(); - _modelPermissionCache.clear(); } /** diff --git a/src/lib/db/apiKeys/modelPermissionCache.ts b/src/lib/db/apiKeys/modelPermissionCache.ts new file mode 100644 index 0000000000..d353f59b38 --- /dev/null +++ b/src/lib/db/apiKeys/modelPermissionCache.ts @@ -0,0 +1,62 @@ +const MODEL_PERMISSION_CACHE_TTL = 60 * 1000; + +interface ModelPermissionCacheValue { + allowed: boolean; + timestamp: number; + generation: number; +} + +const _modelPermissionCache = new Map(); + +function isFresh( + entry: ModelPermissionCacheValue, + now: number, + currentGeneration: number, +): boolean { + if (entry.generation !== currentGeneration) return false; + if (now - entry.timestamp >= MODEL_PERMISSION_CACHE_TTL) return false; + return true; +} + +export function getCachedModelPermission( + cacheKey: string, + now: number, + catalogGeneration: number, +): boolean | undefined { + const entry = _modelPermissionCache.get(cacheKey); + if (!entry) return undefined; + + if (!isFresh(entry, now, catalogGeneration)) { + _modelPermissionCache.delete(cacheKey); + return undefined; + } + + return entry.allowed; +} + +export function setCachedModelPermission( + cacheKey: string, + allowed: boolean, + now: number, + catalogGeneration: number, +): void { + _modelPermissionCache.set(cacheKey, { + allowed, + timestamp: now, + generation: catalogGeneration, + }); +} + +export function evictModelPermissionCache(): void { + if (_modelPermissionCache.size <= 1000) return; + const entriesToRemove = Math.floor(1000 * 0.2); + let i = 0; + for (const key of _modelPermissionCache.keys()) { + if (i++ >= entriesToRemove) break; + _modelPermissionCache.delete(key); + } +} + +export function clearModelPermissionCache(): void { + _modelPermissionCache.clear(); +} diff --git a/src/lib/db/ccDiscoveryAliases.ts b/src/lib/db/ccDiscoveryAliases.ts index 0eb46e2b34..2d16d2c673 100644 --- a/src/lib/db/ccDiscoveryAliases.ts +++ b/src/lib/db/ccDiscoveryAliases.ts @@ -22,6 +22,7 @@ import { getFeatureFlagOverride } from "./featureFlags"; import { getDbInstance } from "./core"; +import { finishModelCatalogWriteWithoutBackup } from "./models/modelCatalogWriteSignals"; const NAMESPACE = "ccDiscoveryAliases"; const FLAG_KEY = "EXPOSE_CC_DISCOVERY_ALIASES"; @@ -71,13 +72,15 @@ export function setCcAliasProviderSetting(providerId: string, v: CcAliasSetting) const key = providerKey(providerId); if (v === null) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key); + finishModelCatalogWriteWithoutBackup(); return; } db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( NAMESPACE, key, - v + v, ); + finishModelCatalogWriteWithoutBackup(); } export function getCcAliasModelSetting(providerId: string, modelId: string): CcAliasSetting { @@ -91,19 +94,21 @@ export function getCcAliasModelSetting(providerId: string, modelId: string): CcA export function setCcAliasModelSetting( providerId: string, modelId: string, - v: CcAliasSetting + v: CcAliasSetting, ): void { const db = getDbInstance(); const key = modelKey(providerId, modelId); if (v === null) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key); + finishModelCatalogWriteWithoutBackup(); return; } db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( NAMESPACE, key, - v + v, ); + finishModelCatalogWriteWithoutBackup(); } /** diff --git a/src/lib/db/featureFlags.ts b/src/lib/db/featureFlags.ts index 724543c354..ea28690498 100644 --- a/src/lib/db/featureFlags.ts +++ b/src/lib/db/featureFlags.ts @@ -8,9 +8,16 @@ import { FEATURE_FLAG_DEFINITIONS } from "@/shared/constants/featureFlagDefinitions"; import { getDbInstance } from "./core"; +import { finishModelCatalogWriteWithoutBackup } from "./models/modelCatalogWriteSignals"; const NAMESPACE = "feature_flags"; +const CATALOG_RELEVANT_FEATURE_FLAGS = new Set([ + "MODEL_CATALOG_INCLUDE_NAMES", + "MODELS_CATALOG_PREFIX_MODE", + "EXPOSE_CC_DISCOVERY_ALIASES", +]); + /** * Returns all feature flag overrides as a key→value map. */ @@ -53,15 +60,18 @@ export function setFeatureFlagOverride(key: string, value: string): void { !definition.enumValues.includes(value) ) { throw new Error( - `Invalid value "${value}" for enum flag ${key}. Allowed: ${definition.enumValues.join(", ")}` + `Invalid value "${value}" for enum flag ${key}. Allowed: ${definition.enumValues.join(", ")}`, ); } const db = getDbInstance(); db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( NAMESPACE, key, - value + value, ); + if (CATALOG_RELEVANT_FEATURE_FLAGS.has(key)) { + finishModelCatalogWriteWithoutBackup(); + } } /** @@ -71,6 +81,9 @@ export function setFeatureFlagOverride(key: string, value: string): void { export function removeFeatureFlagOverride(key: string): void { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, key); + if (CATALOG_RELEVANT_FEATURE_FLAGS.has(key)) { + finishModelCatalogWriteWithoutBackup(); + } } /** @@ -78,5 +91,13 @@ export function removeFeatureFlagOverride(key: string): void { */ export function clearAllFeatureFlagOverrides(): void { const db = getDbInstance(); + const hadRelevantOverride = Boolean( + db + .prepare("SELECT 1 FROM key_value WHERE namespace = ? AND key IN (?, ?, ?) LIMIT 1") + .get(NAMESPACE, ...Array.from(CATALOG_RELEVANT_FEATURE_FLAGS)), + ); db.prepare("DELETE FROM key_value WHERE namespace = ?").run(NAMESPACE); + if (hadRelevantOverride) { + finishModelCatalogWriteWithoutBackup(); + } } diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 2c21f26619..4ad886cef1 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -5,13 +5,13 @@ */ import { getDbInstance } from "./core"; -import { backupDbFile } from "./backup"; import { getProviderConnectionsCount } from "./providers"; import { type JsonRecord, asRecord, toNonEmptyString, getKeyValue } from "./models/shared"; import { finishSyncedAvailableModelsWrite, persistCanonicalSyncedAvailableModels, } from "./models/syncedAvailableModelPersistence"; +import { finishModelCatalogWriteWithBackup } from "./models/modelCatalogWriteSignals"; import { readCompatList, writeCompatList, @@ -105,7 +105,7 @@ export async function addCustomModel( 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 + supportsVision?: boolean, ) { const db = getDbInstance(); const row = db @@ -134,9 +134,9 @@ export async function addCustomModel( }; models.push(model); db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)", ).run(providerId, JSON.stringify(models)); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return model; } @@ -158,7 +158,7 @@ export async function replaceCustomModels( supportsThinking?: boolean; targetFormat?: string; }>, - { allowEmpty = false }: { allowEmpty?: 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 @@ -231,15 +231,15 @@ export async function replaceCustomModels( if (merged.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( - providerId + providerId, ); } else { db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)", ).run(providerId, JSON.stringify(merged)); } - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return merged; } @@ -269,20 +269,20 @@ export async function deleteImportedCustomModels(providerId: string): Promise - typeof model.id === "string" && model.id ? [model.id] : [] + typeof model.id === "string" && model.id ? [model.id] : [], ); for (const modelId of removedIds) removeModelCompatOverride(providerId, modelId); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return removedIds; } @@ -303,17 +303,17 @@ export async function removeCustomModel(providerId: string, modelId: string) { if (filtered.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( - providerId + providerId, ); } else { db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(filtered), - providerId + providerId, ); } removeModelCompatOverride(providerId, modelId); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return true; } @@ -364,8 +364,8 @@ function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | n new Set( record.supportedEndpoints .map((endpoint) => toNonEmptyString(endpoint)) - .filter((endpoint): endpoint is string => Boolean(endpoint)) - ) + .filter((endpoint): endpoint is string => Boolean(endpoint)), + ), ).sort() : undefined; @@ -386,7 +386,7 @@ function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | n ...(Array.isArray(record.supportedThinkingEfforts) ? { supportedThinkingEfforts: record.supportedThinkingEfforts.filter( - (effort): effort is string => typeof effort === "string" && effort.length > 0 + (effort): effort is string => typeof effort === "string" && effort.length > 0, ), } : {}), @@ -425,7 +425,7 @@ function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] */ export async function getSyncedAvailableModelsForConnection( providerId: string, - connectionId: string + connectionId: string, ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; @@ -446,12 +446,12 @@ export async function getSyncedAvailableModelsForConnection( * Get all synced available models for a provider, unioned across all connections. */ export async function getSyncedAvailableModels( - providerId: string + providerId: string, ): Promise { const db = getDbInstance(); const rows = db .prepare( - "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?" + "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?", ) .all(`${providerId}:%`); const map = new Map(); @@ -470,13 +470,13 @@ export async function getSyncedAvailableModels( * Get synced available models for a provider grouped by connection id. */ export async function getSyncedAvailableModelsByConnection( - providerId: string + 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 ?" + "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?", ) .all(`${prefix}%`); const result: Record = {}; @@ -547,7 +547,7 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis json_extract(synced_model.value, '$.id'), json_extract(synced_model.value, '$.name'), json_extract(synced_model.value, '$.model') - ) = ?` + ) = ?`, ) .all(modelId) as Array<{ provider?: unknown }>; @@ -563,7 +563,7 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis export async function replaceSyncedAvailableModelsForConnection( providerId: string, connectionId: string, - models: SyncedAvailableModelInput[] + models: SyncedAvailableModelInput[], ): Promise { const key = `${providerId}:${connectionId}`; // #3199: drop ids the operator DELETED (trash) so a re-fetch does not re-import @@ -574,7 +574,7 @@ export async function replaceSyncedAvailableModelsForConnection( // churning back on through the managed-alias path ("Auto Sync Enabling all // Models"). See getModelIsDeleted for the legacy-row caveat. const normalizedModels = normalizeSyncedAvailableModels(models).filter( - (m) => !getModelIsDeleted(providerId, m.id) + (m) => !getModelIsDeleted(providerId, m.id), ); persistCanonicalSyncedAvailableModels(key, normalizedModels, normalizeSyncedAvailableModels); // Return the full unioned list for the provider @@ -587,13 +587,13 @@ export async function replaceSyncedAvailableModelsForConnection( */ export async function removeSyncedAvailableModel( providerId: string, - modelId: 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 ?" + "SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?", ) .all(`${prefix}%`); @@ -617,11 +617,11 @@ export async function removeSyncedAvailableModel( removedAny = true; if (filtered.length === 0) { db.prepare( - "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?" + "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?", ).run(key); } else { db.prepare( - "UPDATE key_value SET value = ? WHERE namespace = 'syncedAvailableModels' AND key = ?" + "UPDATE key_value SET value = ? WHERE namespace = 'syncedAvailableModels' AND key = ?", ).run(JSON.stringify(filtered), key); } } @@ -639,7 +639,7 @@ export async function removeSyncedAvailableModel( */ export async function deleteSyncedAvailableModelsForConnection( providerId: string, - connectionId: string + connectionId: string, ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; @@ -656,7 +656,7 @@ export async function deleteSyncedAvailableModelsForConnection( */ export async function cleanupProviderModelsAfterConnectionDelete( providerId: string, - connectionId: string + connectionId: string, ): Promise<{ remainingConnections: number; removedImportedModelIds: string[]; @@ -664,7 +664,7 @@ export async function cleanupProviderModelsAfterConnectionDelete( }> { const remainingSyncedModels = await deleteSyncedAvailableModelsForConnection( providerId, - connectionId + connectionId, ); const remainingConnections = getProviderConnectionsCount({ provider: providerId }); const removedImportedModelIds = @@ -682,7 +682,7 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string) const keyPrefix = `${providerId}:`; const result = db .prepare( - "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND substr(key, 1, ?) = ?" + "DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND substr(key, 1, ?) = ?", ) .run(keyPrefix.length, keyPrefix); const changes = Number(result.changes || 0); @@ -696,7 +696,7 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string) */ export async function pruneStaleSyncedAvailableModelsForProvider( providerId: string, - allowedConnectionIds: string[] + allowedConnectionIds: string[], ): Promise { const db = getDbInstance(); if (allowedConnectionIds.length === 0) { @@ -707,7 +707,7 @@ export async function pruneStaleSyncedAvailableModelsForProvider( 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})` + `DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ? AND key NOT IN (${placeholders})`, ) .run(`${keyPrefix}%`, ...allowedKeys); const changes = Number(result.changes || 0); @@ -724,7 +724,7 @@ export async function pruneStaleSyncedAvailableModelsForProvider( function applyTriStateBooleanOverride( next: JsonRecord, updates: Record, - field: string + field: string, ): void { if (!Object.prototype.hasOwnProperty.call(updates, field)) return; if (updates[field] === null) { @@ -737,7 +737,7 @@ function applyTriStateBooleanOverride( export async function updateCustomModel( providerId: string, modelId: string, - updates: Record = {} + updates: Record = {}, ) { const db = getDbInstance(); const row = db @@ -765,7 +765,7 @@ export async function updateCustomModel( currentCompat, updates.compatByProtocol as Partial< Record> - > + >, ); if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined; } @@ -810,10 +810,10 @@ export async function updateCustomModel( db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( JSON.stringify(models), - providerId + providerId, ); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); return next; } @@ -842,7 +842,7 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu typeof x === "object" && !Array.isArray(x) && typeof (x as { id?: string }).id === "string" && - ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase() + ((x as { id: string }).id as string).toLowerCase() === modelId.toLowerCase(), )) as JsonRecord | undefined; return m ?? null; } catch { @@ -859,7 +859,7 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu export function getModelNormalizeToolCallId( providerId: string, modelId: string, - sourceFormat?: string | null + sourceFormat?: string | null, ): boolean { const m = getCustomModelRow(providerId, modelId); const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; @@ -892,7 +892,7 @@ export function getModelNormalizeToolCallId( export function getModelPreserveOpenAIDeveloperRole( providerId: string, modelId: string, - sourceFormat?: string | null + sourceFormat?: string | null, ): boolean | undefined { const m = getCustomModelRow(providerId, modelId); const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; @@ -946,7 +946,7 @@ export function getHiddenModelsByProvider(): Map> { // Query all rows from key_value for both namespaces const rows = db .prepare( - "SELECT key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')" + "SELECT key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')", ) .all() as Array<{ key: string; value: string | null }>; @@ -1034,7 +1034,7 @@ export function setModelIsHidden(providerId: string, modelId: string, hidden: bo function readUpstreamFromJsonRecord( row: JsonRecord | null | undefined, - key: "upstreamHeaders" + key: "upstreamHeaders", ): Record | undefined { if (!row) return undefined; const raw = row[key]; @@ -1056,7 +1056,7 @@ function readUpstreamFromJsonRecord( export function getModelUpstreamExtraHeaders( providerId: string, modelId: string, - sourceFormat?: string | null + sourceFormat?: string | null, ): Record { const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; const m = getCustomModelRow(providerId, modelId); @@ -1083,8 +1083,8 @@ export function getModelUpstreamExtraHeaders( Object.assign( base, sanitizeUpstreamHeadersMap( - co.compatByProtocol[protocol]!.upstreamHeaders as Record - ) + co.compatByProtocol[protocol]!.upstreamHeaders as Record, + ), ); } return base; diff --git a/src/lib/db/models/aliases.ts b/src/lib/db/models/aliases.ts index b88e15d7c1..b28d95e31d 100644 --- a/src/lib/db/models/aliases.ts +++ b/src/lib/db/models/aliases.ts @@ -1,8 +1,8 @@ /** db/models/aliases.ts — model alias CRUD (modelAliases namespace). */ import { getDbInstance } from "../core"; -import { backupDbFile } from "../backup"; import { getKeyValue } from "./shared"; +import { finishModelCatalogWriteWithBackup } from "./modelCatalogWriteSignals"; export async function getModelAliases() { const db = getDbInstance(); @@ -21,15 +21,15 @@ export async function getModelAliases() { export async function setModelAlias(alias: string, model: unknown) { const db = getDbInstance(); db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)" + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)", ).run(alias, JSON.stringify(model)); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); } export async function deleteModelAlias(alias: string) { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias); - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); } /** diff --git a/src/lib/db/models/compat.ts b/src/lib/db/models/compat.ts index 54b05ba3a6..eba83f8703 100644 --- a/src/lib/db/models/compat.ts +++ b/src/lib/db/models/compat.ts @@ -1,13 +1,13 @@ /** db/models/compat.ts — model-compat overrides (normalizeToolCallId, per-protocol flags, upstream headers). */ import { getDbInstance } from "../core"; -import { backupDbFile } from "../backup"; import { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey, } from "@/shared/constants/modelCompat"; import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; import { getKeyValue } from "./shared"; +import { finishModelCatalogWriteWithBackup } from "./modelCatalogWriteSignals"; /** Built-in / alias models: tool-call + developer-role flags without a full custom row */ const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides"; @@ -42,7 +42,7 @@ function isValidUpstreamHeaderName(k: string): boolean { /** Sanitize user-provided upstream header map (used when persisting and when reading for requests). */ export function sanitizeUpstreamHeadersMap( - raw: Record | null | undefined + raw: Record | null | undefined, ): Record { const out: Record = {}; if (!raw || typeof raw !== "object") return out; @@ -66,7 +66,7 @@ export function sanitizeUpstreamHeadersMap( export function deepMergeCompatByProtocol( prev: CompatByProtocolMap | undefined, - patch: Partial>> + patch: Partial>>, ): CompatByProtocolMap { const out: CompatByProtocolMap = { ...(prev || {}) }; for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) { @@ -140,16 +140,16 @@ export function writeCompatList(providerId: string, list: ModelCompatOverride[]) if (list.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( MODEL_COMPAT_NAMESPACE, - providerId + providerId, ); } else { db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( MODEL_COMPAT_NAMESPACE, providerId, - JSON.stringify(list) + JSON.stringify(list), ); } - backupDbFile("pre-write"); + finishModelCatalogWriteWithBackup(); } export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] { @@ -178,7 +178,7 @@ export function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined) export function mergeModelCompatOverride( providerId: string, modelId: string, - patch: ModelCompatPatch + patch: ModelCompatPatch, ) { const list = readCompatList(providerId); const idx = list.findIndex((e) => e.id === modelId); diff --git a/src/lib/db/models/modelCatalogWriteSignals.ts b/src/lib/db/models/modelCatalogWriteSignals.ts new file mode 100644 index 0000000000..0903839baa --- /dev/null +++ b/src/lib/db/models/modelCatalogWriteSignals.ts @@ -0,0 +1,11 @@ +import { backupDbFile } from "../backup"; +import { invalidateModelCatalogCache } from "../readCache"; + +export function finishModelCatalogWriteWithBackup(): void { + backupDbFile("pre-write"); + invalidateModelCatalogCache(); +} + +export function finishModelCatalogWriteWithoutBackup(): void { + invalidateModelCatalogCache(); +} diff --git a/src/lib/db/quotaGroups.ts b/src/lib/db/quotaGroups.ts index b944828394..f2f21966b9 100644 --- a/src/lib/db/quotaGroups.ts +++ b/src/lib/db/quotaGroups.ts @@ -9,6 +9,7 @@ */ import { getDbInstance } from "./core"; +import { invalidateModelCatalogCache } from "./readCache"; // --------------------------------------------------------------------------- // Types @@ -113,7 +114,11 @@ export function listGroups(): QuotaGroup[] { */ export function renameGroup(id: string, name: string): boolean { const result = getDb().prepare("UPDATE quota_groups SET name = ? WHERE id = ?").run(name, id); - return result.changes > 0; + if (result.changes > 0) { + invalidateModelCatalogCache(); + return true; + } + return false; } /** @@ -130,7 +135,7 @@ export function deleteGroup(id: string): boolean { // Protect the seed group. if (id === "group-demo") { throw new Error( - "Cannot delete the protected seed group 'group-demo'. Reassign its pools to another group first." + "Cannot delete the protected seed group 'group-demo'. Reassign its pools to another group first.", ); } diff --git a/src/lib/db/quotaPools.ts b/src/lib/db/quotaPools.ts index b054f15c75..a26a4bc6b0 100644 --- a/src/lib/db/quotaPools.ts +++ b/src/lib/db/quotaPools.ts @@ -9,6 +9,8 @@ */ import { getDbInstance } from "./core"; +import { clearApiKeyCaches } from "./apiKeys"; +import { invalidateModelCatalogCache } from "./readCache"; // Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change. // Imported lazily (dynamic import in the hook) to avoid circular-dependency // risk between db/ and quota/ modules. The import is fire-and-forget; combo @@ -30,7 +32,7 @@ async function removeQuotaCombosGuarded(poolId: string): Promise { } catch (err) { console.warn( "[quota-pools] removeQuotaCombosForPool failed (non-fatal):", - (err as Error)?.message + (err as Error)?.message, ); } } @@ -125,7 +127,7 @@ function assertSingleProvider(connectionIds: string[]): void { const providers = rows.map((r) => r.provider).filter(Boolean); if (new Set(providers).size > 1) { throw new Error( - `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})` + `A quota pool must use a single provider (got: ${[...new Set(providers)].join(", ")})`, ); } } @@ -179,7 +181,7 @@ interface PoolConnectionRow { function getConnectionIds(poolId: string, fallbackConnectionId: string): string[] { const rows = getDb() .prepare( - "SELECT connection_id FROM quota_pool_connections WHERE pool_id = ? ORDER BY created_at ASC" + "SELECT connection_id FROM quota_pool_connections WHERE pool_id = ? ORDER BY created_at ASC", ) .all(poolId); if (rows.length > 0) { @@ -210,7 +212,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { // Batch allocations: 1 query for all pools const allocRows = db .prepare( - `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id IN (${ph})` + `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id IN (${ph})`, ) .all(...poolIds); const allocsByPool = new Map(); @@ -226,7 +228,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { // Batch connections: 1 query for all pools const connRows = db .prepare<{ pool_id: string; connection_id: string }>( - `SELECT pool_id, connection_id FROM quota_pool_connections WHERE pool_id IN (${ph}) ORDER BY created_at ASC` + `SELECT pool_id, connection_id FROM quota_pool_connections WHERE pool_id IN (${ph}) ORDER BY created_at ASC`, ) .all(...poolIds); const connsByPool = new Map(); @@ -253,7 +255,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] { function getAllocations(poolId: string): PoolAllocation[] { const rows = getDb() .prepare( - "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?" + "SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations WHERE pool_id = ?", ) .all(poolId); return rows.map(rowToAllocation); @@ -324,7 +326,7 @@ export function listPools(options?: { limit?: number; offset?: number }): { export function getPool(id: string): QuotaPool | null { const row = getDb() .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", ) .get(id); if (!row) return null; @@ -358,12 +360,12 @@ export function createPool(input: PoolCreate): QuotaPool { const doCreate = database.transaction(() => { database .prepare( - "INSERT INTO quota_pools (id, connection_id, name, group_id, created_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO quota_pools (id, connection_id, name, group_id, created_at) VALUES (?, ?, ?, ?, ?)", ) .run(id, primaryConnectionId, input.name, groupId, now); const insertConn = database.prepare( - "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)" + "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)", ); for (const connId of members) { insertConn.run(id, connId); @@ -372,7 +374,7 @@ export function createPool(input: PoolCreate): QuotaPool { if (input.allocations && input.allocations.length > 0) { const insertAlloc = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ); for (const alloc of input.allocations) { insertAlloc.run( @@ -381,7 +383,7 @@ export function createPool(input: PoolCreate): QuotaPool { alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy + alloc.policy, ); } } @@ -396,12 +398,14 @@ export function createPool(input: PoolCreate): QuotaPool { group_id: groupId, created_at: now, }, - getAllocations(id) + getAllocations(id), ); // Phase B2: fire-and-forget combo sync; failures are logged but never thrown. void syncQuotaCombosGuarded(id); + invalidateModelCatalogCache(); + return result; } @@ -415,7 +419,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { const database = getDb(); const existing = database .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", ) .get(id); if (!existing) return null; @@ -441,7 +445,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { // Replace join rows. database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id); const insertConn = database.prepare( - "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)" + "INSERT OR IGNORE INTO quota_pool_connections (pool_id, connection_id) VALUES (?, ?)", ); for (const connId of input.connectionIds) { insertConn.run(id, connId); @@ -456,7 +460,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(id); const insertAlloc = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ); for (const alloc of input.allocations) { insertAlloc.run( @@ -465,7 +469,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy + alloc.policy, ); } } @@ -477,6 +481,8 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null { // Phase B2: fire-and-forget combo sync; failures are logged but never thrown. void syncQuotaCombosGuarded(id); + invalidateModelCatalogCache(); + return result; } @@ -500,13 +506,20 @@ export function deletePool(id: string): boolean { (SELECT json_group_array(value) FROM json_each(api_keys.allowed_quotas) WHERE value != ?), '[]') WHERE allowed_quotas IS NOT NULL AND allowed_quotas != '[]' - AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)` + AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)`, ) .run(id, id); return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id); }); const result = doDelete(); - return result.changes > 0; + if (result.changes <= 0) return false; + + // Direct rewrite of key permission metadata happens above; clear API-key + // caches so any primed permission entries pick up the new allowed_quotas set. + clearApiKeyCaches(); + invalidateModelCatalogCache(); + + return true; } /** @@ -552,7 +565,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) // without requiring a manual re-save. Persists the normalized weights. const totalWeight = allocations.reduce( (s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0), - 0 + 0, ); const normalizedAllocations = totalWeight === 0 && allocations.length > 0 @@ -563,7 +576,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) // Defensive: fall back to [poolId] (single-pool semantics) if pool not found. const targetPool = database .prepare( - "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?" + "SELECT id, connection_id, name, group_id, created_at FROM quota_pools WHERE id = ?", ) .get(poolId); @@ -582,7 +595,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) const doUpsert = database.transaction(() => { const insert = database.prepare( `INSERT INTO quota_allocations (pool_id, api_key_id, weight, cap_value, cap_unit, policy) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ); for (const pid of poolIdsInGroup) { database.prepare("DELETE FROM quota_allocations WHERE pool_id = ?").run(pid); @@ -593,7 +606,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) alloc.weight, alloc.capValue ?? null, alloc.capUnit ?? null, - alloc.policy + alloc.policy, ); } } @@ -610,13 +623,13 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[]) * Returns pairs of { poolId, allocation }. */ export function listAllocationsForApiKey( - apiKeyId: string + apiKeyId: string, ): Array<{ poolId: string; allocation: PoolAllocation }> { const rows = getDb() .prepare( `SELECT pool_id, api_key_id, weight, cap_value, cap_unit, policy FROM quota_allocations - WHERE api_key_id = ?` + WHERE api_key_id = ?`, ) .all(apiKeyId); return rows.map((row) => ({ poolId: row.pool_id, allocation: rowToAllocation(row) })); diff --git a/tests/unit/model-catalog-policy-invalidation-8728.test.ts b/tests/unit/model-catalog-policy-invalidation-8728.test.ts new file mode 100644 index 0000000000..4c9c7e2744 --- /dev/null +++ b/tests/unit/model-catalog-policy-invalidation-8728.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-model-catalog-policy-8728-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "model-catalog-policy-invalidation-8728"; + +const core = await import("../../src/lib/db/core.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const apiKeys = await import("../../src/lib/db/apiKeys.ts"); +const apiKeyGroups = await import("../../src/lib/db/apiKeyGroups.ts"); +const models = await import("../../src/lib/db/models.ts"); +const quotaPools = await import("../../src/lib/db/quotaPools.ts"); +const quotaGroups = await import("../../src/lib/db/quotaGroups.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeys.resetApiKeyState(); + + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function catalogVersion() { + return readCache.getModelCatalogCacheVersion(); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeys.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("updateApiKeyPermissions increments only on catalog-affecting fields", async () => { + const created = await apiKeys.createApiKey("Cache Policy Key", "machine-cpi-1"); + let version = catalogVersion(); + + assert.equal(await apiKeys.updateApiKeyPermissions(created.id, { isActive: false }), true); + assert.equal(catalogVersion(), version, "isActive does not invalidate model-catalog cache"); + + assert.equal(await apiKeys.updateApiKeyPermissions(created.id, {}), false); + assert.equal(catalogVersion(), version, "no-op update does not invalidate model-catalog cache"); + + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/*"] }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { blockedModels: ["openai/sandbox/*"] }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { allowedQuotas: ["q-1", "q-2"] }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { disableNonPublicModels: true }); + assert.equal(catalogVersion(), ++version); +}); + +test("isModelAllowedForKey cache recomputes when group permissions change", async () => { + const key = await apiKeys.createApiKey("Group Visibility Key", "machine-cpi-2"); + const modelId = "openai/gpt-4o-mini"; + await apiKeys.updateApiKeyPermissions(key.id, { allowedModels: ["openai/*"] }); + + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), true); + + const group = apiKeyGroups.createKeyGroup("Model deny", "denies openai"); + apiKeyGroups.addGroupPermission(group.id, "openai/*", "deny"); + assert.equal(apiKeyGroups.addKeyToGroup(key.id, group.id), true); + + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), false); + assert.ok(catalogVersion() > 0, "group visibility change increments model catalog generation"); +}); + +test("isModelAllowedForKey cache recomputes after custom model visibility changes", async () => { + const key = await apiKeys.createApiKey("Custom Visibility Key", "machine-cpi-3"); + await apiKeys.updateApiKeyPermissions(key.id, { + allowedModels: ["openai/*"], + disableNonPublicModels: true, + }); + + const modelId = "openai/catalog-cache-repro"; + await models.addCustomModel( + "openai", + "catalog-cache-repro", + "Catalog cache repro", + "manual", + "chat-completions", + ["chat"], + ); + + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), true); + + models.setModelIsHidden("openai", "catalog-cache-repro", true); + assert.equal(await apiKeys.isModelAllowedForKey(key.key, modelId), false); +}); + +test("API-key group membership only invalidates model catalog on real membership mutations", async () => { + const key = await apiKeys.createApiKey("Group Membership Key", "machine-cpi-5"); + const group = apiKeyGroups.createKeyGroup("Model deny", "denies openai"); + let version = catalogVersion(); + + assert.equal(apiKeyGroups.addKeyToGroup(key.id, group.id), true); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + assert.equal(apiKeyGroups.addKeyToGroup(key.id, group.id), true); + assert.equal(catalogVersion(), version); + + assert.equal(apiKeyGroups.removeKeyFromGroup(key.id, group.id), true); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + assert.equal(apiKeyGroups.removeKeyFromGroup(key.id, group.id), false); + assert.equal(catalogVersion(), version); +}); + +test("quota pools and quota-group renames signal model-catalog invalidation as expected", async () => { + let version = catalogVersion(); + const pool = quotaPools.createPool({ + connectionId: "conn-quota", + name: "Quota Pool", + groupId: "group-demo", + }); + assert.equal(catalogVersion(), version + 1); + + const group = quotaGroups.createGroup("Quota Group"); + assert.equal(catalogVersion(), version + 1, "creating quota groups does not invalidate catalog"); + + version = catalogVersion(); + const renamed = quotaGroups.renameGroup(group.id, "Renamed Quota Group"); + assert.equal(renamed, true); + assert.equal(catalogVersion(), version + 1, "quota-group rename invalidates catalog"); + + version = catalogVersion(); + assert.notEqual(quotaPools.updatePool(pool.id, { name: "Quota Pool Updated" }), null); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + assert.equal(quotaPools.deletePool(pool.id), true); + assert.equal(catalogVersion(), version + 1); +}); + +test("deletePool clears primed key metadata after allowed_quotas rewrite", async () => { + const pool = quotaPools.createPool({ connectionId: "conn-meta", name: "Meta Pool" }); + const key = await apiKeys.createApiKey("Pool Metadata Key", "machine-cpi-4"); + + await apiKeys.updateApiKeyPermissions(key.id, { allowedQuotas: [pool.id] }); + const before = await apiKeys.getApiKeyMetadata(key.key); + assert.deepEqual(before?.allowedQuotas, [pool.id]); + + assert.equal(quotaPools.deletePool(pool.id), true); + + const after = await apiKeys.getApiKeyMetadata(key.key); + assert.deepEqual(after?.allowedQuotas, [], "allowed_quotas cache must reflect direct rewrite"); +}); diff --git a/tests/unit/model-catalog-source-invalidation-8728.test.ts b/tests/unit/model-catalog-source-invalidation-8728.test.ts new file mode 100644 index 0000000000..50dfa86eff --- /dev/null +++ b/tests/unit/model-catalog-source-invalidation-8728.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-model-catalog-sources-8728-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const models = await import("../../src/lib/db/models.ts"); +const aliases = await import("../../src/lib/db/models/aliases.ts"); +const compat = await import("../../src/lib/db/models/compat.ts"); +const ccAliases = await import("../../src/lib/db/ccDiscoveryAliases.ts"); +const featureFlags = await import("../../src/lib/db/featureFlags.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const openRouterCatalog = await import("../../src/lib/catalog/openrouterCatalog.ts"); + +function resetStorage() { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function catalogVersion() { + return readCache.getModelCatalogCacheVersion(); +} + +const REAL_FETCH = globalThis.fetch; + +function installMockOpenRouterFetch(payload: { data: Array> }): void { + globalThis.fetch = (async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +function installFailingOpenRouterFetch(): void { + globalThis.fetch = (async () => { + throw new Error("simulated openrouter failure"); + }) as typeof fetch; +} + +function restoreRealFetch(): void { + globalThis.fetch = REAL_FETCH; +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + restoreRealFetch(); +}); + +test("custom-model source writes invalidate model-catalog cache version; no-op writes do not", async () => { + let version = catalogVersion(); + + await models.addCustomModel("openai", "manual-one"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + const duplicate = await models.addCustomModel("openai", "manual-one"); + assert.equal(duplicate.id, "manual-one"); + assert.equal(catalogVersion(), version); + + await models.replaceCustomModels("openai", [ + { id: "manual-one", source: "manual", apiFormat: "chat-completions" }, + { id: "imported-one", source: "auto-sync", apiFormat: "chat-completions" }, + ]); + version = version + 1; + assert.equal(catalogVersion(), version); + + const removedIds = await models.deleteImportedCustomModels("openai"); + assert.deepEqual(removedIds, ["imported-one"]); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + const removedExisting = await models.removeCustomModel("openai", "manual-one"); + assert.equal(removedExisting, true); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + const removedMissing = await models.removeCustomModel("openai", "missing"); + assert.equal(removedMissing, false); + assert.equal(catalogVersion(), version); + + const updatedMissing = await models.updateCustomModel("openai", "missing", { + modelName: "Missing", + }); + assert.equal(updatedMissing, null); + assert.equal(catalogVersion(), version); + + const noImported = await models.deleteImportedCustomModels("openai"); + assert.deepEqual(noImported, []); + assert.equal(catalogVersion(), version); + + await models.addCustomModel("openai", "manual-two", "manual two"); + version = catalogVersion(); + const updateResult = await models.updateCustomModel("openai", "manual-two", { + modelName: "Manual Two", + }); + assert.notEqual(updateResult, null); + assert.equal(catalogVersion(), version + 1); + + const noTarget = await models.updateCustomModel("openai", "missing", { modelName: "Missing" }); + assert.equal(noTarget, null); + assert.equal(catalogVersion(), version + 1); +}); + +test("model alias and compat writes invalidate model-catalog cache version", async () => { + let version = catalogVersion(); + + await aliases.setModelAlias("alias-alpha", "openai/manual-one"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + await aliases.deleteModelAlias("alias-alpha"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + compat.writeCompatList("openai", [ + { + id: "manual-two", + normalizeToolCallId: true, + }, + ]); + assert.equal(catalogVersion(), version + 1); +}); + +test("Claude Code discovery alias setters invalidate on both write and inherit/delete", () => { + let version = catalogVersion(); + + ccAliases.setCcAliasProviderSetting("openai", "on"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + ccAliases.setCcAliasProviderSetting("openai", null); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + ccAliases.setCcAliasModelSetting("openai", "gpt-4", "on"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + ccAliases.setCcAliasModelSetting("openai", "gpt-4", null); + assert.equal(catalogVersion(), version + 1); +}); + +test("feature flag writes invalidate catalog version only for catalog-relevant overrides", async () => { + const unrelatedBefore = catalogVersion(); + featureFlags.setFeatureFlagOverride("ARENA_ELO_SYNC_ENABLED", "true"); + assert.equal(catalogVersion(), unrelatedBefore); + + let version = catalogVersion(); + featureFlags.setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + featureFlags.setFeatureFlagOverride("MODELS_CATALOG_PREFIX_MODE", "alias"); + assert.equal(catalogVersion(), version + 1); + + version = catalogVersion(); + featureFlags.setFeatureFlagOverride("EXPOSE_CC_DISCOVERY_ALIASES", "true"); + assert.equal(catalogVersion(), version + 1); + + const relevantKeyBeforeRemove = catalogVersion(); + featureFlags.removeFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES"); + assert.equal(catalogVersion(), relevantKeyBeforeRemove + 1); + + featureFlags.setFeatureFlagOverride("MODELS_CATALOG_PREFIX_MODE", "dual"); + const irrelevantClearVersion = catalogVersion(); + featureFlags.setFeatureFlagOverride("ARENA_ELO_SYNC_ENABLED", "true"); + featureFlags.clearAllFeatureFlagOverrides(); + assert.equal(catalogVersion(), irrelevantClearVersion + 1); + + const clearNoRelevantBefore = catalogVersion(); + featureFlags.clearAllFeatureFlagOverrides(); + assert.equal(catalogVersion(), clearNoRelevantBefore); +}); + +test("refreshOpenRouterCatalog invalidates only on success", async () => { + installMockOpenRouterFetch({ data: [{ id: "openrouter/fake", source: "test" }] }); + try { + const beforeGet = catalogVersion(); + await openRouterCatalog.getOpenRouterCatalog(); + assert.equal( + catalogVersion(), + beforeGet, + "ordinary get should not invalidate the model-catalog cache", + ); + + const beforeRefreshSuccess = catalogVersion(); + const success = await openRouterCatalog.refreshOpenRouterCatalog(); + assert.equal(success.ok, true); + assert.equal(catalogVersion(), beforeRefreshSuccess + 1); + + installFailingOpenRouterFetch(); + const beforeRefreshFailure = catalogVersion(); + const failed = await openRouterCatalog.refreshOpenRouterCatalog(); + assert.equal(failed.ok, false); + assert.equal(catalogVersion(), beforeRefreshFailure, "failed refresh should not invalidate"); + } finally { + restoreRealFetch(); + } +});