mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
fix(api): invalidate model catalog mutation paths
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<TValue> {
|
||||
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<string, { allowed: boolean; timestamp: number }>();
|
||||
|
||||
// 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<TKey, TValue>(cache: Map<TKey, TValue>) {
|
||||
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<string[]>
|
||||
providerOrAlias,
|
||||
providerScopedModel,
|
||||
resolveProviderId,
|
||||
getProviderAlias
|
||||
getProviderAlias,
|
||||
);
|
||||
}
|
||||
return Array.from(candidates);
|
||||
@@ -333,7 +327,7 @@ async function getModelPermissionCandidates(modelId: string): Promise<string[]>
|
||||
}
|
||||
|
||||
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<string>,
|
||||
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<ApiKeyRow>("SELECT * FROM api_keys ORDER BY created_at");
|
||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare<JsonRecord>(
|
||||
"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<ApiKeyRow>(
|
||||
"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<string | null> {
|
||||
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<boolean> {
|
||||
|
||||
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<ApiKeyMetadata | null> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
62
src/lib/db/apiKeys/modelPermissionCache.ts
Normal file
62
src/lib/db/apiKeys/modelPermissionCache.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const MODEL_PERMISSION_CACHE_TTL = 60 * 1000;
|
||||
|
||||
interface ModelPermissionCacheValue {
|
||||
allowed: boolean;
|
||||
timestamp: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const _modelPermissionCache = new Map<string, ModelPermissionCacheValue>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<st
|
||||
|
||||
if (retained.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(retained),
|
||||
providerId
|
||||
providerId,
|
||||
);
|
||||
}
|
||||
|
||||
const removedIds = removed.flatMap((model) =>
|
||||
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<SyncedAvailableModel[]> {
|
||||
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<SyncedAvailableModel[]> {
|
||||
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<string, SyncedAvailableModel>();
|
||||
@@ -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<Record<string, SyncedAvailableModel[]>> {
|
||||
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<string, SyncedAvailableModel[]> = {};
|
||||
@@ -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<SyncedAvailableModel[]> {
|
||||
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<boolean> {
|
||||
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<SyncedAvailableModel[]> {
|
||||
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<number> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown> = {}
|
||||
updates: Record<string, unknown> = {},
|
||||
) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
@@ -765,7 +765,7 @@ export async function updateCustomModel(
|
||||
currentCompat,
|
||||
updates.compatByProtocol as Partial<
|
||||
Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>
|
||||
>
|
||||
>,
|
||||
);
|
||||
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<string, Set<string>> {
|
||||
// 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<string, string> | 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<string, string> {
|
||||
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<string, unknown>
|
||||
)
|
||||
co.compatByProtocol[protocol]!.upstreamHeaders as Record<string, unknown>,
|
||||
),
|
||||
);
|
||||
}
|
||||
return base;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> | null | undefined
|
||||
raw: Record<string, unknown> | null | undefined,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!raw || typeof raw !== "object") return out;
|
||||
@@ -66,7 +66,7 @@ export function sanitizeUpstreamHeadersMap(
|
||||
|
||||
export function deepMergeCompatByProtocol(
|
||||
prev: CompatByProtocolMap | undefined,
|
||||
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>
|
||||
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>,
|
||||
): 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);
|
||||
|
||||
11
src/lib/db/models/modelCatalogWriteSignals.ts
Normal file
11
src/lib/db/models/modelCatalogWriteSignals.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
} 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<PoolConnectionRow>(
|
||||
"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<AllocationRow>(
|
||||
`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<string, AllocationRow[]>();
|
||||
@@ -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<string, string[]>();
|
||||
@@ -253,7 +255,7 @@ function batchBuildPools(rows: PoolRow[]): QuotaPool[] {
|
||||
function getAllocations(poolId: string): PoolAllocation[] {
|
||||
const rows = getDb()
|
||||
.prepare<AllocationRow>(
|
||||
"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<PoolRow>(
|
||||
"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<PoolRow>(
|
||||
"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<PoolRow>(
|
||||
"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<AllocationRow>(
|
||||
`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) }));
|
||||
|
||||
178
tests/unit/model-catalog-policy-invalidation-8728.test.ts
Normal file
178
tests/unit/model-catalog-policy-invalidation-8728.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
214
tests/unit/model-catalog-source-invalidation-8728.test.ts
Normal file
214
tests/unit/model-catalog-source-invalidation-8728.test.ts
Normal file
@@ -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<Record<string, unknown>> }): 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user