mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
Compare commits
2 Commits
fix/12783-
...
feat/video
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f71c4cc63 | ||
|
|
5fc9e37d22 |
@@ -360,7 +360,10 @@ import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity";
|
|||||||
import { getCacheControlSettings } from "@/lib/cacheControlSettings";
|
import { getCacheControlSettings } from "@/lib/cacheControlSettings";
|
||||||
import { guardrailRegistry } from "@/lib/guardrails";
|
import { guardrailRegistry } from "@/lib/guardrails";
|
||||||
import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge";
|
import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge";
|
||||||
import { logClientRawRequestRedacted } from "@/lib/guardrails/videoBridgeSnapshotRedaction";
|
import {
|
||||||
|
logClientRawRequestRedacted,
|
||||||
|
redactPendingBody,
|
||||||
|
} from "@/lib/guardrails/videoBridgeSnapshotRedaction";
|
||||||
import {
|
import {
|
||||||
shouldPreserveCacheControl,
|
shouldPreserveCacheControl,
|
||||||
resolveConnectionCacheOverride,
|
resolveConnectionCacheOverride,
|
||||||
@@ -928,7 +931,7 @@ export async function handleChatCore({
|
|||||||
const pendingRequestId =
|
const pendingRequestId =
|
||||||
trackPendingRequest(model, provider, pendingConnId, true, {
|
trackPendingRequest(model, provider, pendingConnId, true, {
|
||||||
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||||
clientRequest: clientRawRequest?.body ?? body,
|
clientRequest: redactPendingBody(clientRawRequest?.body ?? body, videoBridgeObserved),
|
||||||
providerRequest: initialProviderRequest,
|
providerRequest: initialProviderRequest,
|
||||||
stage: "registered",
|
stage: "registered",
|
||||||
correlationId,
|
correlationId,
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ import { attachLogMeta } from "./cacheUsageMeta.ts";
|
|||||||
* never touches a part whose text differs — see
|
* never touches a part whose text differs — see
|
||||||
* `tests/unit/video-bridge-log-redaction.test.ts`'s "Scenario A" test for the
|
* `tests/unit/video-bridge-log-redaction.test.ts`'s "Scenario A" test for the
|
||||||
* reproduction this fixes.
|
* reproduction this fixes.
|
||||||
|
*
|
||||||
|
* #12430 item 4 (P2c): a message's `content` can also be a plain STRING that
|
||||||
|
* embeds `fullText` as a SUBSTRING rather than an exact array part — derived
|
||||||
|
* dispatches (pipeline-strategy stages, smart-auto-pipeline, context-handoff
|
||||||
|
* summaries) all interpolate the transcript blob into a larger rendered
|
||||||
|
* prompt string before calling `handleSingleModel`. That string branch is
|
||||||
|
* mutually exclusive with the array branch (a message's `content` is one or
|
||||||
|
* the other, never both) and uses `String.prototype.replaceAll` against the
|
||||||
|
* trusted `fullText` literal to swap every occurrence — see
|
||||||
|
* `tests/unit/video-bridge-derived-prompt-redaction.test.ts`.
|
||||||
*/
|
*/
|
||||||
export function applyVideoBridgeLogRedaction(
|
export function applyVideoBridgeLogRedaction(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
@@ -78,6 +88,47 @@ export function applyVideoBridgeLogRedaction(
|
|||||||
const originalMessage = originalContainer[messageIndex];
|
const originalMessage = originalContainer[messageIndex];
|
||||||
if (!originalMessage || typeof originalMessage !== "object") continue;
|
if (!originalMessage || typeof originalMessage !== "object") continue;
|
||||||
const originalContent = (originalMessage as Record<string, unknown>).content;
|
const originalContent = (originalMessage as Record<string, unknown>).content;
|
||||||
|
|
||||||
|
// Derived-prompt dispatches (pipeline-strategy stages, smart-auto-pipeline,
|
||||||
|
// context-handoff summaries — #12430 item 4) embed the transcript as a
|
||||||
|
// SUBSTRING of a plain string `content`, e.g. a rendered stage prompt or a
|
||||||
|
// `{HISTORY}`-interpolated handoff summary, never as an exact array part.
|
||||||
|
// Mutually exclusive with the array branch below: a message's `content`
|
||||||
|
// is either a string or an array, never both, so this and the
|
||||||
|
// `Array.isArray` check never both match the same message.
|
||||||
|
if (typeof originalContent === "string") {
|
||||||
|
if (!originalContent.includes(fullText)) continue;
|
||||||
|
|
||||||
|
// Same lazy clone-on-write as the array branch: root -> container
|
||||||
|
// array -> this message. Siblings keep referencing the originals.
|
||||||
|
if (!rootClone) rootClone = { ...source };
|
||||||
|
let containerClone = clonedContainers.get(container);
|
||||||
|
if (!containerClone) {
|
||||||
|
containerClone = [...originalContainer];
|
||||||
|
clonedContainers.set(container, containerClone);
|
||||||
|
rootClone[container] = containerClone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageKey = `${container}:${messageIndex}`;
|
||||||
|
let messageClone = clonedMessages.get(messageKey);
|
||||||
|
if (!messageClone) {
|
||||||
|
messageClone = { ...(originalMessage as Record<string, unknown>) };
|
||||||
|
clonedMessages.set(messageKey, messageClone);
|
||||||
|
containerClone[messageIndex] = messageClone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read from the (possibly already-cloned) message so a second
|
||||||
|
// redaction entry matching the same string content composes with the
|
||||||
|
// first instead of clobbering it. `fullText` is a trusted literal
|
||||||
|
// (the `[Video description:...]` blob), so replaceAll(string, string)
|
||||||
|
// needs no regex and is safe. replaceAll (not replace): a stage/summary
|
||||||
|
// prompt can quote the transcript back more than once.
|
||||||
|
const currentText =
|
||||||
|
typeof messageClone.content === "string" ? messageClone.content : originalContent;
|
||||||
|
messageClone.content = currentText.replaceAll(fullText, redactedText);
|
||||||
|
redacted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!Array.isArray(originalContent)) continue;
|
if (!Array.isArray(originalContent)) continue;
|
||||||
|
|
||||||
for (let partIndex = 0; partIndex < originalContent.length; partIndex++) {
|
for (let partIndex = 0; partIndex < originalContent.length; partIndex++) {
|
||||||
|
|||||||
@@ -133,3 +133,17 @@ export function logClientRawRequestRedacted(
|
|||||||
clientRawRequest.headers
|
clientRawRequest.headers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call-site wrapper for the `clientRequest` field stored by `trackPendingRequest`
|
||||||
|
* (open-sse/handlers/chatCore.ts): the sibling in-memory leak to
|
||||||
|
* `logClientRawRequestRedacted` above — same raw body, but live-exposed via
|
||||||
|
* /api/usage/call-logs (pendingDetails), /api/logs/[id] and /api/conversations
|
||||||
|
* while the request is in-flight, not just in the persisted detailed-log
|
||||||
|
* snapshot. Identical observed/non-observed branching: a non-observed request
|
||||||
|
* keeps the exact same reference (no clone); an observed one gets the redacted
|
||||||
|
* clone.
|
||||||
|
*/
|
||||||
|
export function redactPendingBody(clientRequest: unknown, videoBridgeObserved: boolean): unknown {
|
||||||
|
return videoBridgeObserved ? redactVideoTranscriptFieldsForLog(clientRequest) : clientRequest;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { redactVideoTranscriptFieldsForLog } from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts";
|
import {
|
||||||
|
redactVideoTranscriptFieldsForLog,
|
||||||
|
redactPendingBody,
|
||||||
|
} from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts";
|
||||||
// Heavy import is fine here (test only, never in the production module under test) — used
|
// Heavy import is fine here (test only, never in the production module under test) — used
|
||||||
// solely to prove the local placeholder literal never drifts from the canonical P1 constant.
|
// solely to prove the local placeholder literal never drifts from the canonical P1 constant.
|
||||||
import { VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER } from "../../../src/lib/guardrails/videoBridgeHelpers.ts";
|
import { VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER } from "../../../src/lib/guardrails/videoBridgeHelpers.ts";
|
||||||
@@ -205,3 +208,57 @@ test("the redaction placeholder matches the canonical P1 constant (no drift)", (
|
|||||||
const part = contentAt(result, "messages", 0)[0];
|
const part = contentAt(result, "messages", 0)[0];
|
||||||
assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER);
|
assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #12430 item 6 (P2c): the sibling in-memory leak. `trackPendingRequest`
|
||||||
|
// (open-sse/handlers/chatCore.ts) stores the same raw client body under
|
||||||
|
// `clientRequest`, live-exposed via /api/usage/call-logs (pendingDetails),
|
||||||
|
// /api/logs/[id] and /api/conversations while the request is in-flight. This
|
||||||
|
// helper is the guarded call-site wrapper chatCore.ts uses, mirroring
|
||||||
|
// logClientRawRequestRedacted's observed/non-observed branching.
|
||||||
|
test("redactPendingBody: observed=true delegates to redactVideoTranscriptFieldsForLog", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "input_video",
|
||||||
|
video_url: "https://example.com/clip.mp4",
|
||||||
|
transcript: { cues: [{ text: "pending secret" }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = redactPendingBody(body, true);
|
||||||
|
assert.notEqual(
|
||||||
|
result,
|
||||||
|
body,
|
||||||
|
"observed path must return a new structure, not the same reference"
|
||||||
|
);
|
||||||
|
const part = contentAt(result, "messages", 0)[0];
|
||||||
|
assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER);
|
||||||
|
assert.ok(!JSON.stringify(result).includes("pending secret"));
|
||||||
|
assert.deepEqual(result, redactVideoTranscriptFieldsForLog(body));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redactPendingBody: observed=false returns the SAME reference unchanged", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "input_video",
|
||||||
|
video_url: "https://example.com/clip.mp4",
|
||||||
|
transcript: { cues: [{ text: "not observed" }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = redactPendingBody(body, false);
|
||||||
|
assert.equal(result, body, "non-observed path must return the exact same reference");
|
||||||
|
});
|
||||||
|
|||||||
184
tests/unit/video-bridge-derived-prompt-redaction.test.ts
Normal file
184
tests/unit/video-bridge-derived-prompt-redaction.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// tests/unit/video-bridge-derived-prompt-redaction.test.ts
|
||||||
|
// P2c of #12150/#12430 (Video Bridge transcript retention — derived-prompt
|
||||||
|
// dispatch logs, item 4).
|
||||||
|
//
|
||||||
|
// Seam trace finding (decisive): videoBridgeLog is ALREADY threaded end-to-end
|
||||||
|
// to every nested handleChatCore — pipeline-strategy stages
|
||||||
|
// (src/domain/pipeline.ts::executeStage), smart-auto-pipeline, and
|
||||||
|
// context-handoff summaries (open-sse/services/contextHandoff.ts) — because
|
||||||
|
// all of them dispatch through the single P1b `handleSingleModel` closure and
|
||||||
|
// terminate in the SAME handleChatCore -> persistAttemptLogs ->
|
||||||
|
// applyVideoBridgeLogRedaction logging path. No plumbing/param changes were
|
||||||
|
// needed anywhere.
|
||||||
|
//
|
||||||
|
// The gap this file proves closed: those derived dispatches embed the
|
||||||
|
// transcript as a SUBSTRING of a plain STRING `content` message —
|
||||||
|
// `{ role: "user", content: <rendered prompt string containing the
|
||||||
|
// "[Video description: ...]" blob> }` — built by executeStage()
|
||||||
|
// (pipeline.ts:196-199 via prompts.ts interpolation) and by the
|
||||||
|
// context-handoff summary builders (contextHandoff.ts:415/729, `{HISTORY}`
|
||||||
|
// template substitution). Before this fix, applyVideoBridgeLogRedaction only
|
||||||
|
// matched ARRAY-content parts by exact text (`part.text === fullText`), so it
|
||||||
|
// silently skipped these string-content messages and the raw transcript
|
||||||
|
// persisted in the stage/summary sub-request call logs.
|
||||||
|
//
|
||||||
|
// This suite calls the real, already-exported `applyVideoBridgeLogRedaction`
|
||||||
|
// (open-sse/handlers/chatCore/attemptLogging.ts) directly — it is a pure
|
||||||
|
// function (no DB), so no persistAttemptLogs/DB harness is needed here; that
|
||||||
|
// integration-level proof already lives in
|
||||||
|
// tests/unit/video-bridge-log-redaction.test.ts.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { applyVideoBridgeLogRedaction } from "../../open-sse/handlers/chatCore/attemptLogging.ts";
|
||||||
|
import type { VideoBridgeLogRedactionEntry } from "../../src/lib/guardrails/videoBridge.ts";
|
||||||
|
|
||||||
|
const SECRET = "secret words";
|
||||||
|
const FULL_TEXT = `[Video description: transcript[source=client] ${SECRET}]`;
|
||||||
|
const REDACTED_TEXT = "[Video description: transcript[source=client] [redacted-video-transcript]]";
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
overrides: Partial<VideoBridgeLogRedactionEntry> = {}
|
||||||
|
): VideoBridgeLogRedactionEntry {
|
||||||
|
return {
|
||||||
|
container: "messages",
|
||||||
|
messageIndex: 0,
|
||||||
|
partIndex: 0,
|
||||||
|
fullText: FULL_TEXT,
|
||||||
|
redactedText: REDACTED_TEXT,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("derived-prompt (pipeline stage): a string-content message with the transcript embedded as a substring is redacted, secret absent, surrounding prompt text intact", () => {
|
||||||
|
const body = {
|
||||||
|
model: "openai/gpt-x",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a summarization stage." },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Summarize the following context.\n\n${FULL_TEXT}\n\nEnd of context.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyVideoBridgeLogRedaction(body, [
|
||||||
|
entry({ messageIndex: 1, partIndex: 0 }),
|
||||||
|
]) as typeof body;
|
||||||
|
|
||||||
|
const redactedContent = result.messages[1].content;
|
||||||
|
assert.equal(
|
||||||
|
redactedContent,
|
||||||
|
`Summarize the following context.\n\n${REDACTED_TEXT}\n\nEnd of context.`
|
||||||
|
);
|
||||||
|
assert.ok(!redactedContent.includes(SECRET), "the raw transcript must not survive redaction");
|
||||||
|
assert.ok(
|
||||||
|
redactedContent.startsWith("Summarize the following context.\n\n"),
|
||||||
|
"surrounding prompt text before the blob must stay intact"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
redactedContent.endsWith("\n\nEnd of context."),
|
||||||
|
"surrounding prompt text after the blob must stay intact"
|
||||||
|
);
|
||||||
|
assert.equal(JSON.stringify(result).includes(SECRET), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derived-prompt (context-handoff summary): input container string content is redacted the same way as messages", () => {
|
||||||
|
const body = {
|
||||||
|
model: "openai/gpt-x",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Continue the conversation given this history.\n\n${FULL_TEXT}\n\nContinue now.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyVideoBridgeLogRedaction(body, [
|
||||||
|
entry({ container: "input", messageIndex: 0, partIndex: 0 }),
|
||||||
|
]) as typeof body;
|
||||||
|
|
||||||
|
const redactedContent = result.input[0].content;
|
||||||
|
assert.equal(
|
||||||
|
redactedContent,
|
||||||
|
`Continue the conversation given this history.\n\n${REDACTED_TEXT}\n\nContinue now.`
|
||||||
|
);
|
||||||
|
assert.ok(!redactedContent.includes(SECRET));
|
||||||
|
assert.equal(JSON.stringify(result).includes(SECRET), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple occurrences of fullText within the same string are ALL replaced (replaceAll, not replace)", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `First mention: ${FULL_TEXT}\n\nQuoted back for grounding: ${FULL_TEXT}\n\nDone.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]) as {
|
||||||
|
messages: Array<{ content: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const redactedContent = result.messages[0].content;
|
||||||
|
assert.equal(
|
||||||
|
redactedContent,
|
||||||
|
`First mention: ${REDACTED_TEXT}\n\nQuoted back for grounding: ${REDACTED_TEXT}\n\nDone.`
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
redactedContent.split(REDACTED_TEXT).length - 1,
|
||||||
|
2,
|
||||||
|
"both occurrences must be replaced"
|
||||||
|
);
|
||||||
|
assert.ok(!redactedContent.includes(SECRET));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regression: the existing ARRAY-content exact-part-match path still redacts (no regression from the new string branch)", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "sys" },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "look at this video" },
|
||||||
|
{ type: "text", text: FULL_TEXT },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 1, partIndex: 1 })]) as {
|
||||||
|
messages: Array<{ content: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = result.messages[1].content as Array<{ text: string }>;
|
||||||
|
assert.equal(content[1].text, REDACTED_TEXT);
|
||||||
|
assert.ok(!content[1].text.includes(SECRET));
|
||||||
|
assert.equal(content[0].text, "look at this video", "sibling part must stay untouched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no mutation of the input object: the caller's body is byte-identical after redaction (string-content path)", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: `before ${FULL_TEXT} after` }],
|
||||||
|
};
|
||||||
|
const snapshotBefore = JSON.parse(JSON.stringify(body));
|
||||||
|
|
||||||
|
applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]);
|
||||||
|
|
||||||
|
assert.deepEqual(body, snapshotBefore, "the original body must never be mutated");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-matching string content is returned unchanged, with the SAME root reference (nothing redacted -> no clone allocated)", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: "nothing to see here, no transcript blob at all" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
body,
|
||||||
|
"when no fullText matches, the exact same object reference is returned"
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user