mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
refactor(db): extract pricing/lkgp/cache-metrics from db/settings.ts into leaf modules (#5709)
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).
This commit is contained in:
committed by
GitHub
parent
cf90ff11f6
commit
2787449f57
@@ -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<string, unknown>;
|
||||
type PricingModels = Record<string, JsonRecord>;
|
||||
type PricingByProvider = Record<string, PricingModels>;
|
||||
export type PricingSource = "default" | "litellm" | "modelsDev" | "user";
|
||||
export type PricingSourceMap = Record<string, Record<string, PricingSource>>;
|
||||
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<typeof getDbInstance>,
|
||||
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 = <T>(
|
||||
obj: Record<string, T> | 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<PricingModels>(pricing, pLower);
|
||||
|
||||
if (!providerPricing) {
|
||||
const alias = findKeyInsensitive<string>(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<JsonRecord>(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<string, unknown> = {};
|
||||
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<LKGPRecord | null> {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
// 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<CacheTrendPoint[]> {
|
||||
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";
|
||||
|
||||
235
src/lib/db/settings/cacheMetrics.ts
Normal file
235
src/lib/db/settings/cacheMetrics.ts
Normal file
@@ -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<string, unknown>) {
|
||||
// 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<CacheTrendPoint[]> {
|
||||
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();
|
||||
}
|
||||
49
src/lib/db/settings/lkgp.ts
Normal file
49
src/lib/db/settings/lkgp.ts
Normal file
@@ -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<LKGPRecord | null> {
|
||||
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();
|
||||
}
|
||||
254
src/lib/db/settings/pricing.ts
Normal file
254
src/lib/db/settings/pricing.ts
Normal file
@@ -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<string, JsonRecord>;
|
||||
type PricingByProvider = Record<string, PricingModels>;
|
||||
export type PricingSource = "default" | "litellm" | "modelsDev" | "user";
|
||||
export type PricingSourceMap = Record<string, Record<string, PricingSource>>;
|
||||
|
||||
function readPricingNamespace(
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
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 = <T>(
|
||||
obj: Record<string, T> | 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<PricingModels>(pricing, pLower);
|
||||
|
||||
if (!providerPricing) {
|
||||
const alias = findKeyInsensitive<string>(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<JsonRecord>(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<string, unknown> = {};
|
||||
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 {};
|
||||
}
|
||||
9
src/lib/db/settings/shared.ts
Normal file
9
src/lib/db/settings/shared.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* db/settings/shared.ts — Foundational types and helpers shared across settings leaf modules.
|
||||
*/
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" ? (value as JsonRecord) : {};
|
||||
}
|
||||
90
tests/unit/db-settings-split.test.ts
Normal file
90
tests/unit/db-settings-split.test.ts
Normal file
@@ -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<string, unknown>)[name],
|
||||
"function",
|
||||
`Expected "${name}" to be exported as a function`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user