mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
* fix(sse): defer enqueuing of event lines to align event names with data lines and prevent stop-signal event name misattribution * fix(sse): preserve keep-alives and prevent pending event leakage on dropped chunks * fix(sse): preserve pending event lines before other non-data lines and fix zero-window-size bypass * fix(sse): defer lastEventLine update until after flush check to preserve previous event context on flush * fix(sse): flush trailing pendingEventLine when stream closes * fix(sse): preserve consecutive event lines without intervening data --------- Co-authored-by: Ruslan Sivak <russ@ruslansivak.com>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import { createSseTextTransform, FieldCategory, getFieldCategory } from "./sseTextTransform";
|
|
import { sanitizePII } from "./piiSanitizer";
|
|
|
|
export interface PiiTransformOptions {
|
|
windowSize?: number;
|
|
}
|
|
|
|
export function createPiiSseTransform(options?: PiiTransformOptions): TransformStream {
|
|
const choiceBuffers = new Map<string, Record<FieldCategory, string>>();
|
|
|
|
const getBuffers = (index: string | number): Record<FieldCategory, string> => {
|
|
const key = String(index);
|
|
let buf = choiceBuffers.get(key);
|
|
if (!buf) {
|
|
buf = {
|
|
content: "",
|
|
reasoning: "",
|
|
toolArgs: "",
|
|
partialJson: ""
|
|
};
|
|
choiceBuffers.set(index, buf);
|
|
}
|
|
return buf;
|
|
};
|
|
|
|
let windowSize = Math.max(200, options?.windowSize ?? (parseInt(process.env.PII_WINDOW_SIZE || "", 10) || 200));
|
|
if (options?.windowSize !== undefined && process.env.PII_TEST_BYPASS_MIN_WINDOW === "true") {
|
|
windowSize = options.windowSize;
|
|
}
|
|
const W = windowSize;
|
|
|
|
const processor = (
|
|
text: string,
|
|
field: FieldCategory,
|
|
isStopSignal = false,
|
|
index: string | number = "0_0",
|
|
isSnapshot = false
|
|
): string => {
|
|
if (isSnapshot) {
|
|
return sanitizePII(text).text;
|
|
}
|
|
const buffers = getBuffers(index);
|
|
buffers[field] += text;
|
|
const { text: sanitized, endMatchIndex } = sanitizePII(buffers[field], !isStopSignal);
|
|
let emitLength = isStopSignal ? sanitized.length : Math.max(0, sanitized.length - W);
|
|
|
|
// Cap emitLength at the start of any PII that touched the end of the buffer
|
|
if (!isStopSignal && endMatchIndex !== undefined && emitLength > endMatchIndex) {
|
|
emitLength = endMatchIndex;
|
|
}
|
|
|
|
// Prevent slicing in the middle of a UTF-16 surrogate pair (e.g. emojis)
|
|
if (emitLength > 0 && emitLength < sanitized.length) {
|
|
const charCode = sanitized.charCodeAt(emitLength - 1);
|
|
// High surrogate range is 0xD800 - 0xDBFF
|
|
if (charCode >= 0xd800 && charCode <= 0xdbff) {
|
|
emitLength -= 1;
|
|
}
|
|
}
|
|
|
|
const toEmit = sanitized.slice(0, emitLength);
|
|
buffers[field] = sanitized.slice(emitLength);
|
|
return toEmit;
|
|
};
|
|
|
|
const onFlush = (lastJson: any, isJsonStream = false, lastContentJson: any = null): any => {
|
|
// Force final redaction on all buffers
|
|
for (const [index, buffers] of choiceBuffers.entries()) {
|
|
for (const key of Object.keys(buffers)) {
|
|
const field = key as FieldCategory;
|
|
if (buffers[field]) {
|
|
buffers[field] = sanitizePII(buffers[field]).text;
|
|
}
|
|
}
|
|
}
|
|
|
|
let hasRemaining = false;
|
|
for (const buffers of choiceBuffers.values()) {
|
|
for (const key of Object.keys(buffers)) {
|
|
if (buffers[key as FieldCategory].length > 0) {
|
|
hasRemaining = true;
|
|
}
|
|
}
|
|
}
|
|
if (!hasRemaining) {
|
|
return null;
|
|
}
|
|
|
|
if (!lastJson) {
|
|
const buffers = getBuffers("0_0");
|
|
if (buffers.content) {
|
|
const remaining = buffers.content;
|
|
buffers.content = "";
|
|
|
|
if (isJsonStream) {
|
|
// Wrap in a safe default OpenAI format to prevent client-side SDK crashes
|
|
return {
|
|
choices: [
|
|
{
|
|
delta: {
|
|
content: remaining
|
|
}
|
|
}
|
|
]
|
|
};
|
|
} else {
|
|
return remaining;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Explicitly target formats to prevent metadata corruption and leakage
|
|
const METADATA_KEYS = [
|
|
"id", "model", "object", "created", "finish_reason", "finishReason",
|
|
"role", "type", "index", "stop_reason", "stop_sequence",
|
|
"system_fingerprint", "service_tier", "usage", "prompt_tokens",
|
|
"completion_tokens", "total_tokens", "input_tokens", "output_tokens",
|
|
"logprobs", "refusal", "name", "event"
|
|
];
|
|
|
|
// 1. Claude format
|
|
if (typeof lastJson.type === "string" && (lastJson.type.startsWith("message") || lastJson.type.startsWith("content_block"))) {
|
|
const buffers = getBuffers("0_0");
|
|
const delta: any = { type: "text_delta" };
|
|
let hasDelta = false;
|
|
if (buffers.content) {
|
|
delta.text = buffers.content;
|
|
buffers.content = "";
|
|
hasDelta = true;
|
|
}
|
|
if (buffers.reasoning) {
|
|
delta.thinking = buffers.reasoning;
|
|
buffers.reasoning = "";
|
|
hasDelta = true;
|
|
}
|
|
if (buffers.partialJson) {
|
|
delta.partial_json = buffers.partialJson;
|
|
buffers.partialJson = "";
|
|
hasDelta = true;
|
|
}
|
|
if (hasDelta) {
|
|
return {
|
|
type: "content_block_delta",
|
|
index: typeof lastJson.index === "number" ? lastJson.index : 0,
|
|
delta
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 2. OpenAI Chat Completions
|
|
if (lastJson.choices && Array.isArray(lastJson.choices)) {
|
|
const finalJson = JSON.parse(JSON.stringify(lastJson));
|
|
const presentIndexes = new Set(finalJson.choices.map((c: any) => c.index).filter((idx: any) => typeof idx === "number"));
|
|
for (const [compositeKey, choiceBuf] of choiceBuffers.entries()) {
|
|
const choiceIdx = parseInt(compositeKey.split("_")[0] || "0", 10);
|
|
if (!presentIndexes.has(choiceIdx) && (choiceBuf.content || choiceBuf.reasoning || choiceBuf.toolArgs)) {
|
|
finalJson.choices.push({ index: choiceIdx, delta: {} });
|
|
presentIndexes.add(choiceIdx);
|
|
}
|
|
}
|
|
|
|
for (const choice of finalJson.choices) {
|
|
const choiceIdx = typeof choice.index === "number" ? choice.index : 0;
|
|
|
|
// Find if we have tool buffers for this choice
|
|
const toolEntries = Array.from(choiceBuffers.entries())
|
|
.filter(([key]) => key.startsWith(`${choiceIdx}_`) && key !== `${choiceIdx}_0`);
|
|
|
|
const choiceBuf = getBuffers(`${choiceIdx}_0`);
|
|
if (!choice.delta) choice.delta = {};
|
|
const delta = choice.delta;
|
|
|
|
if (choiceBuf.content) {
|
|
delta.content = choiceBuf.content;
|
|
choiceBuf.content = "";
|
|
} else {
|
|
delete delta.content;
|
|
}
|
|
if (choiceBuf.reasoning) {
|
|
delta.reasoning_content = choiceBuf.reasoning;
|
|
choiceBuf.reasoning = "";
|
|
} else {
|
|
delete delta.reasoning_content;
|
|
}
|
|
if (choiceBuf.toolArgs || toolEntries.length > 0) {
|
|
if (!choice.delta.tool_calls) choice.delta.tool_calls = [];
|
|
|
|
if (choiceBuf.toolArgs) {
|
|
choice.delta.tool_calls.push({
|
|
index: 0,
|
|
function: { arguments: choiceBuf.toolArgs }
|
|
});
|
|
choiceBuf.toolArgs = "";
|
|
}
|
|
|
|
for (const [key, buf] of toolEntries) {
|
|
if (buf.toolArgs) {
|
|
const toolIdx = parseInt(key.split("_")[1] || "0", 10);
|
|
choice.delta.tool_calls.push({
|
|
index: toolIdx,
|
|
function: { arguments: buf.toolArgs }
|
|
});
|
|
buf.toolArgs = "";
|
|
}
|
|
}
|
|
} else {
|
|
delete choice.delta.tool_calls;
|
|
}
|
|
}
|
|
return finalJson;
|
|
}
|
|
|
|
// 3. Responses API
|
|
if (typeof lastJson.type === "string" && lastJson.type.startsWith("response.")) {
|
|
const finalJson = JSON.parse(JSON.stringify(lastJson));
|
|
const idx = typeof finalJson.output_index === "number" ? finalJson.output_index : 0;
|
|
const buffers = getBuffers(`${idx}_0`);
|
|
if (buffers.content) {
|
|
finalJson.delta = buffers.content;
|
|
buffers.content = "";
|
|
}
|
|
if (buffers.toolArgs) {
|
|
finalJson.item = {
|
|
arguments: buffers.toolArgs
|
|
};
|
|
buffers.toolArgs = "";
|
|
}
|
|
return finalJson;
|
|
}
|
|
|
|
// 4. Gemini format
|
|
if (Array.isArray(lastJson.candidates)) {
|
|
const finalJson = JSON.parse(JSON.stringify(lastJson));
|
|
for (const cand of finalJson.candidates) {
|
|
const idx = typeof cand.index === "number" ? cand.index : 0;
|
|
const buffers = getBuffers(`${idx}_0`);
|
|
if (!cand.content) cand.content = {};
|
|
cand.content.parts = [];
|
|
|
|
if (buffers.content) {
|
|
cand.content.parts.push({ text: buffers.content });
|
|
buffers.content = "";
|
|
}
|
|
}
|
|
return finalJson;
|
|
}
|
|
|
|
// 5. Generic fallback
|
|
const templateJson = lastContentJson || lastJson;
|
|
const finalJson = JSON.parse(JSON.stringify(templateJson));
|
|
const clearDeltas = (obj: any) => {
|
|
if (!obj || typeof obj !== "object") return;
|
|
for (const key of Object.keys(obj)) {
|
|
if (METADATA_KEYS.includes(key)) {
|
|
continue;
|
|
}
|
|
if (typeof obj[key] === "string") {
|
|
obj[key] = "";
|
|
} else if (typeof obj[key] === "object") {
|
|
clearDeltas(obj[key]);
|
|
}
|
|
}
|
|
};
|
|
clearDeltas(finalJson);
|
|
|
|
const populateRemaining = (obj: any, currentChoiceIdx = 0, currentToolIdx = 0) => {
|
|
if (!obj || typeof obj !== "object") return;
|
|
|
|
let choiceIdx = currentChoiceIdx;
|
|
let toolIdx = currentToolIdx;
|
|
|
|
if (typeof obj.index === "number") {
|
|
if (obj.delta || obj.message || obj.finish_reason) {
|
|
choiceIdx = obj.index;
|
|
} else if (obj.function || obj.id || obj.type === "function") {
|
|
toolIdx = obj.index;
|
|
} else {
|
|
choiceIdx = obj.index;
|
|
}
|
|
}
|
|
|
|
const compositeKey = `${choiceIdx}_${toolIdx}`;
|
|
|
|
for (const key of Object.keys(obj)) {
|
|
if (METADATA_KEYS.includes(key)) {
|
|
continue;
|
|
}
|
|
if (typeof obj[key] === "string") {
|
|
const field: FieldCategory = getFieldCategory(key);
|
|
const choiceBuf = getBuffers(compositeKey);
|
|
if (choiceBuf[field]) {
|
|
obj[key] = (obj[key] || "") + choiceBuf[field];
|
|
choiceBuf[field] = "";
|
|
}
|
|
} else if (typeof obj[key] === "object") {
|
|
populateRemaining(obj[key], choiceIdx, toolIdx);
|
|
}
|
|
}
|
|
};
|
|
|
|
populateRemaining(finalJson, 0, 0);
|
|
|
|
// Clear all buffers
|
|
for (const buffers of choiceBuffers.values()) {
|
|
buffers.content = "";
|
|
buffers.reasoning = "";
|
|
buffers.toolArgs = "";
|
|
buffers.partialJson = "";
|
|
}
|
|
|
|
return finalJson;
|
|
};
|
|
|
|
return createSseTextTransform(processor, onFlush);
|
|
}
|