Files
OmniRoute/open-sse/utils/requestLogger.ts
Diego Rodrigues de Sa e Souza 8169b97d84 Release v3.8.18 (#3482)
* chore(release): open v3.8.18 development cycle

* fix(catalog): stop Codex CLI model-catalog refresh from erroring (#3481)

Codex's model-catalog refresh (codex_models_manager) does
GET /v1/models?client_version=<v> and decodes a JSON object with a
TOP-LEVEL `models` array. OmniRoute answers in the OpenAI-standard
`{object,data}` shape, so codex fails with "missing field `models`"
and logs "failed to refresh available models" on every startup.

Detect codex clients via the `originator` / `user-agent` = `codex_*`
headers they send and add an EMPTY top-level `models: []` so the decode
succeeds. Non-codex OpenAI clients keep the byte-identical `{object,data}`
response.

The array is intentionally empty: codex replaces its built-in per-model
agent prompt (`base_instructions`, ~21k chars) with whatever a populated
entry carries for the selected model, so emitting our catalog would drop
the agent prompt to nothing and break codex's agent behaviour (verified
empirically against codex 0.137). An empty list keeps codex on its
built-in model info — same inference as before, minus the error.

Validated end-to-end with the real handler against codex 0.137:
"failed to refresh available models" → 0 occurrences, instructions
preserved (built-in Codex agent prompt, not empty).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: ignore quality reports and local prompt artifacts

Add generated quality gate reports, metrics files, and local setup prompt
artifacts to .gitignore to prevent committing environment-specific or
temporary files.

* fix(provider): detect Responses API format when body has `input` but … (#3490)

Integrated into release/v3.8.18

* fix(sse): normalize numeric provider ids to strings (#3451)

Integrated into release/v3.8.18

* feat(browserPool): resolve Playwright proxy from proxy_registry DB (#3492)

Integrated into release/v3.8.18

* fix(theoldllm): generate X-Request-Token server-side, drop Playwright (#3491)

Integrated into release/v3.8.18

* feat(plugins): add lifecycle hooks and theme-manager plugin (#3473)

Integrated into release/v3.8.18

* fix(combo): parallel pre-screen + circuit-breaker fast-exit for priority combos (#3169)

Integrated into release/v3.8.18

* feat(ui): unifi active and finished requests into single view #1422 (#3401)

Integrated into release/v3.8.18

* docs(changelog): record #3401, #3473, #3492, #3490, #3451, #3491, #3169 under v3.8.18

* feat(docs): add doc accuracy gate + refresh AGENTS.md counts (#3510)

Integrated into release/v3.8.18

* fix(sse): drop empty-choices chunks without usage instead of injecting retry text (#3513)

PR #3422 ('allow OpenAI usage-only empty choices chunks') reintroduced the
assistant-content injection '[OmniRoute] Upstream returned an empty response.
Please retry.' for empty `choices: []` chunks that carry no valid usage. Clients
(Goose/opencode) feed that text back as a turn and spin in a retry loop -- the
exact regression #3400 had fixed by dropping the chunk.

Restore the drop behavior for the no-usage case while preserving #3422's
standards-compliant forwarding of usage-only `include_usage` final chunks.
Realign the mislabeled stream-utils test (it asserted the injection) and add a
dedicated regression guard.

Reported-by: @mochizzan
Refs: #3502, #3388, #3400, #3422

* fix(authz): fall back to URL token when Authorization isn't a usable Bearer (#3504)

Integrated into release/v3.8.18

* fix(playground): authenticate via session, test key policy by id (#3503)

Integrated into release/v3.8.18

* docs(changelog): record #3510, #3504, #3503 under v3.8.18

* fix: llama base url normalization (#3519)

* docs(changelog): reconcile v3.8.18 — add #3519, #3513, #3435-repair, gitignore chore (full commit↔changelog coverage)

* fix(opencode-plugin): bound regex quantifiers in normaliseFreeLabel (polynomial-ReDoS)

CodeQL js/polynomial-redos: unbounded \s* before an anchored \s*$ allowed
O(n²) backtracking on attacker-influenced display names. Bounded to {0,8}/{1,8}
(ample for any real label spacing). Plugin builds + 254 tests green.

* fix(types): restore clean typecheck:core for v3.8.18 release gate

- getPendingRequests() typed to real shape (was widened to object) → fixes
  unknown 'count' in the unified-requests view (#3401)
- streamChunks log payload cast to its declared type (callLogs.ts)
- preScreenTargets aligned to canonical IsModelAvailable signature (#3169),
  Promise.resolve-normalized so .catch never hits a bare boolean

All 5 gates green: lint(0 err) + typecheck:core + cycles + docs-all + unit + vitest(146).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Andrey Borodulin <borodulin@gmail.com>
Co-authored-by: Dmitrii Safronov <zimniy@cyberbrain.cc>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
2026-06-09 15:56:24 -03:00

380 lines
12 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 = {
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;
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;
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[],
};
}
function truncateLogString(value: string, maxLength = MAX_LOG_STRING_LENGTH): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, Math.floor(maxLength / 2))}\n[...truncated ${value.length - maxLength} chars...]\n${value.slice(-Math.ceil(maxLength / 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;
if (depth >= 6) return "[MaxDepth]";
if (Array.isArray(value)) {
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 [
{
_omniroute_truncated_array: true,
originalLength: value.length,
retainedTailItems: MAX_LOG_ARRAY_ITEMS,
},
...mapped,
];
}
return mapped;
}
const result: JsonRecord = {};
const entries = Object.entries(value as JsonRecord);
for (const [k, item] of entries.slice(0, MAX_LOG_OBJECT_KEYS)) {
result[k] = cloneBoundedForLog(item, depth + 1, k);
}
if (entries.length > MAX_LOG_OBJECT_KEYS) {
result._omniroute_truncated_keys = entries.length - MAX_LOG_OBJECT_KEYS;
}
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.connectionId || !options.model) return;
pendingPushed = true;
try {
const pending = getPendingById();
for (const entry of pending.values()) {
if (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();
appendBoundedChunk(arr, bytes, 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) {
return {
sessionPath: null,
logClientRawRequest() {},
logOpenAIRequest() {},
logTargetRequest() {},
logProviderResponse() {},
appendProviderChunk: chunkMethods.appendProviderChunk,
appendOpenAIChunk: chunkMethods.appendOpenAIChunk,
logConvertedResponse() {},
appendConvertedChunk: chunkMethods.appendConvertedChunk,
logError() {},
getPipelinePayloads() { return 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),
};
},
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);
},
};
}