omniroute / src /lib /usage /callLogArtifacts.ts
igorkurgin's picture
Deploy OmniRoute to Hugging Face Space
35743bd verified
Raw
History Blame Contribute Delete
9.12 kB
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);
}