From 2787449f57c3d55c01595229b2dc061b2d7fed66 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:37:07 -0300 Subject: [PATCH] refactor(db): extract pricing/lkgp/cache-metrics from db/settings.ts into leaf modules (#5709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCO E3 of the god-files campaign. db/settings.ts (1154 LOC) mixed five concerns; the three cleanly-separable ones plus the shared toRecord/JsonRecord helper were lifted out verbatim into a new src/lib/db/settings/ subdirectory, leaving the Settings-core + Proxy config concerns in the host (host now 646 LOC). The host re-exports every moved public symbol so the module's public API (consumed by ~93 test files + localDb) is unchanged. - settings/shared.ts — toRecord + JsonRecord (9 LOC) - settings/pricing.ts — pricing layers/sources/per-model + update/reset (254 LOC) - settings/lkgp.ts — Last-Known-Good-Provider get/set/clear (49 LOC) - settings/cacheMetrics.ts — cache metrics + trend (235 LOC) Settings-core + the Proxy-config concern stay in the host: proxy is the most tangled (245-line resolveProxyForConnection, resolution cache, imports from ./proxies) and getSettings is the most central function — leaving them is the correct coupled-core stop. Pricing/LKGP/Cache have NO dependency on Settings/Proxy helpers (verified); the dependency DAG is acyclic (check:cycles). Adds tests/unit/db-settings-split.test.ts: characterization of the shared toRecord helper + a guard asserting the host preserves its full public export surface. Validated: typecheck:core, check:cycles (no cycles), 149 existing+new db/settings consumer tests green (db-settings-crud/extended, 8 pricing suites, cache-metrics, 2 proxy-resolution suites + 29 new), ESLint, Prettier, check:file-size (host 646 < frozen 1155). --- src/lib/db/settings.ts | 550 +-------------------------- src/lib/db/settings/cacheMetrics.ts | 235 ++++++++++++ src/lib/db/settings/lkgp.ts | 49 +++ src/lib/db/settings/pricing.ts | 254 +++++++++++++ src/lib/db/settings/shared.ts | 9 + tests/unit/db-settings-split.test.ts | 90 +++++ 6 files changed, 658 insertions(+), 529 deletions(-) create mode 100644 src/lib/db/settings/cacheMetrics.ts create mode 100644 src/lib/db/settings/lkgp.ts create mode 100644 src/lib/db/settings/pricing.ts create mode 100644 src/lib/db/settings/shared.ts create mode 100644 tests/unit/db-settings-split.test.ts diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 10341b3860..e99fce7c4e 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -10,12 +10,8 @@ import { getProxyRegistryGeneration, resolveProxyForScopeFromRegistry } from "./ import { getComboModelProvider as getComboEntryProvider } from "@/lib/combos/steps"; import { requestBodyLimitMbFromEnv } from "@/shared/constants/bodySize"; import { DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE } from "@/shared/constants/responsesPreviousResponseId"; +import { type JsonRecord, toRecord } from "./settings/shared"; -type JsonRecord = Record; -type PricingModels = Record; -type PricingByProvider = Record; -export type PricingSource = "default" | "litellm" | "modelsDev" | "user"; -export type PricingSourceMap = Record>; type ProxyValue = JsonRecord | string | null; type ProxyResolutionResult = { proxy: ProxyValue; @@ -67,10 +63,6 @@ interface ProxyConfig { [key: string]: unknown; } -function toRecord(value: unknown): JsonRecord { - return value && typeof value === "object" ? (value as JsonRecord) : {}; -} - function toProxyMap(value: unknown): ProxyMap { return value && typeof value === "object" ? (value as ProxyMap) : {}; } @@ -218,294 +210,6 @@ export async function isCloudEnabled() { return settings.cloudEnabled === true; } -// ──────────────── Pricing ──────────────── - -function readPricingNamespace( - db: ReturnType, - namespace: string -): PricingByProvider { - const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = ?").all(namespace); - const pricing: PricingByProvider = {}; - - for (const row of rows) { - const record = toRecord(row); - const key = typeof record.key === "string" ? record.key : null; - const rawValue = typeof record.value === "string" ? record.value : null; - if (!key || rawValue === null) continue; - - try { - pricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels; - } catch { - // Corrupted data — skip silently, fallback to lower layers - } - } - - return pricing; -} - -function mergePricingLayers(layers: PricingByProvider[]): PricingByProvider { - const mergedPricing: PricingByProvider = {}; - - for (const layer of layers) { - for (const [provider, models] of Object.entries(layer)) { - if (!mergedPricing[provider]) { - mergedPricing[provider] = { ...models }; - continue; - } - - for (const [model, pricing] of Object.entries(models)) { - mergedPricing[provider][model] = mergedPricing[provider][model] - ? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) } - : pricing; - } - } - } - - return mergedPricing; -} - -function buildPricingSourceMap(layers: { - defaults: PricingByProvider; - litellm: PricingByProvider; - modelsDev: PricingByProvider; - user: PricingByProvider; -}): PricingSourceMap { - const sourceMap: PricingSourceMap = {}; - const mergedPricing = mergePricingLayers([ - layers.defaults, - layers.litellm, - layers.modelsDev, - layers.user, - ]); - - for (const [provider, models] of Object.entries(mergedPricing)) { - sourceMap[provider] = {}; - - for (const model of Object.keys(models)) { - if (layers.user[provider]?.[model]) { - sourceMap[provider][model] = "user"; - } else if (layers.modelsDev[provider]?.[model]) { - sourceMap[provider][model] = "modelsDev"; - } else if (layers.litellm[provider]?.[model]) { - sourceMap[provider][model] = "litellm"; - } else { - sourceMap[provider][model] = "default"; - } - } - } - - return sourceMap; -} - -async function getPricingLayers() { - const db = getDbInstance(); - - // Layer 1: Hardcoded defaults (lowest priority) - const { getDefaultPricing } = await import("@/shared/constants/pricing"); - return { - defaults: getDefaultPricing(), - litellm: readPricingNamespace(db, "pricing_synced"), - modelsDev: readPricingNamespace(db, "models_dev_pricing"), - user: readPricingNamespace(db, "pricing"), - }; -} - -export async function getPricing() { - const layers = await getPricingLayers(); - // Merge: defaults → LiteLLM → models.dev → user (each layer overrides the previous) - return mergePricingLayers([layers.defaults, layers.litellm, layers.modelsDev, layers.user]); -} - -export async function getPricingWithSources(): Promise<{ - pricing: PricingByProvider; - sourceMap: PricingSourceMap; -}> { - const layers = await getPricingLayers(); - return { - pricing: mergePricingLayers([layers.defaults, layers.litellm, layers.modelsDev, layers.user]), - sourceMap: buildPricingSourceMap(layers), - }; -} - -export async function getPricingForModel(provider: string, model: string) { - const pricing = await getPricing(); - - const findKeyInsensitive = ( - obj: Record | undefined | null, - key: string - ): T | undefined => { - if (!obj || !key) return undefined; - const lowerKey = key.toLowerCase(); - for (const [k, v] of Object.entries(obj)) { - if (k.toLowerCase() === lowerKey) return v; - } - return undefined; - }; - - const pLower = (provider || "").toLowerCase(); - let providerPricing = findKeyInsensitive(pricing, pLower); - - if (!providerPricing) { - const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower); - if (alias) providerPricing = findKeyInsensitive(pricing, alias); - } - - if (!providerPricing) { - for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { - if (typeof mappedAlias === "string" && mappedAlias.toLowerCase() === pLower) { - providerPricing = findKeyInsensitive(pricing, id); - if (providerPricing) break; - } - } - } - - if (!providerPricing) { - const np = pLower.replace(/-cn$/, ""); - if (np && np !== pLower) { - providerPricing = findKeyInsensitive(pricing, np); - } - } - - if (!providerPricing) return null; - - const mLower = (model || "").toLowerCase(); - let modelPricing = findKeyInsensitive(providerPricing, mLower); - - if (!modelPricing) { - const hyphenModel = mLower.replace(/\./g, "-"); - modelPricing = findKeyInsensitive(providerPricing, hyphenModel); - } - - return modelPricing || null; -} - -export async function updatePricing(pricingData: PricingByProvider) { - const db = getDbInstance(); - const insert = db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('pricing', ?, ?)" - ); - - const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); - const existing: PricingByProvider = {}; - for (const row of rows) { - const record = toRecord(row); - const key = typeof record.key === "string" ? record.key : null; - const rawValue = typeof record.value === "string" ? record.value : null; - if (!key || rawValue === null) continue; - existing[key] = toRecord(JSON.parse(rawValue)) as PricingModels; - } - - const tx = db.transaction(() => { - for (const [provider, models] of Object.entries(pricingData)) { - insert.run(provider, JSON.stringify({ ...(existing[provider] || {}), ...models })); - } - }); - tx(); - backupDbFile("pre-write"); - invalidateDbCache("pricing"); // Bust the pricing read cache - const updated: PricingByProvider = {}; - const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); - for (const row of allRows) { - const record = toRecord(row); - const key = typeof record.key === "string" ? record.key : null; - const rawValue = typeof record.value === "string" ? record.value : null; - if (!key || rawValue === null) continue; - updated[key] = toRecord(JSON.parse(rawValue)) as PricingModels; - } - return updated; -} - -export async function resetPricing(provider: string, model?: string) { - const db = getDbInstance(); - - if (model) { - const row = db - .prepare("SELECT value FROM key_value WHERE namespace = 'pricing' AND key = ?") - .get(provider); - if (row) { - const rowRecord = toRecord(row); - const value = typeof rowRecord.value === "string" ? rowRecord.value : "{}"; - const models = toRecord(JSON.parse(value)); - delete models[model]; - if (Object.keys(models).length === 0) { - db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider); - } else { - db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'pricing' AND key = ?").run( - JSON.stringify(models), - provider - ); - } - } - } else { - db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider); - } - - backupDbFile("pre-write"); - const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); - const result: Record = {}; - for (const row of allRows) { - const record = toRecord(row); - const key = typeof record.key === "string" ? record.key : null; - const rawValue = typeof record.value === "string" ? record.value : null; - if (!key || rawValue === null) continue; - result[key] = JSON.parse(rawValue); - } - return result; -} - -export async function resetAllPricing() { - const db = getDbInstance(); - db.prepare("DELETE FROM key_value WHERE namespace = 'pricing'").run(); - backupDbFile("pre-write"); - return {}; -} - -// ──────────────── LKGP (Last Known Good Provider) ──────────────── - -export interface LKGPRecord { - provider: string; - connectionId?: string; -} - -export async function getLKGP(comboName: string, modelId: string): Promise { - const db = getDbInstance(); - const key = `${comboName}:${modelId}`; - const row = db - .prepare("SELECT value FROM key_value WHERE namespace = 'lkgp' AND key = ?") - .get(key) as { value?: string } | undefined; - if (!row?.value) return null; - try { - const parsed = JSON.parse(row.value); - if (typeof parsed === "object" && parsed !== null && "provider" in parsed) { - return parsed as LKGPRecord; - } - return { provider: String(parsed) }; - } catch { - return { provider: row.value }; - } -} - -export async function setLKGP( - comboName: string, - modelId: string, - providerId: string, - connectionId?: string -) { - const db = getDbInstance(); - const key = `${comboName}:${modelId}`; - const value: LKGPRecord = { provider: providerId }; - if (connectionId) value.connectionId = connectionId; - db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('lkgp', ?, ?)").run( - key, - JSON.stringify(value) - ); -} - -export function clearAllLKGP(): void { - const db = getDbInstance(); - db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp'").run(); -} - // ──────────────── Proxy Config ──────────────── const DEFAULT_PROXY_CONFIG: ProxyConfig = { global: null, providers: {}, combos: {}, keys: {} }; @@ -724,8 +428,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: if (perKeyEnabled) { try { const apiKeyRow = db.prepare("SELECT proxy_id FROM api_keys WHERE id = ?").get(apiKeyId) as - | { proxy_id?: string | null } - | undefined; + { proxy_id?: string | null } | undefined; if (apiKeyRow?.proxy_id) { const proxyRow = db .prepare( @@ -919,236 +622,25 @@ export async function setProxyConfig(config: Record) { return current; } -// ──────────────── Cache Control Metrics ──────────────── -// Cache metrics are now computed from usage_history table on-the-fly -// This avoids race conditions and keeps a single source of truth for token data +// ──────────────── Re-exports from leaf modules ──────────────── -export async function getCacheMetrics() { - const db = getDbInstance(); +export { + type PricingSource, + type PricingSourceMap, + getPricing, + getPricingWithSources, + getPricingForModel, + updatePricing, + resetPricing, + resetAllPricing, +} from "./settings/pricing"; - try { - // Aggregate totals from usage_history - const totalsRow = db - .prepare( - ` - SELECT - COUNT(*) as totalRequests, - SUM(tokens_input) as totalInputTokens, - SUM(tokens_cache_read) as totalCachedTokens, - SUM(tokens_cache_creation) as totalCacheCreationTokens - FROM usage_history - WHERE tokens_cache_read > 0 OR tokens_cache_creation > 0 - ` - ) - .get() as - | { - totalRequests: number; - totalInputTokens: number | null; - totalCachedTokens: number | null; - totalCacheCreationTokens: number | null; - } - | undefined; +export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP } from "./settings/lkgp"; - // Get all requests count (including those without cache activity) - const allRequestsRow = db - .prepare( - ` - SELECT COUNT(*) as totalRequests - FROM usage_history - ` - ) - .get() as { totalRequests: number } | undefined; - - // Aggregate by provider - const byProviderRows = db - .prepare( - ` - SELECT - provider, - COUNT(*) as totalRequests, - SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests, - SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN tokens_input ELSE 0 END) as inputTokens, - SUM(tokens_cache_read) as cachedTokens, - SUM(tokens_cache_creation) as cacheCreationTokens - FROM usage_history - WHERE provider IS NOT NULL - GROUP BY provider - HAVING cachedRequests > 0 - ` - ) - .all() as Array<{ - provider: string; - totalRequests: number; - cachedRequests: number; - inputTokens: number | null; - cachedTokens: number | null; - cacheCreationTokens: number | null; - }>; - - // Aggregate by combo strategy (direct requests stored as 'direct') - const byStrategyRows = db - .prepare( - ` - SELECT - COALESCE(combo_strategy, 'direct') as strategy, - COUNT(*) as requests, - SUM(tokens_input) as inputTokens, - SUM(tokens_cache_read) as cachedTokens, - SUM(tokens_cache_creation) as cacheCreationTokens - FROM usage_history - WHERE (tokens_cache_read > 0 OR tokens_cache_creation > 0) - GROUP BY combo_strategy - ` - ) - .all() as Array<{ - strategy: string; - requests: number; - inputTokens: number | null; - cachedTokens: number | null; - cacheCreationTokens: number | null; - }>; - - const tokensSaved = totalsRow?.totalCachedTokens || 0; - - const AVG_INPUT_PRICE_PER_MILLION = 3; - const CACHE_DISCOUNT = 0.9; - const estimatedCostSaved = - Math.round((tokensSaved / 1_000_000) * AVG_INPUT_PRICE_PER_MILLION * CACHE_DISCOUNT * 100) / - 100; - - // Build byProvider object - const byProvider: Record< - string, - { - requests: number; - totalRequests: number; - cachedRequests: number; - inputTokens: number; - cachedTokens: number; - cacheCreationTokens: number; - } - > = {}; - for (const row of byProviderRows) { - byProvider[row.provider] = { - requests: row.cachedRequests, - totalRequests: row.totalRequests, - cachedRequests: row.cachedRequests, - inputTokens: row.inputTokens || 0, - cachedTokens: row.cachedTokens || 0, - cacheCreationTokens: row.cacheCreationTokens || 0, - }; - } - - // Build byStrategy object - const byStrategy: Record< - string, - { - requests: number; - inputTokens: number; - cachedTokens: number; - cacheCreationTokens: number; - } - > = {}; - for (const row of byStrategyRows) { - byStrategy[row.strategy] = { - requests: row.requests, - inputTokens: row.inputTokens || 0, - cachedTokens: row.cachedTokens || 0, - cacheCreationTokens: row.cacheCreationTokens || 0, - }; - } - - return { - totalRequests: allRequestsRow?.totalRequests || totalsRow?.totalRequests || 0, - requestsWithCacheControl: totalsRow?.totalRequests || 0, - totalInputTokens: totalsRow?.totalInputTokens || 0, - totalCachedTokens: totalsRow?.totalCachedTokens || 0, - totalCacheCreationTokens: totalsRow?.totalCacheCreationTokens || 0, - tokensSaved, - estimatedCostSaved, - byProvider, - byStrategy, - lastUpdated: new Date().toISOString(), - }; - } catch (error) { - console.error("Failed to fetch cache metrics from usage_history:", error); - return { - totalRequests: 0, - requestsWithCacheControl: 0, - totalInputTokens: 0, - totalCachedTokens: 0, - totalCacheCreationTokens: 0, - tokensSaved: 0, - estimatedCostSaved: 0, - byProvider: {}, - byStrategy: {}, - lastUpdated: new Date().toISOString(), - }; - } -} - -export async function updateCacheMetrics(_metrics: Record) { - // No-op: metrics are now computed from usage_history on-the-fly - // The usage_history table is the single source of truth - return getCacheMetrics(); -} - -export interface CacheTrendPoint { - timestamp: string; - requests: number; - cachedRequests: number; - inputTokens: number; - cachedTokens: number; - cacheCreationTokens: number; -} - -export async function getCacheTrend(hours = 24): Promise { - const db = getDbInstance(); - - try { - const rows = db - .prepare( - ` - SELECT - strftime('%Y-%m-%dT%H:00:00Z', timestamp) as hour, - COUNT(*) as requests, - SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests, - SUM(tokens_input) as inputTokens, - SUM(tokens_cache_read) as cachedTokens, - SUM(tokens_cache_creation) as cacheCreationTokens - FROM usage_history - WHERE timestamp >= datetime('now', ?) - GROUP BY hour - ORDER BY hour ASC - ` - ) - .all(`-${hours} hours`) as Array<{ - hour: string; - requests: number; - cachedRequests: number; - inputTokens: number | null; - cachedTokens: number | null; - cacheCreationTokens: number | null; - }>; - - return rows.map((r) => ({ - timestamp: r.hour, - requests: r.requests, - cachedRequests: r.cachedRequests, - inputTokens: r.inputTokens || 0, - cachedTokens: r.cachedTokens || 0, - cacheCreationTokens: r.cacheCreationTokens || 0, - })); - } catch (error) { - console.error("Failed to fetch cache trend:", error); - return []; - } -} - -export async function resetCacheMetrics() { - // No-op: cache metrics are computed from usage_history. - console.warn( - "resetCacheMetrics is deprecated - cache metrics are now computed from usage_history" - ); - return getCacheMetrics(); -} +export { + type CacheTrendPoint, + getCacheMetrics, + updateCacheMetrics, + getCacheTrend, + resetCacheMetrics, +} from "./settings/cacheMetrics"; diff --git a/src/lib/db/settings/cacheMetrics.ts b/src/lib/db/settings/cacheMetrics.ts new file mode 100644 index 0000000000..4a2ce8d5ec --- /dev/null +++ b/src/lib/db/settings/cacheMetrics.ts @@ -0,0 +1,235 @@ +/** + * db/settings/cacheMetrics.ts — Cache control metrics (computed from usage_history on-the-fly). + */ + +import { getDbInstance } from "../core"; + +export async function getCacheMetrics() { + const db = getDbInstance(); + + try { + // Aggregate totals from usage_history + const totalsRow = db + .prepare( + ` + SELECT + COUNT(*) as totalRequests, + SUM(tokens_input) as totalInputTokens, + SUM(tokens_cache_read) as totalCachedTokens, + SUM(tokens_cache_creation) as totalCacheCreationTokens + FROM usage_history + WHERE tokens_cache_read > 0 OR tokens_cache_creation > 0 + ` + ) + .get() as + | { + totalRequests: number; + totalInputTokens: number | null; + totalCachedTokens: number | null; + totalCacheCreationTokens: number | null; + } + | undefined; + + // Get all requests count (including those without cache activity) + const allRequestsRow = db + .prepare( + ` + SELECT COUNT(*) as totalRequests + FROM usage_history + ` + ) + .get() as { totalRequests: number } | undefined; + + // Aggregate by provider + const byProviderRows = db + .prepare( + ` + SELECT + provider, + COUNT(*) as totalRequests, + SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests, + SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN tokens_input ELSE 0 END) as inputTokens, + SUM(tokens_cache_read) as cachedTokens, + SUM(tokens_cache_creation) as cacheCreationTokens + FROM usage_history + WHERE provider IS NOT NULL + GROUP BY provider + HAVING cachedRequests > 0 + ` + ) + .all() as Array<{ + provider: string; + totalRequests: number; + cachedRequests: number; + inputTokens: number | null; + cachedTokens: number | null; + cacheCreationTokens: number | null; + }>; + + // Aggregate by combo strategy (direct requests stored as 'direct') + const byStrategyRows = db + .prepare( + ` + SELECT + COALESCE(combo_strategy, 'direct') as strategy, + COUNT(*) as requests, + SUM(tokens_input) as inputTokens, + SUM(tokens_cache_read) as cachedTokens, + SUM(tokens_cache_creation) as cacheCreationTokens + FROM usage_history + WHERE (tokens_cache_read > 0 OR tokens_cache_creation > 0) + GROUP BY combo_strategy + ` + ) + .all() as Array<{ + strategy: string; + requests: number; + inputTokens: number | null; + cachedTokens: number | null; + cacheCreationTokens: number | null; + }>; + + const tokensSaved = totalsRow?.totalCachedTokens || 0; + + const AVG_INPUT_PRICE_PER_MILLION = 3; + const CACHE_DISCOUNT = 0.9; + const estimatedCostSaved = + Math.round((tokensSaved / 1_000_000) * AVG_INPUT_PRICE_PER_MILLION * CACHE_DISCOUNT * 100) / + 100; + + // Build byProvider object + const byProvider: Record< + string, + { + requests: number; + totalRequests: number; + cachedRequests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; + } + > = {}; + for (const row of byProviderRows) { + byProvider[row.provider] = { + requests: row.cachedRequests, + totalRequests: row.totalRequests, + cachedRequests: row.cachedRequests, + inputTokens: row.inputTokens || 0, + cachedTokens: row.cachedTokens || 0, + cacheCreationTokens: row.cacheCreationTokens || 0, + }; + } + + // Build byStrategy object + const byStrategy: Record< + string, + { + requests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; + } + > = {}; + for (const row of byStrategyRows) { + byStrategy[row.strategy] = { + requests: row.requests, + inputTokens: row.inputTokens || 0, + cachedTokens: row.cachedTokens || 0, + cacheCreationTokens: row.cacheCreationTokens || 0, + }; + } + + return { + totalRequests: allRequestsRow?.totalRequests || totalsRow?.totalRequests || 0, + requestsWithCacheControl: totalsRow?.totalRequests || 0, + totalInputTokens: totalsRow?.totalInputTokens || 0, + totalCachedTokens: totalsRow?.totalCachedTokens || 0, + totalCacheCreationTokens: totalsRow?.totalCacheCreationTokens || 0, + tokensSaved, + estimatedCostSaved, + byProvider, + byStrategy, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + console.error("Failed to fetch cache metrics from usage_history:", error); + return { + totalRequests: 0, + requestsWithCacheControl: 0, + totalInputTokens: 0, + totalCachedTokens: 0, + totalCacheCreationTokens: 0, + tokensSaved: 0, + estimatedCostSaved: 0, + byProvider: {}, + byStrategy: {}, + lastUpdated: new Date().toISOString(), + }; + } +} + +export async function updateCacheMetrics(_metrics: Record) { + // No-op: metrics are now computed from usage_history on-the-fly + // The usage_history table is the single source of truth + return getCacheMetrics(); +} + +export interface CacheTrendPoint { + timestamp: string; + requests: number; + cachedRequests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; +} + +export async function getCacheTrend(hours = 24): Promise { + const db = getDbInstance(); + + try { + const rows = db + .prepare( + ` + SELECT + strftime('%Y-%m-%dT%H:00:00Z', timestamp) as hour, + COUNT(*) as requests, + SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests, + SUM(tokens_input) as inputTokens, + SUM(tokens_cache_read) as cachedTokens, + SUM(tokens_cache_creation) as cacheCreationTokens + FROM usage_history + WHERE timestamp >= datetime('now', ?) + GROUP BY hour + ORDER BY hour ASC + ` + ) + .all(`-${hours} hours`) as Array<{ + hour: string; + requests: number; + cachedRequests: number; + inputTokens: number | null; + cachedTokens: number | null; + cacheCreationTokens: number | null; + }>; + + return rows.map((r) => ({ + timestamp: r.hour, + requests: r.requests, + cachedRequests: r.cachedRequests, + inputTokens: r.inputTokens || 0, + cachedTokens: r.cachedTokens || 0, + cacheCreationTokens: r.cacheCreationTokens || 0, + })); + } catch (error) { + console.error("Failed to fetch cache trend:", error); + return []; + } +} + +export async function resetCacheMetrics() { + // No-op: cache metrics are computed from usage_history. + console.warn( + "resetCacheMetrics is deprecated - cache metrics are now computed from usage_history" + ); + return getCacheMetrics(); +} diff --git a/src/lib/db/settings/lkgp.ts b/src/lib/db/settings/lkgp.ts new file mode 100644 index 0000000000..b5b990f641 --- /dev/null +++ b/src/lib/db/settings/lkgp.ts @@ -0,0 +1,49 @@ +/** + * db/settings/lkgp.ts — Last Known Good Provider (LKGP) persistence. + */ + +import { getDbInstance } from "../core"; + +export interface LKGPRecord { + provider: string; + connectionId?: string; +} + +export async function getLKGP(comboName: string, modelId: string): Promise { + const db = getDbInstance(); + const key = `${comboName}:${modelId}`; + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'lkgp' AND key = ?") + .get(key) as { value?: string } | undefined; + if (!row?.value) return null; + try { + const parsed = JSON.parse(row.value); + if (typeof parsed === "object" && parsed !== null && "provider" in parsed) { + return parsed as LKGPRecord; + } + return { provider: String(parsed) }; + } catch { + return { provider: row.value }; + } +} + +export async function setLKGP( + comboName: string, + modelId: string, + providerId: string, + connectionId?: string +) { + const db = getDbInstance(); + const key = `${comboName}:${modelId}`; + const value: LKGPRecord = { provider: providerId }; + if (connectionId) value.connectionId = connectionId; + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('lkgp', ?, ?)").run( + key, + JSON.stringify(value) + ); +} + +export function clearAllLKGP(): void { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp'").run(); +} diff --git a/src/lib/db/settings/pricing.ts b/src/lib/db/settings/pricing.ts new file mode 100644 index 0000000000..b0f7dd1b74 --- /dev/null +++ b/src/lib/db/settings/pricing.ts @@ -0,0 +1,254 @@ +/** + * db/settings/pricing.ts — Pricing data CRUD (user overrides, LiteLLM sync, models.dev sync). + */ + +import { getDbInstance } from "../core"; +import { backupDbFile } from "../backup"; +import { invalidateDbCache } from "../readCache"; +import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { type JsonRecord, toRecord } from "./shared"; + +type PricingModels = Record; +type PricingByProvider = Record; +export type PricingSource = "default" | "litellm" | "modelsDev" | "user"; +export type PricingSourceMap = Record>; + +function readPricingNamespace( + db: ReturnType, + namespace: string +): PricingByProvider { + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = ?").all(namespace); + const pricing: PricingByProvider = {}; + + for (const row of rows) { + const record = toRecord(row); + const key = typeof record.key === "string" ? record.key : null; + const rawValue = typeof record.value === "string" ? record.value : null; + if (!key || rawValue === null) continue; + + try { + pricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels; + } catch { + // Corrupted data — skip silently, fallback to lower layers + } + } + + return pricing; +} + +function mergePricingLayers(layers: PricingByProvider[]): PricingByProvider { + const mergedPricing: PricingByProvider = {}; + + for (const layer of layers) { + for (const [provider, models] of Object.entries(layer)) { + if (!mergedPricing[provider]) { + mergedPricing[provider] = { ...models }; + continue; + } + + for (const [model, pricing] of Object.entries(models)) { + mergedPricing[provider][model] = mergedPricing[provider][model] + ? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) } + : pricing; + } + } + } + + return mergedPricing; +} + +function buildPricingSourceMap(layers: { + defaults: PricingByProvider; + litellm: PricingByProvider; + modelsDev: PricingByProvider; + user: PricingByProvider; +}): PricingSourceMap { + const sourceMap: PricingSourceMap = {}; + const mergedPricing = mergePricingLayers([ + layers.defaults, + layers.litellm, + layers.modelsDev, + layers.user, + ]); + + for (const [provider, models] of Object.entries(mergedPricing)) { + sourceMap[provider] = {}; + + for (const model of Object.keys(models)) { + if (layers.user[provider]?.[model]) { + sourceMap[provider][model] = "user"; + } else if (layers.modelsDev[provider]?.[model]) { + sourceMap[provider][model] = "modelsDev"; + } else if (layers.litellm[provider]?.[model]) { + sourceMap[provider][model] = "litellm"; + } else { + sourceMap[provider][model] = "default"; + } + } + } + + return sourceMap; +} + +async function getPricingLayers() { + const db = getDbInstance(); + + // Layer 1: Hardcoded defaults (lowest priority) + const { getDefaultPricing } = await import("@/shared/constants/pricing"); + return { + defaults: getDefaultPricing(), + litellm: readPricingNamespace(db, "pricing_synced"), + modelsDev: readPricingNamespace(db, "models_dev_pricing"), + user: readPricingNamespace(db, "pricing"), + }; +} + +export async function getPricing() { + const layers = await getPricingLayers(); + // Merge: defaults → LiteLLM → models.dev → user (each layer overrides the previous) + return mergePricingLayers([layers.defaults, layers.litellm, layers.modelsDev, layers.user]); +} + +export async function getPricingWithSources(): Promise<{ + pricing: PricingByProvider; + sourceMap: PricingSourceMap; +}> { + const layers = await getPricingLayers(); + return { + pricing: mergePricingLayers([layers.defaults, layers.litellm, layers.modelsDev, layers.user]), + sourceMap: buildPricingSourceMap(layers), + }; +} + +export async function getPricingForModel(provider: string, model: string) { + const pricing = await getPricing(); + + const findKeyInsensitive = ( + obj: Record | undefined | null, + key: string + ): T | undefined => { + if (!obj || !key) return undefined; + const lowerKey = key.toLowerCase(); + for (const [k, v] of Object.entries(obj)) { + if (k.toLowerCase() === lowerKey) return v; + } + return undefined; + }; + + const pLower = (provider || "").toLowerCase(); + let providerPricing = findKeyInsensitive(pricing, pLower); + + if (!providerPricing) { + const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower); + if (alias) providerPricing = findKeyInsensitive(pricing, alias); + } + + if (!providerPricing) { + for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { + if (typeof mappedAlias === "string" && mappedAlias.toLowerCase() === pLower) { + providerPricing = findKeyInsensitive(pricing, id); + if (providerPricing) break; + } + } + } + + if (!providerPricing) { + const np = pLower.replace(/-cn$/, ""); + if (np && np !== pLower) { + providerPricing = findKeyInsensitive(pricing, np); + } + } + + if (!providerPricing) return null; + + const mLower = (model || "").toLowerCase(); + let modelPricing = findKeyInsensitive(providerPricing, mLower); + + if (!modelPricing) { + const hyphenModel = mLower.replace(/\./g, "-"); + modelPricing = findKeyInsensitive(providerPricing, hyphenModel); + } + + return modelPricing || null; +} + +export async function updatePricing(pricingData: PricingByProvider) { + const db = getDbInstance(); + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('pricing', ?, ?)" + ); + + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + const existing: PricingByProvider = {}; + for (const row of rows) { + const record = toRecord(row); + const key = typeof record.key === "string" ? record.key : null; + const rawValue = typeof record.value === "string" ? record.value : null; + if (!key || rawValue === null) continue; + existing[key] = toRecord(JSON.parse(rawValue)) as PricingModels; + } + + const tx = db.transaction(() => { + for (const [provider, models] of Object.entries(pricingData)) { + insert.run(provider, JSON.stringify({ ...(existing[provider] || {}), ...models })); + } + }); + tx(); + backupDbFile("pre-write"); + invalidateDbCache("pricing"); // Bust the pricing read cache + const updated: PricingByProvider = {}; + const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + for (const row of allRows) { + const record = toRecord(row); + const key = typeof record.key === "string" ? record.key : null; + const rawValue = typeof record.value === "string" ? record.value : null; + if (!key || rawValue === null) continue; + updated[key] = toRecord(JSON.parse(rawValue)) as PricingModels; + } + return updated; +} + +export async function resetPricing(provider: string, model?: string) { + const db = getDbInstance(); + + if (model) { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'pricing' AND key = ?") + .get(provider); + if (row) { + const rowRecord = toRecord(row); + const value = typeof rowRecord.value === "string" ? rowRecord.value : "{}"; + const models = toRecord(JSON.parse(value)); + delete models[model]; + if (Object.keys(models).length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider); + } else { + db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'pricing' AND key = ?").run( + JSON.stringify(models), + provider + ); + } + } + } else { + db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider); + } + + backupDbFile("pre-write"); + const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + const result: Record = {}; + for (const row of allRows) { + const record = toRecord(row); + const key = typeof record.key === "string" ? record.key : null; + const rawValue = typeof record.value === "string" ? record.value : null; + if (!key || rawValue === null) continue; + result[key] = JSON.parse(rawValue); + } + return result; +} + +export async function resetAllPricing() { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = 'pricing'").run(); + backupDbFile("pre-write"); + return {}; +} diff --git a/src/lib/db/settings/shared.ts b/src/lib/db/settings/shared.ts new file mode 100644 index 0000000000..8b2b068b76 --- /dev/null +++ b/src/lib/db/settings/shared.ts @@ -0,0 +1,9 @@ +/** + * db/settings/shared.ts — Foundational types and helpers shared across settings leaf modules. + */ + +export type JsonRecord = Record; + +export function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" ? (value as JsonRecord) : {}; +} diff --git a/tests/unit/db-settings-split.test.ts b/tests/unit/db-settings-split.test.ts new file mode 100644 index 0000000000..88ca1cc203 --- /dev/null +++ b/tests/unit/db-settings-split.test.ts @@ -0,0 +1,90 @@ +/** + * Characterization test: settings.ts god-file decomposition. + * Verifies that: + * 1. toRecord in shared.ts has correct behavior (DB-free, pure function). + * 2. The host settings.ts still re-exports the full public API surface. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// ── 1. shared.ts — toRecord ────────────────────────────────────────────────── + +import { toRecord } from "../../src/lib/db/settings/shared.ts"; + +describe("toRecord", () => { + it("returns the object as-is when given a plain object", () => { + const obj = { a: 1, b: "two" }; + assert.deepStrictEqual(toRecord(obj), obj); + }); + + it("returns {} for null", () => { + assert.deepStrictEqual(toRecord(null), {}); + }); + + it("returns {} for undefined", () => { + assert.deepStrictEqual(toRecord(undefined), {}); + }); + + it("returns {} for a string", () => { + assert.deepStrictEqual(toRecord("hello"), {}); + }); + + it("returns {} for a number", () => { + assert.deepStrictEqual(toRecord(42), {}); + }); + + it("returns {} for an array (arrays are objects but toRecord returns the array cast)", () => { + // toRecord casts arrays as JsonRecord — they ARE objects, so the cast succeeds. + const arr = [1, 2, 3]; + assert.strictEqual(toRecord(arr), arr); + }); +}); + +// ── 2. settings.ts — public API surface ───────────────────────────────────── + +const settingsModule = await import("../../src/lib/db/settings.ts"); + +describe("settings.ts public API surface", () => { + const expectedFunctions = [ + // Settings core + "getSettings", + "updateSettings", + "isCloudEnabled", + // Proxy helpers (exported) + "bumpProxyConfigGeneration", + // Proxy config + "getProxyConfig", + "getProxyForLevel", + "setProxyForLevel", + "deleteProxyForLevel", + "resolveProxyForConnection", + "setProxyConfig", + // Pricing (re-exported from ./settings/pricing) + "getPricing", + "getPricingWithSources", + "getPricingForModel", + "updatePricing", + "resetPricing", + "resetAllPricing", + // LKGP (re-exported from ./settings/lkgp) + "getLKGP", + "setLKGP", + "clearAllLKGP", + // Cache metrics (re-exported from ./settings/cacheMetrics) + "getCacheMetrics", + "updateCacheMetrics", + "getCacheTrend", + "resetCacheMetrics", + ] as const; + + for (const name of expectedFunctions) { + it(`exports "${name}" as a function`, () => { + assert.strictEqual( + typeof (settingsModule as Record)[name], + "function", + `Expected "${name}" to be exported as a function` + ); + }); + } +});