mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
buildClientRawRequest deep-cloned the ENTIRE request body on every chat request, unbounded. On the #7847 incident payload (3.05 MiB, 729 messages, 86 tools) that retains 3.19 MiB per request, and it is pure waste: every consumer of clientRawRequest.body is observability and none of them keeps the full payload. chatCore.ts -> reqLogger.logClientRawRequest no-op when the logger is disabled, otherwise re-clones via cloneBoundedForLog (0.08 MiB) chatCore.ts -> trackPendingRequest clientRequest, surfaced by /api/logs/[id] chat.ts -> recordRejectedRequestUsage requestBody None feeds dispatch, translation or the upstream request, so the snapshot is now taken with cloneBoundedForLog: 3.19 MiB -> 0.08 MiB, a 41x reduction, and retention no longer scales with history length. It stays a clone rather than an alias because body is rewritten downstream (plugin onRequest hook, compression) and the log must show what the client actually sent. Bounding at the entry means the logger re-bounds an already-bounded value, which exposed that cloneBoundedForLog was NOT idempotent -- each container exceeded its own bound once the marker was added, so a second pass truncated again: arrays [marker, ...24 items] is 25 entries > 24, so the marker and one real item were dropped and originalLength was rewritten as 25 instead of the true 729 objects 80 keys + _omniroute_truncated_keys is 81 > 80, so a real key was evicted to make room for the marker and the dropped count was reported as 1 instead of 20 strings the marker was appended AFTER slicing to maxLength, so the bounded string was longer than the bound Without this the persisted log payload would have changed shape versus before the fix. All three now keep the marker inside the budget and treat an already-bounded value as final; verified end to end -- the marker still reports originalLength 729. TDD: tests/unit/repro-7847-bound-client-raw-request.test.ts was written first and failed on three assertions (unbounded retention, retention scaling with history, and the idempotence precondition) before either change.
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import { getPendingById } from "@/lib/usage/usageHistory";
|
|
import { sanitizeErrorMessage } from "./error.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
type HeaderInput =
|
|
| Headers
|
|
| Record<string, unknown>
|
|
| { entries?: () => IterableIterator<[string, string]> }
|
|
| null
|
|
| undefined;
|
|
|
|
export type RequestPipelinePayloads = {
|
|
routeDecision?: JsonRecord;
|
|
clientRawRequest?: JsonRecord;
|
|
openaiRequest?: JsonRecord;
|
|
providerRequest?: JsonRecord;
|
|
providerResponse?: JsonRecord;
|
|
clientResponse?: JsonRecord;
|
|
error?: JsonRecord;
|
|
streamChunks?: {
|
|
provider?: string[];
|
|
openai?: string[];
|
|
client?: string[];
|
|
};
|
|
};
|
|
|
|
type RequestLogger = {
|
|
sessionPath: null;
|
|
logClientRawRequest: (endpoint: unknown, body: unknown, headers?: HeaderInput) => void;
|
|
logRouteDecision: (decision: unknown) => void;
|
|
logOpenAIRequest: (body: unknown) => void;
|
|
logTargetRequest: (url: unknown, headers: HeaderInput, body: unknown) => void;
|
|
logProviderResponse: (
|
|
status: unknown,
|
|
statusText: unknown,
|
|
headers: HeaderInput,
|
|
body: unknown
|
|
) => void;
|
|
appendProviderChunk: (chunk: string) => void;
|
|
appendOpenAIChunk: (chunk: string) => void;
|
|
logConvertedResponse: (body: unknown) => void;
|
|
appendConvertedChunk: (chunk: string) => void;
|
|
logError: (error: unknown, requestBody?: unknown) => void;
|
|
getPipelinePayloads: () => RequestPipelinePayloads | null;
|
|
};
|
|
|
|
type RequestLoggerOptions = {
|
|
enabled?: boolean;
|
|
captureStreamChunks?: boolean;
|
|
maxStreamChunkBytes?: number;
|
|
maxStreamChunkItems?: number;
|
|
requestId?: string | null;
|
|
model?: string;
|
|
provider?: string;
|
|
connectionId?: string | null;
|
|
};
|
|
|
|
const DEFAULT_MAX_STREAM_CHUNK_BYTES = 128 * 1024;
|
|
const DEFAULT_MAX_STREAM_CHUNK_ITEMS = 10_240;
|
|
const MAX_LOG_STRING_LENGTH = 64 * 1024;
|
|
export const MAX_LOG_ARRAY_ITEMS = 24;
|
|
const MAX_LOG_OBJECT_KEYS = 80;
|
|
|
|
function maskSensitiveHeaders(headers: HeaderInput): Record<string, unknown> {
|
|
if (!headers) return {};
|
|
|
|
const headerEntries =
|
|
typeof (headers as Headers).entries === "function"
|
|
? Object.fromEntries((headers as Headers).entries())
|
|
: { ...(headers as Record<string, unknown>) };
|
|
|
|
const masked = { ...headerEntries };
|
|
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"];
|
|
|
|
for (const key of Object.keys(masked)) {
|
|
const lowerKey = key.toLowerCase();
|
|
// Whitelist x-ratelimit- headers from redaction
|
|
if (lowerKey.startsWith("x-ratelimit-")) {
|
|
continue;
|
|
}
|
|
if (!sensitiveKeys.some((candidate) => lowerKey.includes(candidate))) {
|
|
continue;
|
|
}
|
|
|
|
const value = masked[key];
|
|
if (typeof value === "string" && value.length > 20) {
|
|
masked[key] = `${value.slice(0, 10)}...${value.slice(-5)}`;
|
|
} else if (value) {
|
|
masked[key] = "[REDACTED]";
|
|
}
|
|
}
|
|
|
|
return masked;
|
|
}
|
|
|
|
function createEmptyStreamChunks() {
|
|
return {
|
|
provider: [] as string[],
|
|
openai: [] as string[],
|
|
client: [] as string[],
|
|
};
|
|
}
|
|
|
|
const TRUNCATED_ARRAY_MARKER = "_omniroute_truncated_array";
|
|
const TRUNCATED_KEYS_MARKER = "_omniroute_truncated_keys";
|
|
|
|
function isTruncatedArrayMarker(value: unknown): boolean {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
!Array.isArray(value) &&
|
|
(value as JsonRecord)[TRUNCATED_ARRAY_MARKER] === true
|
|
);
|
|
}
|
|
|
|
function truncateLogString(value: string, maxLength = MAX_LOG_STRING_LENGTH): string {
|
|
if (value.length <= maxLength) return value;
|
|
// The marker has to fit INSIDE the budget (#7847): keeping maxLength characters and then
|
|
// adding the marker produced a result longer than maxLength, so re-bounding an already
|
|
// bounded string truncated it a second time and the function was not idempotent.
|
|
const marker = `\n[...truncated ${value.length - maxLength} chars...]\n`;
|
|
const keep = Math.max(0, maxLength - marker.length);
|
|
return `${value.slice(0, Math.floor(keep / 2))}${marker}${value.slice(-Math.ceil(keep / 2))}`;
|
|
}
|
|
|
|
/**
|
|
* Recursively clone `value` for logging, with size bounds applied:
|
|
* - Arrays longer than MAX_LOG_ARRAY_ITEMS are truncated to the tail with a
|
|
* sentinel marker prepended.
|
|
* - The `tools` field is exempt from array truncation: the full tool inventory
|
|
* is debug-critical for understanding which tools the model had access to,
|
|
* and individual tool descriptions are independently bounded by
|
|
* truncateLogString, so the total size remains naturally capped.
|
|
*
|
|
* The optional `key` parameter carries the parent object's field name when
|
|
* recursing into an object's values, enabling the per-field exemption above.
|
|
* Top-level arrays (no key context) remain subject to truncation.
|
|
*/
|
|
export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null = null): unknown {
|
|
if (value === null || value === undefined) return value;
|
|
if (typeof value === "string") return truncateLogString(value);
|
|
if (typeof value !== "object") return value;
|
|
// Binary/opaque byte views (Uint8Array, Buffer, DataView, ...) are not
|
|
// "real" arrays to Array.isArray(); without this guard they fall through
|
|
// to the generic-object branch below and get expanded into one JS key per
|
|
// decoded byte instead of being treated as an opaque buffer (see #7297).
|
|
if (ArrayBuffer.isView(value)) {
|
|
return `[binary ${(value as ArrayBufferView).byteLength} bytes]`;
|
|
}
|
|
if (depth >= 6) return "[MaxDepth]";
|
|
|
|
if (Array.isArray(value)) {
|
|
// Idempotence (#7847): an already-bounded array is [marker, ...tail] — MAX_LOG_ARRAY_ITEMS + 1
|
|
// entries, which is over the limit. Re-truncating it would drop the marker plus one real
|
|
// item and rewrite originalLength with the truncated length (25 instead of the true 800), so
|
|
// the log would misreport how much was cut. Keep the original marker, re-bound only the tail.
|
|
if (isTruncatedArrayMarker(value[0])) {
|
|
return [value[0], ...value.slice(1).map((item) => cloneBoundedForLog(item, depth + 1))];
|
|
}
|
|
const exempt = key === "tools";
|
|
const shouldTruncate = !exempt && value.length > MAX_LOG_ARRAY_ITEMS;
|
|
const source = shouldTruncate ? value.slice(-MAX_LOG_ARRAY_ITEMS) : value;
|
|
const mapped = source.map((item) => cloneBoundedForLog(item, depth + 1));
|
|
if (shouldTruncate) {
|
|
return [
|
|
{
|
|
[TRUNCATED_ARRAY_MARKER]: true,
|
|
originalLength: value.length,
|
|
retainedTailItems: MAX_LOG_ARRAY_ITEMS,
|
|
},
|
|
...mapped,
|
|
];
|
|
}
|
|
return mapped;
|
|
}
|
|
|
|
const result: JsonRecord = {};
|
|
// Idempotence (#7847): our own marker key must not be counted as payload, or a re-bounded
|
|
// object would push a real key out to make room for it and report `1` dropped instead of 20.
|
|
const carriedDropped = (value as JsonRecord)[TRUNCATED_KEYS_MARKER];
|
|
const carried = typeof carriedDropped === "number" ? carriedDropped : 0;
|
|
const entries = Object.entries(value as JsonRecord).filter(
|
|
([k]) => !(carried > 0 && k === TRUNCATED_KEYS_MARKER)
|
|
);
|
|
for (const [k, item] of entries.slice(0, MAX_LOG_OBJECT_KEYS)) {
|
|
result[k] = cloneBoundedForLog(item, depth + 1, k);
|
|
}
|
|
const dropped = Math.max(0, entries.length - MAX_LOG_OBJECT_KEYS) + carried;
|
|
if (dropped > 0) {
|
|
result[TRUNCATED_KEYS_MARKER] = dropped;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function appendBoundedChunk(
|
|
chunks: string[],
|
|
bytes: { value: number; truncated: boolean },
|
|
chunk: string,
|
|
maxBytes: number,
|
|
maxItems = DEFAULT_MAX_STREAM_CHUNK_ITEMS
|
|
) {
|
|
if (typeof chunk !== "string" || chunk.length === 0) {
|
|
return;
|
|
}
|
|
if (chunks.length >= maxItems) {
|
|
bytes.truncated = true;
|
|
chunks[maxItems - 1] = `[stream chunk log truncated after ${maxItems} chunks]`;
|
|
return;
|
|
}
|
|
if (bytes.value >= maxBytes) {
|
|
bytes.truncated = true;
|
|
return;
|
|
}
|
|
|
|
const remaining = maxBytes - bytes.value;
|
|
if (chunk.length <= remaining) {
|
|
chunks.push(chunk);
|
|
bytes.value += chunk.length;
|
|
return;
|
|
}
|
|
|
|
chunks.push(chunk.slice(0, remaining));
|
|
if (chunks.length < maxItems) {
|
|
chunks.push(`[stream chunk log truncated after ${maxBytes} bytes]`);
|
|
}
|
|
bytes.value = maxBytes;
|
|
bytes.truncated = true;
|
|
}
|
|
|
|
function hasOwnValues(value: unknown): boolean {
|
|
return Boolean(value && typeof value === "object" && Object.keys(value as JsonRecord).length > 0);
|
|
}
|
|
|
|
function compactPipelinePayloads(
|
|
payloads: RequestPipelinePayloads
|
|
): RequestPipelinePayloads | null {
|
|
const result: RequestPipelinePayloads = {};
|
|
|
|
for (const [key, value] of Object.entries(payloads)) {
|
|
if (value === null || value === undefined) {
|
|
continue;
|
|
}
|
|
|
|
if (key === "streamChunks" && value && typeof value === "object") {
|
|
const chunkRecord = value as Record<string, unknown>;
|
|
const compactedChunks = Object.fromEntries(
|
|
Object.entries(chunkRecord).filter(
|
|
([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0
|
|
)
|
|
);
|
|
if (Object.keys(compactedChunks).length > 0) {
|
|
result.streamChunks = compactedChunks;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
result[key as keyof RequestPipelinePayloads] = value;
|
|
}
|
|
|
|
return hasOwnValues(result) ? result : null;
|
|
}
|
|
function makeStreamChunkMethods(options: RequestLoggerOptions, captureChunks: boolean) {
|
|
const streamChunks = createEmptyStreamChunks();
|
|
const streamChunkBytes = {
|
|
provider: { value: 0, truncated: false },
|
|
openai: { value: 0, truncated: false },
|
|
client: { value: 0, truncated: false },
|
|
};
|
|
const maxBytes =
|
|
Number.isInteger(options.maxStreamChunkBytes) && Number(options.maxStreamChunkBytes) > 0
|
|
? Number(options.maxStreamChunkBytes)
|
|
: DEFAULT_MAX_STREAM_CHUNK_BYTES;
|
|
const maxItems =
|
|
Number.isInteger(options.maxStreamChunkItems) && Number(options.maxStreamChunkItems) > 0
|
|
? Number(options.maxStreamChunkItems)
|
|
: DEFAULT_MAX_STREAM_CHUNK_ITEMS;
|
|
let pendingPushed = false;
|
|
|
|
const push = () => {
|
|
if (pendingPushed) return;
|
|
if (!options.requestId && (!options.connectionId || !options.model)) return;
|
|
pendingPushed = true;
|
|
try {
|
|
const pending = getPendingById();
|
|
const exactEntry = options.requestId ? pending.get(options.requestId) : null;
|
|
if (exactEntry) {
|
|
exactEntry.streamChunks = { ...streamChunks };
|
|
return;
|
|
}
|
|
|
|
for (const entry of pending.values()) {
|
|
if (
|
|
entry?.connectionId === options.connectionId &&
|
|
entry?.model === options.model &&
|
|
entry?.provider === (options.provider || "")
|
|
) {
|
|
entry.streamChunks = { ...streamChunks };
|
|
return;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Do not allow logging failures to disrupt request handling
|
|
try {
|
|
console.warn("[requestLogger] updatePendingRequestStreamChunks failed:", e);
|
|
} catch {}
|
|
}
|
|
};
|
|
|
|
const append = (arr: string[], bytes: { value: number; truncated: boolean }, chunk: string) => {
|
|
if (!captureChunks) return;
|
|
push();
|
|
const ts = new Date().toISOString().slice(11, 23);
|
|
appendBoundedChunk(arr, bytes, `[${ts}] ${chunk}`, maxBytes, maxItems);
|
|
};
|
|
|
|
return {
|
|
streamChunks,
|
|
streamChunkBytes,
|
|
appendProviderChunk(chunk: string) {
|
|
append(streamChunks.provider, streamChunkBytes.provider, chunk);
|
|
},
|
|
appendOpenAIChunk(chunk: string) {
|
|
append(streamChunks.openai, streamChunkBytes.openai, chunk);
|
|
},
|
|
appendConvertedChunk(chunk: string) {
|
|
append(streamChunks.client, streamChunkBytes.client, chunk);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function createRequestLogger(
|
|
_sourceFormat?: string,
|
|
_targetFormat?: string,
|
|
_model?: string,
|
|
options: RequestLoggerOptions = {}
|
|
): Promise<RequestLogger> {
|
|
const captureStreamChunks = options.captureStreamChunks !== false;
|
|
// Stream chunk capture is always set up — even when the logger is disabled,
|
|
// so that active requests always have real-time stream data available via
|
|
// the /api/logs/active endpoint.
|
|
const chunkMethods = makeStreamChunkMethods(options, captureStreamChunks);
|
|
|
|
if (options.enabled === false) {
|
|
let routeDecision: JsonRecord | null = null;
|
|
return {
|
|
sessionPath: null,
|
|
logClientRawRequest() {},
|
|
logRouteDecision(decision) {
|
|
routeDecision = cloneBoundedForLog(decision) as JsonRecord;
|
|
},
|
|
logOpenAIRequest() {},
|
|
logTargetRequest() {},
|
|
logProviderResponse() {},
|
|
appendProviderChunk: chunkMethods.appendProviderChunk,
|
|
appendOpenAIChunk: chunkMethods.appendOpenAIChunk,
|
|
logConvertedResponse() {},
|
|
appendConvertedChunk: chunkMethods.appendConvertedChunk,
|
|
logError() {},
|
|
getPipelinePayloads() {
|
|
return routeDecision ? { routeDecision } : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
const payloads: RequestPipelinePayloads = {
|
|
...(captureStreamChunks ? { streamChunks: chunkMethods.streamChunks } : {}),
|
|
};
|
|
|
|
return {
|
|
sessionPath: null,
|
|
|
|
logClientRawRequest(endpoint, body, headers = {}) {
|
|
payloads.clientRawRequest = {
|
|
timestamp: new Date().toISOString(),
|
|
endpoint,
|
|
headers: maskSensitiveHeaders(headers),
|
|
body: cloneBoundedForLog(body),
|
|
};
|
|
},
|
|
|
|
logRouteDecision(decision) {
|
|
payloads.routeDecision = cloneBoundedForLog(decision) as JsonRecord;
|
|
},
|
|
|
|
logOpenAIRequest(body) {
|
|
payloads.openaiRequest = {
|
|
timestamp: new Date().toISOString(),
|
|
body: cloneBoundedForLog(body),
|
|
};
|
|
},
|
|
|
|
logTargetRequest(url, headers, body) {
|
|
payloads.providerRequest = {
|
|
timestamp: new Date().toISOString(),
|
|
url,
|
|
headers: maskSensitiveHeaders(headers),
|
|
body: cloneBoundedForLog(body),
|
|
};
|
|
},
|
|
|
|
logProviderResponse(status, statusText, headers, body) {
|
|
payloads.providerResponse = {
|
|
timestamp: new Date().toISOString(),
|
|
status,
|
|
statusText,
|
|
headers: maskSensitiveHeaders(headers),
|
|
body: cloneBoundedForLog(body),
|
|
};
|
|
},
|
|
|
|
appendProviderChunk: chunkMethods.appendProviderChunk,
|
|
appendOpenAIChunk: chunkMethods.appendOpenAIChunk,
|
|
logConvertedResponse(body) {
|
|
payloads.clientResponse = {
|
|
timestamp: new Date().toISOString(),
|
|
body: cloneBoundedForLog(body),
|
|
};
|
|
},
|
|
appendConvertedChunk: chunkMethods.appendConvertedChunk,
|
|
|
|
logError(error, requestBody = null) {
|
|
payloads.error = {
|
|
timestamp: new Date().toISOString(),
|
|
error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
|
|
requestBody: cloneBoundedForLog(requestBody),
|
|
};
|
|
},
|
|
|
|
getPipelinePayloads() {
|
|
return compactPipelinePayloads(payloads);
|
|
},
|
|
};
|
|
}
|