mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
This commit is contained in:
committed by
GitHub
parent
5d1f4687eb
commit
edeb76b96f
@@ -0,0 +1 @@
|
||||
- **fix(sse):** stop the streaming PII sanitizer from splicing OpenRouter metadata (`provider`, `native_finish_reason`, `reasoning_details[].format`) into the answer text buffer (#13488) — thanks @Xore
|
||||
@@ -1,6 +1,7 @@
|
||||
export type FieldCategory = "content" | "reasoning" | "toolArgs" | "partialJson";
|
||||
|
||||
const CATEGORY_MAP: Record<string, FieldCategory> = {
|
||||
// Keys that always map to a fixed category, regardless of where they appear in the chunk.
|
||||
const FIXED_CATEGORY_MAP: Record<string, FieldCategory> = {
|
||||
reasoning: "reasoning",
|
||||
thinking: "reasoning",
|
||||
reasoning_content: "reasoning",
|
||||
@@ -8,8 +9,69 @@ const CATEGORY_MAP: Record<string, FieldCategory> = {
|
||||
partial_json: "partialJson",
|
||||
};
|
||||
|
||||
// System/protocol metadata keys that must never be routed through the PII processor or
|
||||
// buffer, no matter which JSON shape they appear in. Shared by the real-time sanitizeObject
|
||||
// pass (below) and streamingPiiTransform.ts's onFlush generic-fallback branch — the two
|
||||
// copies of this list had drifted (see issue #13488): neither one listed `provider`,
|
||||
// `native_finish_reason`, or the `reasoning_details[].format` field, so those OpenRouter
|
||||
// metadata strings fell through to the default "content" category and got spliced into the
|
||||
// same sliding-window buffer as the actual answer text.
|
||||
export const METADATA_KEYS = new Set([
|
||||
"id",
|
||||
"model",
|
||||
"object",
|
||||
"created",
|
||||
"finish_reason",
|
||||
"finishReason",
|
||||
"native_finish_reason",
|
||||
"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",
|
||||
"provider",
|
||||
"format",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify a string field as a PII-processed category, or `null` when it is metadata that
|
||||
* must pass through untouched. `parentKey` is the key of the object that directly contains
|
||||
* `key` (empty string at the JSON root) — it disambiguates `text`, which means the answer
|
||||
* everywhere except inside a `reasoning_details[]` item, where it is reasoning text (and
|
||||
* `format` alongside it is metadata, not content, even though it is not disambiguated by
|
||||
* parent elsewhere). Any string field not explicitly recognized as metadata defaults to
|
||||
* "content" — this keeps non-standard/unrecognized stream shapes (arbitrary JSON keys that
|
||||
* match none of the known provider formats) from silently losing their text.
|
||||
*/
|
||||
export function classifyField(key: string, parentKey = ""): FieldCategory | null {
|
||||
if (FIXED_CATEGORY_MAP[key]) {
|
||||
return FIXED_CATEGORY_MAP[key];
|
||||
}
|
||||
if (key === "text" && parentKey === "reasoning_details") {
|
||||
return "reasoning";
|
||||
}
|
||||
if (METADATA_KEYS.has(key)) {
|
||||
return null;
|
||||
}
|
||||
return "content";
|
||||
}
|
||||
|
||||
// Back-compat helper kept for any external caller expecting a category rather than `null`
|
||||
// for metadata; internal call sites use `classifyField` so they can skip metadata entirely.
|
||||
export function getFieldCategory(key: string): FieldCategory {
|
||||
return CATEGORY_MAP[key] || "content";
|
||||
return classifyField(key) ?? "content";
|
||||
}
|
||||
|
||||
const STOP_EVENT_TYPES = new Set([
|
||||
@@ -123,34 +185,18 @@ export function createSseTextTransform(
|
||||
const isStopSignal = checkIfStopSignal(json);
|
||||
const isSnapshot = checkIfSnapshot(json);
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
// Recursively sanitize all string properties (except system metadata)
|
||||
const sanitizeObject = (obj: any, currentChoiceIdx = 0, currentToolIdx = 0) => {
|
||||
// Recursively sanitize string properties, skipping recognized system metadata
|
||||
// (`classifyField` returns null for METADATA_KEYS — `provider`,
|
||||
// `native_finish_reason`, `reasoning_details[].format`, etc.). `parentKey` is the
|
||||
// key of the enclosing object (unchanged across array-index recursion) so
|
||||
// `classifyField` can tell `reasoning_details[].text` apart from ordinary content
|
||||
// text. See issue #13488.
|
||||
const sanitizeObject = (
|
||||
obj: any,
|
||||
currentChoiceIdx = 0,
|
||||
currentToolIdx = 0,
|
||||
parentKey = ""
|
||||
) => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
let choiceIdx = currentChoiceIdx;
|
||||
@@ -167,14 +213,15 @@ export function createSseTextTransform(
|
||||
}
|
||||
|
||||
const compositeKey = `${choiceIdx}_${toolIdx}`;
|
||||
const isArray = Array.isArray(obj);
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (METADATA_KEYS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof obj[key] === "string") {
|
||||
const val = obj[key];
|
||||
const field: FieldCategory = getFieldCategory(key);
|
||||
const field = classifyField(key, parentKey);
|
||||
if (field === null) {
|
||||
continue;
|
||||
}
|
||||
if (field === "toolArgs" || field === "partialJson") {
|
||||
obj[key] = val;
|
||||
matched = true;
|
||||
@@ -183,7 +230,7 @@ export function createSseTextTransform(
|
||||
obj[key] = processor(val, field, isStopSignal, compositeKey, isSnapshot);
|
||||
matched = true;
|
||||
} else if (typeof obj[key] === "object") {
|
||||
sanitizeObject(obj[key], choiceIdx, toolIdx);
|
||||
sanitizeObject(obj[key], choiceIdx, toolIdx, isArray ? parentKey : key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSseTextTransform, FieldCategory, getFieldCategory } from "./sseTextTransform";
|
||||
import { createSseTextTransform, FieldCategory, classifyField } from "./sseTextTransform";
|
||||
import { sanitizePII } from "./piiSanitizer";
|
||||
|
||||
export interface PiiTransformOptions {
|
||||
@@ -116,33 +116,6 @@ export function createPiiSseTransform(options?: PiiTransformOptions): TransformS
|
||||
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" &&
|
||||
@@ -283,22 +256,31 @@ export function createPiiSseTransform(options?: PiiTransformOptions): TransformS
|
||||
// 5. Generic fallback
|
||||
const templateJson = lastContentJson || lastJson;
|
||||
const finalJson = JSON.parse(JSON.stringify(templateJson));
|
||||
const clearDeltas = (obj: any) => {
|
||||
// Skip recognized system metadata (same `classifyField`/METADATA_KEYS as sanitizeObject
|
||||
// in sseTextTransform.ts) — fields like `provider` or `native_finish_reason` must never
|
||||
// be cleared to "" or refilled with buffered answer text here either. See #13488.
|
||||
const clearDeltas = (obj: any, parentKey = "") => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
const isArray = Array.isArray(obj);
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (METADATA_KEYS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof obj[key] === "string") {
|
||||
if (classifyField(key, parentKey) === null) {
|
||||
continue;
|
||||
}
|
||||
obj[key] = "";
|
||||
} else if (typeof obj[key] === "object") {
|
||||
clearDeltas(obj[key]);
|
||||
clearDeltas(obj[key], isArray ? parentKey : key);
|
||||
}
|
||||
}
|
||||
};
|
||||
clearDeltas(finalJson);
|
||||
|
||||
const populateRemaining = (obj: any, currentChoiceIdx = 0, currentToolIdx = 0) => {
|
||||
const populateRemaining = (
|
||||
obj: any,
|
||||
currentChoiceIdx = 0,
|
||||
currentToolIdx = 0,
|
||||
parentKey = ""
|
||||
) => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
let choiceIdx = currentChoiceIdx;
|
||||
@@ -315,20 +297,21 @@ export function createPiiSseTransform(options?: PiiTransformOptions): TransformS
|
||||
}
|
||||
|
||||
const compositeKey = `${choiceIdx}_${toolIdx}`;
|
||||
const isArray = Array.isArray(obj);
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (METADATA_KEYS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof obj[key] === "string") {
|
||||
const field: FieldCategory = getFieldCategory(key);
|
||||
const field = classifyField(key, parentKey);
|
||||
if (field === null) {
|
||||
continue;
|
||||
}
|
||||
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(obj[key], choiceIdx, toolIdx, isArray ? parentKey : key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
227
tests/unit/issue-13488-pii-openrouter-metadata-splice.test.ts
Normal file
227
tests/unit/issue-13488-pii-openrouter-metadata-splice.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Isolate DB state
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-issue-13488-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
// Enable the feature flag for tests (mode "warn" per the issue repro steps — "nothing is
|
||||
// supposed to be modified", yet the windowed re-emission still runs and scrambles output).
|
||||
const originalEnv = process.env.PII_RESPONSE_SANITIZATION;
|
||||
const originalMode = process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
process.env.PII_RESPONSE_SANITIZATION = "true";
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = "warn";
|
||||
process.env.PII_TEST_BYPASS_MIN_WINDOW = "true";
|
||||
|
||||
import { createPiiSseTransform } from "../../src/lib/streamingPiiTransform.ts";
|
||||
|
||||
async function testTransform(transform: TransformStream, inputChunks: string[]): Promise<string> {
|
||||
const writer = transform.writable.getWriter();
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const writePromise = (async () => {
|
||||
for (const chunk of inputChunks) {
|
||||
await writer.write(new TextEncoder().encode(chunk));
|
||||
}
|
||||
await writer.close();
|
||||
})();
|
||||
|
||||
const outputChunks: string[] = [];
|
||||
let res = await reader.read();
|
||||
while (!res.done) {
|
||||
outputChunks.push(new TextDecoder().decode(res.value));
|
||||
res = await reader.read();
|
||||
}
|
||||
|
||||
await writePromise;
|
||||
return outputChunks.join("");
|
||||
}
|
||||
|
||||
function extractContentAndMetadata(output: string, metadataKey: string) {
|
||||
const dataLines = output
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("data: ") && l !== "data: [DONE]");
|
||||
|
||||
let reassembledContent = "";
|
||||
const metadataValues = new Set<string>();
|
||||
for (const line of dataLines) {
|
||||
const json = JSON.parse(line.slice("data: ".length));
|
||||
const delta = json.choices?.[0]?.delta;
|
||||
if (delta?.content) reassembledContent += delta.content;
|
||||
if (typeof json[metadataKey] === "string") metadataValues.add(json[metadataKey]);
|
||||
}
|
||||
return { reassembledContent, metadataValues };
|
||||
}
|
||||
|
||||
// Reproduces the exact shape from the issue: OpenRouter SSE chunks carry a top-level
|
||||
// "provider" string and delta.reasoning_details[].text, alongside delta.content — all on
|
||||
// the same choice. windowSize is kept small (5) to force windowed re-emission on every
|
||||
// chunk, matching the reporter's observation that scrambling happens even in mode=warn.
|
||||
test("issue #13488: OpenRouter top-level `provider` field must not share the content buffer", async () => {
|
||||
const transform = createPiiSseTransform({ windowSize: 5 });
|
||||
|
||||
const makeChunk = (provider: string, content: string, reasoningText = "") =>
|
||||
`data: ${JSON.stringify({
|
||||
id: "gen-1",
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
provider,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content,
|
||||
role: "assistant",
|
||||
reasoning: "",
|
||||
reasoning_details: reasoningText
|
||||
? [{ type: "reasoning.text", text: reasoningText, format: "" }]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})}\n\n`;
|
||||
|
||||
const chunks = [
|
||||
makeChunk("Together", "Lake"),
|
||||
makeChunk("Together", "Saimaa "),
|
||||
makeChunk("Together", "is the largest "),
|
||||
makeChunk("Together", "lake in Finland."),
|
||||
];
|
||||
const done = `data: [DONE]\n\n`;
|
||||
|
||||
const output = await testTransform(transform, [...chunks, done]);
|
||||
|
||||
const { reassembledContent, metadataValues: providerValues } = extractContentAndMetadata(
|
||||
output,
|
||||
"provider"
|
||||
);
|
||||
|
||||
const expectedContent = "LakeSaimaa is the largest lake in Finland.";
|
||||
|
||||
assert.equal(
|
||||
reassembledContent,
|
||||
expectedContent,
|
||||
`content must reassemble byte-identical to input even with PII sanitization enabled; ` +
|
||||
`got ${JSON.stringify(reassembledContent)}`
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...providerValues],
|
||||
["Together"],
|
||||
`the "provider" field must stay constant across every chunk (metadata, not answer text); ` +
|
||||
`got ${JSON.stringify([...providerValues])}`
|
||||
);
|
||||
|
||||
// None of the answer text should ever have leaked into the provider field.
|
||||
for (const p of providerValues) {
|
||||
assert.ok(
|
||||
!/Lake|Saimaa|largest|Finland/.test(p),
|
||||
`provider field must never contain spliced-in answer text; got "${p}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Same shape but with mode=redact, per the issue's Validation Plan — the splice bug must
|
||||
// also be gone when redaction (not just pass-through warn mode) is active.
|
||||
test("issue #13488: mode=redact must not splice `provider` and `content` either", async () => {
|
||||
const originalModeLocal = process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = "redact";
|
||||
try {
|
||||
const transform = createPiiSseTransform({ windowSize: 5 });
|
||||
|
||||
const makeChunk = (provider: string, content: string) =>
|
||||
`data: ${JSON.stringify({
|
||||
id: "gen-2",
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
provider,
|
||||
choices: [{ index: 0, delta: { content, role: "assistant" } }],
|
||||
})}\n\n`;
|
||||
|
||||
const chunks = [
|
||||
makeChunk("Together", "The "),
|
||||
makeChunk("Together", "capital "),
|
||||
makeChunk("Together", "of France "),
|
||||
makeChunk("Together", "is Paris."),
|
||||
];
|
||||
const done = `data: [DONE]\n\n`;
|
||||
|
||||
const output = await testTransform(transform, [...chunks, done]);
|
||||
const { reassembledContent, metadataValues: providerValues } = extractContentAndMetadata(
|
||||
output,
|
||||
"provider"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
reassembledContent,
|
||||
"The capital of France is Paris.",
|
||||
`content must reassemble byte-identical even under redact mode; got ${JSON.stringify(reassembledContent)}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...providerValues],
|
||||
["Together"],
|
||||
`provider must stay constant under redact mode; got ${JSON.stringify([...providerValues])}`
|
||||
);
|
||||
} finally {
|
||||
if (originalModeLocal !== undefined) {
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = originalModeLocal;
|
||||
} else {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// A second concurrently-streamed metadata field (native_finish_reason) alongside `provider`
|
||||
// and `content` — closes the family of "any recognized metadata field shares the buffer",
|
||||
// not just the single field named in the report.
|
||||
test("issue #13488: a second metadata field (native_finish_reason) must not share buffers either", async () => {
|
||||
const transform = createPiiSseTransform({ windowSize: 5 });
|
||||
|
||||
const makeChunk = (provider: string, finishReason: string, content: string) =>
|
||||
`data: ${JSON.stringify({
|
||||
id: "gen-3",
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
provider,
|
||||
native_finish_reason: finishReason,
|
||||
choices: [{ index: 0, delta: { content, role: "assistant" } }],
|
||||
})}\n\n`;
|
||||
|
||||
const chunks = [
|
||||
makeChunk("Together", "in_progress", "Hello "),
|
||||
makeChunk("Together", "in_progress", "there, "),
|
||||
makeChunk("Together", "in_progress", "world!"),
|
||||
];
|
||||
const done = `data: [DONE]\n\n`;
|
||||
|
||||
const output = await testTransform(transform, [...chunks, done]);
|
||||
const { reassembledContent, metadataValues: providerValues } = extractContentAndMetadata(
|
||||
output,
|
||||
"provider"
|
||||
);
|
||||
const { metadataValues: finishReasonValues } = extractContentAndMetadata(
|
||||
output,
|
||||
"native_finish_reason"
|
||||
);
|
||||
|
||||
assert.equal(reassembledContent, "Hello there, world!");
|
||||
assert.deepEqual([...providerValues], ["Together"]);
|
||||
assert.deepEqual([...finishReasonValues], ["in_progress"]);
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.PII_RESPONSE_SANITIZATION = originalEnv;
|
||||
} else {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION;
|
||||
}
|
||||
if (originalMode !== undefined) {
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = originalMode;
|
||||
} else {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
}
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
Reference in New Issue
Block a user