import fs from "node:fs"; import path from "node:path"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; import { resolveDataDir } from "../dataPaths"; import { getCallLogPipelineMaxSizeBytes } from "../logEnv"; const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; const DATA_DIR = resolveDataDir({ isCloud }); export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs"); export const MAX_CALL_LOG_ARTIFACT_BYTES = 512 * 1024; const SIZE_LIMIT_EXCEEDED_REASON = "call_log_artifact_size_limit_exceeded"; const OMITTED_FOR_SIZE_LIMIT = "[omitted: call log artifact size limit exceeded]"; const STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT = "[stream chunks omitted: call log artifact size limit exceeded]"; export type CallLogDetailState = "none" | "ready" | "missing" | "corrupt" | "legacy-inline"; export type CallLogArtifact = { schemaVersion: 5; summary: { id: string; timestamp: string; method: string; path: string; status: number; model: string; requestedModel: string | null; provider: string; account: string; connectionId: string | null; duration: number; tokens: { in: number; out: number; cacheRead: number | null; cacheWrite: number | null; reasoning: number | null; compressed: number | null; }; requestType: string | null; sourceFormat: string | null; targetFormat: string | null; apiKeyId: string | null; apiKeyName: string | null; comboName: string | null; comboStepId: string | null; comboExecutionKey: string | null; }; requestBody: unknown; responseBody: unknown; error: unknown; pipeline?: RequestPipelinePayloads; }; export type CallLogArtifactWriteResult = { relPath: string; sizeBytes: number; sha256: string; }; export function buildArtifactRelativePath(timestamp: string, id: string) { const parsed = new Date(timestamp); const safeTimestamp = ( Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString() ).replace(/[:]/g, "-"); const dateFolder = safeTimestamp.slice(0, 10); return path.posix.join(dateFolder, `${safeTimestamp}_${id}.json`); } function computeArtifactChecksum(serialized: string): string { const bytes = Buffer.from(serialized); let hash = 0x811c9dc5; for (const byte of bytes) { hash ^= byte; hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(16).padStart(8, "0"); } function truncateArtifactForStorage(artifact: CallLogArtifact): CallLogArtifact { const pipeline = artifact.pipeline; if (!pipeline?.streamChunks) return artifact; return { ...artifact, pipeline: { ...pipeline, streamChunks: { provider: pipeline.streamChunks.provider?.length ? [STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT] : undefined, openai: pipeline.streamChunks.openai?.length ? [STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT] : undefined, client: pipeline.streamChunks.client?.length ? [STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT] : undefined, }, }, }; } function omitOversizedPipeline(artifact: CallLogArtifact): CallLogArtifact { if (!artifact.pipeline) return artifact; return { ...artifact, pipeline: { error: { _omniroute_truncated: true, reason: SIZE_LIMIT_EXCEEDED_REASON, }, }, }; } function getArtifactMaxBytes(artifact: CallLogArtifact): number { return artifact.pipeline ? getCallLogPipelineMaxSizeBytes() : MAX_CALL_LOG_ARTIFACT_BYTES; } function buildMinimalArtifactForSizeLimit(artifact: CallLogArtifact) { return { schemaVersion: artifact.schemaVersion, summary: artifact.summary, requestBody: OMITTED_FOR_SIZE_LIMIT, responseBody: OMITTED_FOR_SIZE_LIMIT, error: artifact.error ? OMITTED_FOR_SIZE_LIMIT : null, pipeline: { error: { _omniroute_truncated: true, reason: SIZE_LIMIT_EXCEEDED_REASON, }, }, }; } function serializeFinalSizeLimitFallback(artifact: CallLogArtifact, maxBytes: number): string { const withSummary = JSON.stringify(buildMinimalArtifactForSizeLimit(artifact)); if (Buffer.byteLength(withSummary) <= maxBytes) { return withSummary; } return JSON.stringify({ schemaVersion: artifact.schemaVersion, _omniroute_truncated: true, reason: SIZE_LIMIT_EXCEEDED_REASON, }); } function serializeArtifactForStorage(artifact: CallLogArtifact): string { const maxBytes = getArtifactMaxBytes(artifact); const serialized = JSON.stringify(artifact, null, 2); if (Buffer.byteLength(serialized) <= maxBytes) { return serialized; } const truncated = JSON.stringify(truncateArtifactForStorage(artifact), null, 2); if (Buffer.byteLength(truncated) <= maxBytes) { return truncated; } const withoutPipeline = JSON.stringify(omitOversizedPipeline(artifact), null, 2); if (Buffer.byteLength(withoutPipeline) <= maxBytes) { return withoutPipeline; } const minimal = JSON.stringify( { ...omitOversizedPipeline(artifact), requestBody: OMITTED_FOR_SIZE_LIMIT, responseBody: OMITTED_FOR_SIZE_LIMIT, error: artifact.error ? OMITTED_FOR_SIZE_LIMIT : null, }, null, 2 ); if (Buffer.byteLength(minimal) <= maxBytes) { return minimal; } return serializeFinalSizeLimitFallback(artifact, maxBytes); } export function writeCallArtifact( artifact: CallLogArtifact, relativePath = buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id) ): CallLogArtifactWriteResult | null { if (!CALL_LOGS_DIR || isBuildPhase) return null; const absPath = path.join(CALL_LOGS_DIR, relativePath); const tmpPath = `${absPath}.${process.pid}.${Date.now()}.tmp`; try { const serialized = serializeArtifactForStorage(artifact); const sizeBytes = Buffer.byteLength(serialized); // Keep the legacy field name for storage compatibility, but use a non-cryptographic checksum // so artifact bookkeeping is not treated as password hashing by static analysis. const fileChecksum = computeArtifactChecksum(serialized); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(tmpPath, serialized); fs.renameSync(tmpPath, absPath); return { relPath: relativePath, sizeBytes, sha256: fileChecksum, }; } catch (error) { try { fs.rmSync(tmpPath, { force: true }); } catch { // Best effort cleanup only. } console.error("[callLogs] Failed to write request artifact:", (error as Error).message); return null; } } export function readCallArtifact(relativePath: string | null): { artifact: CallLogArtifact | null; state: "ready" | "missing" | "corrupt"; } { if (!CALL_LOGS_DIR || !relativePath) { return { artifact: null, state: "missing" }; } try { const absPath = path.join(CALL_LOGS_DIR, relativePath); if (!fs.existsSync(absPath)) { return { artifact: null, state: "missing" }; } return { artifact: JSON.parse(fs.readFileSync(absPath, "utf8")) as CallLogArtifact, state: "ready", }; } catch (error) { console.error("[callLogs] Failed to read request artifact:", (error as Error).message); return { artifact: null, state: "corrupt" }; } } export function deleteCallArtifact(relativePath: string | null): boolean { if (!CALL_LOGS_DIR || !relativePath) return false; try { const absPath = path.join(CALL_LOGS_DIR, relativePath); if (!fs.existsSync(absPath)) return false; fs.rmSync(absPath, { force: true }); return true; } catch { return false; } } export function cleanupEmptyCallLogDirs(baseDir = CALL_LOGS_DIR) { if (!baseDir || !fs.existsSync(baseDir)) return; try { for (const entry of fs.readdirSync(baseDir)) { const entryPath = path.join(baseDir, entry); const stat = fs.statSync(entryPath); if (!stat.isDirectory()) continue; if (fs.readdirSync(entryPath).length === 0) { fs.rmSync(entryPath, { recursive: true, force: true }); } } } catch { // Best effort only. } } export function listCallLogArtifactFiles(baseDir = CALL_LOGS_DIR) { if (!baseDir || !fs.existsSync(baseDir)) return []; return fs .readdirSync(baseDir) .flatMap((entry) => { const entryPath = path.join(baseDir, entry); try { const stat = fs.statSync(entryPath); if (!stat.isDirectory()) return []; return fs .readdirSync(entryPath) .filter((file) => file.endsWith(".json")) .map((file) => { const absPath = path.join(entryPath, file); const fileStat = fs.statSync(absPath); return { relativePath: path.posix.join(entry, file), absPath, mtimeMs: fileStat.mtimeMs, }; }); } catch { return []; } }) .sort((a, b) => b.mtimeMs - a.mtimeMs); }