mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
277 lines
8.2 KiB
TypeScript
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),
|
|
}));
|
|
}
|