mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
Introduce a runtime settings layer that hydrates persisted config at startup and reapplies aliases, payload rules, cache behavior, CLI compatibility, usage tuning, and related switches when settings change or SQLite updates. Replace the legacy prompt injection middleware path with a guardrail registry that supports prompt injection detection, PII masking, disabled guardrail overrides, and post-call response handling across the chat pipeline. Add a metadata registry for model catalog and alias resolution so catalog endpoints return enriched capabilities plus diagnostic headers and typed alias errors instead of ad hoc responses. Convert unsupported built-in web_search tools into an OmniRoute fallback tool, execute them through builtin skills, and preserve Responses API function call output with sanitized usage fields. Centralize provider header fingerprints for GitHub, Cursor, Qwen, Qoder, Kiro, and Antigravity, and migrate management passwords from env or plaintext storage into persisted bcrypt hashes during startup and login.
777 lines
26 KiB
TypeScript
777 lines
26 KiB
TypeScript
/**
|
|
* db/settings.js — Settings, pricing, and proxy config.
|
|
*/
|
|
|
|
import { getDbInstance } from "./core";
|
|
import { backupDbFile } from "./backup";
|
|
import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts";
|
|
import { invalidateDbCache } from "./readCache";
|
|
import { resolveProxyForConnectionFromRegistry } from "./proxies";
|
|
import { getComboModelProvider as getComboEntryProvider } from "@/lib/combos/steps";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type PricingModels = Record<string, JsonRecord>;
|
|
type PricingByProvider = Record<string, PricingModels>;
|
|
type ProxyValue = JsonRecord | string | null;
|
|
type ProxyMap = Record<string, ProxyValue>;
|
|
|
|
interface ProxyConfig {
|
|
global: ProxyValue;
|
|
providers: ProxyMap;
|
|
combos: ProxyMap;
|
|
keys: ProxyMap;
|
|
[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) : {};
|
|
}
|
|
|
|
function toProxyValue(value: unknown): ProxyValue {
|
|
if (value === null || typeof value === "string") return value as string | null;
|
|
if (value && typeof value === "object") return value as JsonRecord;
|
|
return null;
|
|
}
|
|
|
|
// ──────────────── Settings ────────────────
|
|
|
|
export async function getSettings() {
|
|
const db = getDbInstance();
|
|
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all();
|
|
const settings: Record<string, unknown> = {
|
|
cloudEnabled: false,
|
|
stickyRoundRobinLimit: 3,
|
|
requestRetry: 3,
|
|
maxRetryIntervalSec: 30,
|
|
antigravitySignatureCacheMode: "enabled",
|
|
requireLogin: true,
|
|
hiddenSidebarItems: [],
|
|
alwaysPreserveClientCache: "auto",
|
|
idempotencyWindowMs: 5000,
|
|
wsAuth: false,
|
|
};
|
|
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;
|
|
settings[key] = JSON.parse(rawValue);
|
|
}
|
|
|
|
// Auto-complete onboarding for pre-configured deployments (Docker/VM)
|
|
// If INITIAL_PASSWORD is set via env, this is a headless deploy — skip the wizard
|
|
if (!settings.setupComplete && process.env.INITIAL_PASSWORD) {
|
|
settings.setupComplete = true;
|
|
settings.requireLogin = true;
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'setupComplete', 'true')"
|
|
).run();
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'requireLogin', 'true')"
|
|
).run();
|
|
}
|
|
|
|
return settings;
|
|
}
|
|
|
|
export async function updateSettings(updates: Record<string, unknown>) {
|
|
const db = getDbInstance();
|
|
const insert = db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
|
|
);
|
|
const tx = db.transaction(() => {
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
insert.run(key, JSON.stringify(value));
|
|
}
|
|
});
|
|
tx();
|
|
backupDbFile("pre-write");
|
|
invalidateDbCache("settings"); // Bust the read cache immediately
|
|
const nextSettings = await getSettings();
|
|
|
|
try {
|
|
const { applyRuntimeSettings } = await import("@/lib/config/runtimeSettings");
|
|
await applyRuntimeSettings(nextSettings, { source: "settings:update" });
|
|
} catch (error) {
|
|
console.warn(
|
|
"[HOT_RELOAD] Failed to apply runtime settings after update:",
|
|
error instanceof Error ? error.message : error
|
|
);
|
|
}
|
|
|
|
return nextSettings;
|
|
}
|
|
|
|
export async function isCloudEnabled() {
|
|
const settings = await getSettings();
|
|
return settings.cloudEnabled === true;
|
|
}
|
|
|
|
// ──────────────── Pricing ────────────────
|
|
|
|
export async function getPricing() {
|
|
const db = getDbInstance();
|
|
|
|
// Layer 1: Hardcoded defaults (lowest priority)
|
|
const { getDefaultPricing } = await import("@/shared/constants/pricing");
|
|
const defaultPricing = getDefaultPricing();
|
|
|
|
// Layer 2: Synced external pricing from LiteLLM (middle-low priority)
|
|
const syncedRows = db
|
|
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
|
|
.all();
|
|
const syncedPricing: PricingByProvider = {};
|
|
for (const row of syncedRows) {
|
|
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;
|
|
syncedPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
|
}
|
|
|
|
// Layer 3: Synced pricing from models.dev (middle-high priority)
|
|
const modelsDevRows = db
|
|
.prepare("SELECT key, value FROM key_value WHERE namespace = 'models_dev_pricing'")
|
|
.all();
|
|
const modelsDevPricing: PricingByProvider = {};
|
|
for (const row of modelsDevRows) {
|
|
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 {
|
|
modelsDevPricing[key] = JSON.parse(rawValue) as PricingModels;
|
|
} catch {
|
|
// Corrupted data — skip silently, fallback to lower layers
|
|
}
|
|
}
|
|
|
|
// Layer 4: User overrides (highest priority)
|
|
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
|
const userPricing: 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;
|
|
userPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
|
}
|
|
|
|
// Merge: defaults → LiteLLM → models.dev → user (each layer overrides the previous)
|
|
const mergedPricing: PricingByProvider = {};
|
|
|
|
// Start with defaults
|
|
for (const [provider, models] of Object.entries(defaultPricing) as Array<[string, unknown]>) {
|
|
mergedPricing[provider] = { ...(toRecord(models) as PricingModels) };
|
|
}
|
|
|
|
// Layer synced (LiteLLM), then models.dev, then user on top
|
|
for (const layer of [syncedPricing, modelsDevPricing, userPricing]) {
|
|
for (const [provider, models] of Object.entries(layer)) {
|
|
if (!mergedPricing[provider]) {
|
|
mergedPricing[provider] = { ...models };
|
|
} else {
|
|
for (const [model, pricing] of Object.entries(models)) {
|
|
mergedPricing[provider][model] = mergedPricing[provider][model]
|
|
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
|
|
: pricing;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return mergedPricing;
|
|
}
|
|
|
|
export async function getPricingForModel(provider: string, model: string) {
|
|
const pricing = await getPricing();
|
|
if (pricing[provider]?.[model]) return pricing[provider][model];
|
|
|
|
const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels");
|
|
const alias = PROVIDER_ID_TO_ALIAS[provider];
|
|
if (alias && pricing[alias]) return pricing[alias][model] || null;
|
|
|
|
const np = provider?.replace(/-cn$/, "");
|
|
if (np && np !== provider && pricing[np]) return pricing[np][model] || null;
|
|
|
|
return 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 async function getLKGP(comboName: string, modelId: string): Promise<string | 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 {
|
|
return JSON.parse(row.value);
|
|
} catch {
|
|
return row.value;
|
|
}
|
|
}
|
|
|
|
export async function setLKGP(comboName: string, modelId: string, providerId: string) {
|
|
const db = getDbInstance();
|
|
const key = `${comboName}:${modelId}`;
|
|
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('lkgp', ?, ?)").run(
|
|
key,
|
|
JSON.stringify(providerId)
|
|
);
|
|
}
|
|
|
|
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: {} };
|
|
const ALIAS_TO_PROVIDER_ID = Object.entries(PROVIDER_ID_TO_ALIAS).reduce(
|
|
(acc, [providerId, alias]) => {
|
|
if (alias) acc[alias] = providerId;
|
|
acc[providerId] = providerId;
|
|
return acc;
|
|
},
|
|
{} as Record<string, string>
|
|
);
|
|
|
|
function resolveProviderAliasOrId(providerOrAlias: string): string {
|
|
if (typeof providerOrAlias !== "string") return providerOrAlias;
|
|
return ALIAS_TO_PROVIDER_ID[providerOrAlias] || providerOrAlias;
|
|
}
|
|
|
|
function getComboModelProvider(modelEntry: unknown): string | null {
|
|
const providerOrAlias = getComboEntryProvider(modelEntry);
|
|
return providerOrAlias ? resolveProviderAliasOrId(providerOrAlias) : null;
|
|
}
|
|
|
|
function migrateProxyEntry(value: unknown): JsonRecord | null {
|
|
if (!value) return null;
|
|
if (typeof value === "object") {
|
|
const record = toRecord(value);
|
|
if (record.type) return record;
|
|
}
|
|
if (typeof value !== "string") return null;
|
|
|
|
try {
|
|
const url = new URL(value);
|
|
return {
|
|
type: url.protocol.replace(":", "") || "http",
|
|
host: url.hostname,
|
|
port:
|
|
url.port ||
|
|
(url.protocol === "socks5:" ? "1080" : url.protocol === "https:" ? "443" : "8080"),
|
|
username: url.username ? decodeURIComponent(url.username) : "",
|
|
password: url.password ? decodeURIComponent(url.password) : "",
|
|
};
|
|
} catch {
|
|
const parts = value.split(":");
|
|
return {
|
|
type: "http",
|
|
host: parts[0] || value,
|
|
port: parts[1] || "8080",
|
|
username: "",
|
|
password: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function getProxyConfig() {
|
|
const db = getDbInstance();
|
|
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'proxyConfig'").all();
|
|
|
|
const raw: ProxyConfig = { ...DEFAULT_PROXY_CONFIG };
|
|
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;
|
|
raw[key] = JSON.parse(rawValue);
|
|
}
|
|
|
|
let migrated = false;
|
|
if (raw.global && typeof raw.global === "string") {
|
|
raw.global = migrateProxyEntry(raw.global);
|
|
migrated = true;
|
|
}
|
|
if (raw.providers) {
|
|
for (const [k, v] of Object.entries(raw.providers)) {
|
|
if (typeof v === "string") {
|
|
raw.providers[k] = migrateProxyEntry(v);
|
|
migrated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (migrated) {
|
|
const insert = db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)"
|
|
);
|
|
if (raw.global !== undefined) insert.run("global", JSON.stringify(raw.global));
|
|
if (raw.providers) insert.run("providers", JSON.stringify(raw.providers));
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
export async function getProxyForLevel(level: string, id?: string | null) {
|
|
const config = await getProxyConfig();
|
|
if (level === "global") return config.global || null;
|
|
const map = toProxyMap(config[level + "s"] || config[level] || {});
|
|
return (id ? map[id] : null) || null;
|
|
}
|
|
|
|
export async function setProxyForLevel(level: string, id: string | null, proxy: ProxyValue) {
|
|
const db = getDbInstance();
|
|
const config = await getProxyConfig();
|
|
|
|
if (level === "global") {
|
|
config.global = proxy || null;
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', 'global', ?)"
|
|
).run(JSON.stringify(config.global));
|
|
} else {
|
|
const mapKey = level + "s";
|
|
const map = toProxyMap(config[mapKey] || {});
|
|
if (proxy && id) {
|
|
map[id] = proxy;
|
|
} else {
|
|
if (id) delete map[id];
|
|
}
|
|
config[mapKey] = map;
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)"
|
|
).run(mapKey, JSON.stringify(map));
|
|
}
|
|
|
|
backupDbFile("pre-write");
|
|
return config;
|
|
}
|
|
|
|
export async function deleteProxyForLevel(level: string, id: string | null) {
|
|
return setProxyForLevel(level, id, null);
|
|
}
|
|
|
|
export async function resolveProxyForConnection(connectionId: string) {
|
|
const registryResolved = await resolveProxyForConnectionFromRegistry(connectionId);
|
|
if (registryResolved?.proxy) {
|
|
return registryResolved;
|
|
}
|
|
|
|
const config = await getProxyConfig();
|
|
|
|
if (connectionId && config.keys?.[connectionId]) {
|
|
return { proxy: config.keys[connectionId], level: "key", levelId: connectionId };
|
|
}
|
|
|
|
const db = getDbInstance();
|
|
const connection = db
|
|
.prepare("SELECT provider FROM provider_connections WHERE id = ?")
|
|
.get(connectionId);
|
|
|
|
if (connection) {
|
|
const connectionRecord = toRecord(connection);
|
|
const provider =
|
|
typeof connectionRecord.provider === "string" ? connectionRecord.provider : null;
|
|
if (config.combos && Object.keys(config.combos).length > 0) {
|
|
const combos = db.prepare("SELECT id, data FROM combos").all();
|
|
for (const comboRow of combos) {
|
|
const comboRecord = toRecord(comboRow);
|
|
const comboId = typeof comboRecord.id === "string" ? comboRecord.id : null;
|
|
if (comboId && config.combos[comboId]) {
|
|
try {
|
|
const comboRaw = typeof comboRecord.data === "string" ? comboRecord.data : null;
|
|
if (!comboRaw) continue;
|
|
const combo = toRecord(JSON.parse(comboRaw));
|
|
const comboModels = Array.isArray(combo.models) ? combo.models : [];
|
|
const usesProvider = comboModels.some(
|
|
(entry) => getComboModelProvider(entry) === provider
|
|
);
|
|
if (usesProvider) {
|
|
return { proxy: config.combos[comboId], level: "combo", levelId: comboId };
|
|
}
|
|
} catch {
|
|
// Ignore malformed combo records during proxy resolution.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (provider && config.providers?.[provider]) {
|
|
return {
|
|
proxy: config.providers[provider],
|
|
level: "provider",
|
|
levelId: provider,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (config.global) {
|
|
return { proxy: config.global, level: "global", levelId: null };
|
|
}
|
|
|
|
return { proxy: null, level: "direct", levelId: null };
|
|
}
|
|
|
|
export async function setProxyConfig(config: Record<string, unknown>) {
|
|
if (config.level !== undefined) {
|
|
const level = typeof config.level === "string" ? config.level : "global";
|
|
const id = typeof config.id === "string" ? config.id : null;
|
|
const proxy = (config.proxy as ProxyValue) || null;
|
|
return setProxyForLevel(level, id, proxy);
|
|
}
|
|
|
|
const db = getDbInstance();
|
|
const current = await getProxyConfig();
|
|
const insert = db.prepare(
|
|
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)"
|
|
);
|
|
|
|
const tx = db.transaction(() => {
|
|
if (config.global !== undefined) {
|
|
current.global = toProxyValue(config.global);
|
|
insert.run("global", JSON.stringify(current.global));
|
|
}
|
|
for (const mapKey of ["providers", "combos", "keys"]) {
|
|
if (config[mapKey]) {
|
|
const merged = { ...toProxyMap(current[mapKey]), ...toProxyMap(config[mapKey]) };
|
|
for (const [k, v] of Object.entries(merged)) {
|
|
if (!v) delete merged[k];
|
|
}
|
|
current[mapKey] = merged;
|
|
insert.run(mapKey, JSON.stringify(merged));
|
|
}
|
|
}
|
|
});
|
|
tx();
|
|
|
|
backupDbFile("pre-write");
|
|
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
|
|
|
|
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 strategy
|
|
// Since combo_strategy isn't tracked in usage_history yet, we use 'direct' for all requests
|
|
// TODO: Add combo_strategy column to usage_history for proper strategy tracking
|
|
const byStrategyRows = db
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
'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 'direct'
|
|
`
|
|
)
|
|
.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: cannot delete historical usage data
|
|
// Cache metrics are computed from usage_history, so they reflect actual request history
|
|
console.warn(
|
|
"resetCacheMetrics is deprecated - cache metrics are now computed from usage_history"
|
|
);
|
|
return getCacheMetrics();
|
|
}
|