Skip to content

fix(sse): stop PII sanitizer splicing OpenRouter metadata into content (#13488) - #13792

Merged
diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13488-pii-sanitizer-splices-openrouter-metadata
Sep 16, 2026
Merged

diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13488-pii-sanitizer-splices-openrouter-metadata

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #13488

Root cause

createSseTextTransform's sanitizeObject (src/lib/sseTextTransform.ts) classified any
string field not in a hard-coded METADATA_KEYS deny-list as "content" via
getFieldCategory's default branch. OpenRouter SSE chunks carry a top-level provider
string and delta.reasoning_details[].text/.format alongside delta.content on the same
choice index — since neither provider nor native_finish_reason nor
reasoning_details[].format were in the deny-list, all of them fell into the same
per-choice "content" sliding-window FIFO buffer in streamingPiiTransform.ts as the actual
answer text, producing interleaved/scrambled output on both sides. The two files carried
independent copies of METADATA_KEYS that had already drifted (one had
system_fingerprint, neither had provider).

Fix

  • Consolidated the two drifted METADATA_KEYS lists into one shared Set, exported from
    src/lib/sseTextTransform.ts and imported by src/lib/streamingPiiTransform.ts; added
    provider, native_finish_reason, and format to it.
  • Added a classifyField(key, parentKey) helper (replacing getFieldCategory at the
    internal call sites) that also disambiguates reasoning_details[].text — which is
    reasoning text, not answer content — from ordinary text/content/delta fields, by
    tracking the enclosing object's key through the recursive walk.
  • Applied the same classifyField/METADATA_KEYS logic to streamingPiiTransform.ts's
    onFlush generic-fallback branch (clearDeltas/populateRemaining) so metadata fields
    aren't blanked to "" or repopulated with buffered answer text there either.
  • Deliberately kept the "any string field not recognized as metadata defaults to content"
    fallback (rather than switching to a strict allow-list) — tests/unit/piiReproduction.test.ts
    ("THEORY-004: Data Loss in Unknown Stream Fallbacks") already encodes that non-standard/
    unrecognized JSON shapes must not silently lose their text, and a strict allow-list broke
    that existing, intentional behavior.

Regression test

tests/unit/issue-13488-pii-openrouter-metadata-splice.test.ts — 3 cases: the exact
OpenRouter repro from the issue (mode warn), the same shape under mode redact, and a
second concurrently-streamed metadata field (native_finish_reason) to close the family
rather than just the one field named in the report.

RED on unfixed code:

✖ issue #13488: OpenRouter top-level `provider` field must not share the content buffer
  AssertionError: content must reassemble byte-identical to input even with PII sanitization enabled;
  got "etheetherSaetheris the laretherlake in Finland."
  - 'LakeSaimaa is the largest lake in Finland.'

GREEN after the fix:

✔ issue #13488: OpenRouter top-level `provider` field must not share the content buffer
✔ issue #13488: mode=redact must not splice `provider` and `content` either
✔ issue #13488: a second metadata field (native_finish_reason) must not share buffers either
ℹ tests 3 | pass 3 | fail 0

Gates run

  • npx eslint --suppressions-location config/quality/eslint-suppressions.json src/lib/sseTextTransform.ts src/lib/streamingPiiTransform.ts tests/unit/issue-13488-pii-openrouter-metadata-splice.test.ts → clean, exit 0.
  • npm run typecheck:core → exit 0, no errors.
  • node scripts/check/check-file-size.mjs → the only ✗ is open-sse/utils/stream.ts (pre-existing, untouched by this branch — confirmed unchanged vs origin/release/v3.8.51).
  • node scripts/check/check-complexity.mjs → OK — 2824 violations (baseline 3218).
  • node scripts/check/check-cognitive-complexity.mjs → OK — 1278 violations (baseline 1437).
  • node scripts/check/check-test-discovery.mjs → OK — new test file discovered.
  • Full existing suites for the touched area, run individually (DB-per-file isolation):
    tests/unit/sseTextTransform.test.ts (16/16), tests/unit/streamingPiiTransform.test.ts
    (28/28), tests/unit/streamingPiiInitialization.test.ts (1/1),
    tests/unit/pii-opt-in-default.test.ts (5/5), tests/unit/piiReproduction.test.ts (5/5),
    tests/unit/adversarialPii.test.ts (11/11), tests/unit/piiSanitizerIpv6.test.ts (18/18)
    — all green.

Existing tests aligned

None weakened or deleted. tests/unit/piiReproduction.test.ts's "THEORY-004" case (arbitrary
msg key in a non-standard JSON shape must still be treated as content, not silently
dropped) directly shaped the fix's design — kept the deny-list-plus-explicit-additions
approach instead of switching to a strict allow-list, specifically so that test's existing,
intentional contract keeps passing unmodified.

diegosouzapw and others added 2 commits September 15, 2026 17:04
#13488)

createSseTextTransform's sanitizeObject classified any string field not in a
hard-coded METADATA_KEYS deny-list as "content" (getFieldCategory default),
so OpenRouter's top-level `provider`, `native_finish_reason`, and
`reasoning_details[].format` fell into the same per-choice sliding-window
buffer in streamingPiiTransform.ts as the actual delta.content text. Both
files carried independent copies of METADATA_KEYS that had already drifted.

Added those three keys to a single shared METADATA_KEYS set (exported from
sseTextTransform.ts, consumed by both files) and gave classifyField a
parentKey parameter so `reasoning_details[].text` classifies as "reasoning"
instead of the ambiguous default "content" — closing the specific splice
without breaking the existing behavior of routing genuinely unrecognized
JSON shapes' string fields through the content buffer.

Regression test: tests/unit/issue-13488-pii-openrouter-metadata-splice.test.ts
(RED on unfixed code: reassembled content came back scrambled with
provider-name fragments; GREEN after the fix, plus redact-mode and a second
concurrent metadata field coverage).
@diegosouzapw
diegosouzapw merged commit edeb76b into release/v3.8.51 Sep 16, 2026
19 of 21 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
diegosouzapw#13488) (diegosouzapw#13792)

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(backend): streaming PII sanitizer splices OpenRouter metadata into answer text (scrambled output)

1 participant