Files
OmniRoute/src/lib/db/reasoningCache.ts
mi 94682d2a76 perf(ram): reduce server memory footprint (#2903)
Integrated into release/v3.8.7
2026-05-29 13:21:36 -03:00

277 lines
8.2 KiB
TypeScript

/**
* reasoningCache.ts — DB domain module for the Reasoning Replay Cache.
*
* Persists reasoning_content from thinking-mode models (DeepSeek V4, Kimi K2,
* Qwen-Thinking, etc.) keyed by tool_call_id. Used for crash recovery and
* dashboard visibility. The hot path uses the in-memory cache in
* open-sse/services/reasoningCache.ts; this module is the persistence layer.
*
* @see Issue #1628
*/
import { getDbInstance } from "./core";
// ──────────────── Types ────────────────
export interface ReasoningCacheEntry {
toolCallId: string;
provider: string;
model: string;
reasoning: string;
charCount: number;
createdAt: string;
expiresAt: string;
}
export interface ReasoningCacheStats {
totalEntries: number;
totalChars: number;
byProvider: Record<string, { entries: number; chars: number }>;
byModel: Record<string, { entries: number; chars: number }>;
oldestEntry: string | null;
newestEntry: string | null;
}
// ──────────────── Constants ────────────────
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
function toUnixEpochSeconds(dateMs: number): number {
return Math.floor(dateMs / 1000);
}
const EXPIRES_AT_EPOCH_SQL = `COALESCE(
CASE
WHEN typeof(expires_at) IN ('integer', 'real') THEN CAST(expires_at AS INTEGER)
WHEN typeof(expires_at) = 'text' AND expires_at <> '' AND expires_at NOT GLOB '*[^0-9]*'
THEN CAST(expires_at AS INTEGER)
ELSE unixepoch(expires_at)
END,
0
)`;
function epochSecondsToIso(value: number | string): string {
const text = String(value);
const seconds =
typeof value === "number" || (text !== "" && !/[^0-9]/.test(text))
? Number.parseInt(text, 10)
: NaN;
if (Number.isFinite(seconds) && seconds > 0) {
return new Date(seconds * 1000).toISOString();
}
const parsedMs = Date.parse(text);
if (Number.isFinite(parsedMs)) {
return new Date(parsedMs).toISOString();
}
return String(value);
}
const MAX_ENTRY_BYTES = 10000;
// ──────────────── CRUD ────────────────
/**
* Store a reasoning_content entry for a given tool_call_id.
* Uses INSERT OR REPLACE to handle duplicate tool_call_ids gracefully.
*/
export function setReasoningCache(
toolCallId: string,
provider: string,
model: string,
reasoning: string,
ttlMs: number = DEFAULT_TTL_MS
): void {
if (reasoning.length > MAX_ENTRY_BYTES) {
reasoning = reasoning.slice(0, MAX_ENTRY_BYTES);
}
const db = getDbInstance();
const expiresAt = toUnixEpochSeconds(Date.now() + ttlMs);
const charCount = reasoning.length;
db.prepare(
`INSERT OR REPLACE INTO reasoning_cache
(tool_call_id, provider, model, reasoning, char_count, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), ?)`
).run(toolCallId, provider, model, reasoning, charCount, expiresAt);
}
/**
* Retrieve a cached reasoning_content by tool_call_id.
* Returns null if not found or expired.
*/
export function getReasoningCache(
toolCallId: string
): { reasoning: string; provider: string; model: string } | null {
const db = getDbInstance();
const row = db
.prepare(
`SELECT reasoning, provider, model FROM reasoning_cache
WHERE tool_call_id = ? AND ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')`
)
.get(toolCallId) as { reasoning: string; provider: string; model: string } | undefined;
return row ?? null;
}
/**
* Delete a specific reasoning cache entry.
*/
export function deleteReasoningCache(toolCallId: string): number {
const db = getDbInstance();
const result = db.prepare(`DELETE FROM reasoning_cache WHERE tool_call_id = ?`).run(toolCallId);
return result.changes;
}
/**
* Delete all expired entries. Returns count of rows removed.
*/
export function cleanupExpiredReasoning(): number {
const db = getDbInstance();
const result = db
.prepare(`DELETE FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} <= unixepoch('now')`)
.run();
return result.changes;
}
/**
* Delete all entries, optionally filtered by provider.
* Returns count of rows removed.
*/
export function clearAllReasoningCache(provider?: string): number {
const db = getDbInstance();
if (provider) {
const result = db.prepare(`DELETE FROM reasoning_cache WHERE provider = ?`).run(provider);
return result.changes;
}
const result = db.prepare(`DELETE FROM reasoning_cache`).run();
return result.changes;
}
// ──────────────── Stats ────────────────
/**
* Get aggregate statistics for the reasoning cache.
*/
export function getReasoningCacheStats(): ReasoningCacheStats {
const db = getDbInstance();
// Total counts
const totals = db
.prepare(
`SELECT COUNT(*) as total_entries, COALESCE(SUM(char_count), 0) as total_chars
FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')`
)
.get() as { total_entries: number; total_chars: number };
// By provider
const providerRows = db
.prepare(
`SELECT provider, COUNT(*) as entries, COALESCE(SUM(char_count), 0) as chars
FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')
GROUP BY provider ORDER BY entries DESC`
)
.all() as { provider: string; entries: number; chars: number }[];
const byProvider: Record<string, { entries: number; chars: number }> = {};
for (const row of providerRows) {
byProvider[row.provider] = { entries: row.entries, chars: row.chars };
}
// By model
const modelRows = db
.prepare(
`SELECT model, COUNT(*) as entries, COALESCE(SUM(char_count), 0) as chars
FROM reasoning_cache WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')
GROUP BY model ORDER BY entries DESC`
)
.all() as { model: string; entries: number; chars: number }[];
const byModel: Record<string, { entries: number; chars: number }> = {};
for (const row of modelRows) {
byModel[row.model] = { entries: row.entries, chars: row.chars };
}
// Oldest/newest
const oldest = db
.prepare(
`SELECT created_at FROM reasoning_cache
WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now') ORDER BY created_at ASC LIMIT 1`
)
.get() as { created_at: string } | undefined;
const newest = db
.prepare(
`SELECT created_at FROM reasoning_cache
WHERE ${EXPIRES_AT_EPOCH_SQL} > unixepoch('now') ORDER BY created_at DESC LIMIT 1`
)
.get() as { created_at: string } | undefined;
return {
totalEntries: totals.total_entries,
totalChars: totals.total_chars,
byProvider,
byModel,
oldestEntry: oldest?.created_at ?? null,
newestEntry: newest?.created_at ?? null,
};
}
// ──────────────── Paginated Entries ────────────────
/**
* List reasoning cache entries with optional filters and pagination.
*/
export function getReasoningCacheEntries(
opts: {
limit?: number;
offset?: number;
provider?: string;
model?: string;
} = {}
): ReasoningCacheEntry[] {
const db = getDbInstance();
const limit = Math.min(opts.limit ?? 50, 200);
const offset = opts.offset ?? 0;
const conditions: string[] = [`${EXPIRES_AT_EPOCH_SQL} > unixepoch('now')`];
const params: unknown[] = [];
if (opts.provider) {
conditions.push("provider = ?");
params.push(opts.provider);
}
if (opts.model) {
conditions.push("model = ?");
params.push(opts.model);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = db
.prepare(
`SELECT tool_call_id, provider, model, reasoning, char_count, created_at, expires_at
FROM reasoning_cache ${where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as {
tool_call_id: string;
provider: string;
model: string;
reasoning: string;
char_count: number;
created_at: string;
expires_at: number | string;
}[];
return rows.map((row) => ({
toolCallId: row.tool_call_id,
provider: row.provider,
model: row.model,
reasoning: row.reasoning,
charCount: row.char_count,
createdAt: row.created_at,
expiresAt: epochSecondsToIso(row.expires_at),
}));
}