mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
Release v3.8.32 — see CHANGELOG.md [3.8.32] for the full list. Merged via --admin over documented non-blocking checks: CodeQL alerts ratchet (#665 fixed by #4457/#4462, auto-closes on main rescan), Integration Tests (env-flaky batch-upstream), SonarCloud/SonarQube (advisory new-code).
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
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, isChatDebugFileEnabled } 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 type PurgeCallLogArtifactDirectoryResult = {
|
|
deletedArtifacts: number;
|
|
errors: number;
|
|
};
|
|
|
|
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 {
|
|
// Debug mode: write full untruncated payload
|
|
if (isChatDebugFileEnabled()) {
|
|
return JSON.stringify(artifact, null, 2);
|
|
}
|
|
|
|
const maxBytes = getArtifactMaxBytes(artifact);
|
|
// Single-pass, non-pretty serialization on the hot path. Artifacts are machine-read via
|
|
// JSON.parse (readCallArtifact), so pretty-printing only doubled the bytes and CPU of
|
|
// serializing large request/response bodies on every request — a contributor to the
|
|
// CPU-runaway. The debug path above keeps pretty output for human inspection.
|
|
const serialized = JSON.stringify(artifact);
|
|
if (Buffer.byteLength(serialized) <= maxBytes) {
|
|
return serialized;
|
|
}
|
|
|
|
const truncated = JSON.stringify(truncateArtifactForStorage(artifact));
|
|
if (Buffer.byteLength(truncated) <= maxBytes) {
|
|
return truncated;
|
|
}
|
|
|
|
const withoutPipeline = JSON.stringify(omitOversizedPipeline(artifact));
|
|
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,
|
|
});
|
|
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);
|
|
}
|
|
|
|
export function purgeCallLogArtifactDirectory(
|
|
baseDir = CALL_LOGS_DIR
|
|
): PurgeCallLogArtifactDirectoryResult {
|
|
const result = { deletedArtifacts: 0, errors: 0 };
|
|
if (!baseDir || !fs.existsSync(baseDir)) return result;
|
|
|
|
try {
|
|
result.deletedArtifacts = listCallLogArtifactFiles(baseDir).length;
|
|
} catch {
|
|
result.deletedArtifacts = 0;
|
|
}
|
|
|
|
try {
|
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error("[callLogArtifacts] Failed to purge call log artifacts:", error);
|
|
result.deletedArtifacts = 0;
|
|
result.errors++;
|
|
}
|
|
|
|
return result;
|
|
}
|