@st-gr/sail-proxy 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled/gateway/node_modules/.package-lock.json +17 -17
- package/bundled/gateway/node_modules/form-data/CHANGELOG.md +29 -2
- package/bundled/gateway/node_modules/form-data/README.md +4 -4
- package/bundled/gateway/node_modules/form-data/lib/form_data.js +14 -2
- package/bundled/gateway/node_modules/form-data/package.json +7 -7
- package/bundled/gateway/node_modules/qs/CHANGELOG.md +16 -0
- package/bundled/gateway/node_modules/qs/README.md +1 -1
- package/bundled/gateway/node_modules/qs/dist/qs.js +15 -15
- package/bundled/gateway/node_modules/qs/eslint.config.mjs +1 -0
- package/bundled/gateway/node_modules/qs/lib/parse.js +54 -24
- package/bundled/gateway/node_modules/qs/lib/stringify.js +11 -4
- package/bundled/gateway/node_modules/qs/package.json +3 -3
- package/bundled/gateway/node_modules/qs/test/parse.js +135 -0
- package/bundled/gateway/node_modules/qs/test/stringify.js +138 -0
- package/bundled/gateway/node_modules/qs/test/utils.js +31 -3
- package/bundled/gateway/node_modules/semver/classes/range.js +17 -4
- package/bundled/gateway/node_modules/semver/package.json +2 -2
- package/bundled/gateway/node_modules/side-channel/CHANGELOG.md +10 -0
- package/bundled/gateway/node_modules/side-channel/README.md +1 -1
- package/bundled/gateway/node_modules/side-channel/index.js +5 -2
- package/bundled/gateway/node_modules/side-channel/package.json +10 -10
- package/bundled/gateway/node_modules/side-channel/test/index.js +16 -0
- package/bundled/gateway/package-lock.json +22 -22
- package/bundled/gateway/package.json +13 -5
- package/bundled/gateway/services/gateway/src/controllers/anthropicController.js +1 -1
- package/bundled/gateway/services/gateway/src/controllers/awsBedrockController.js +5 -1
- package/bundled/gateway/services/gateway/src/index.js +14 -0
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/detectors/index.js +23 -1
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/detectors/regexDetectors.js +149 -10
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/entityToggles.d.ts +24 -0
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/entityToggles.js +54 -0
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/index.d.ts +2 -1
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/index.js +441 -92
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacementMap.d.ts +24 -0
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacementMap.js +93 -4
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacer.d.ts +22 -0
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/replacer.js +77 -0
- package/bundled/gateway/services/gateway/src/plugins/pseudonymization/streamBuffer.js +26 -8
- package/bundled/gateway/services/gateway/src/services/awsBedrockService.js +118 -46
- package/bundled/gateway/services/gateway/src/services/betaFlagQuarantine.d.ts +18 -0
- package/bundled/gateway/services/gateway/src/services/betaFlagQuarantine.js +111 -0
- package/bundled/gateway/services/gateway/src/services/configService.d.ts +8 -0
- package/bundled/gateway/services/gateway/src/services/configService.js +61 -53
- package/bundled/gateway/services/gateway/src/utils/bedrockStreamParser.d.ts +9 -0
- package/bundled/gateway/services/gateway/src/utils/bedrockStreamParser.js +69 -3
- package/bundled/gateway/services/gateway/src/utils/betaFeatureFilter.d.ts +31 -0
- package/bundled/gateway/services/gateway/src/utils/betaFeatureFilter.js +58 -0
- package/bundled/gateway/services/gateway/src/utils/payloadLogger.d.ts +6 -0
- package/bundled/gateway/services/gateway/src/utils/payloadLogger.js +11 -7
- package/bundled/gateway/services/gateway/src/utils/pluginCacheSelector.d.ts +15 -0
- package/bundled/gateway/services/gateway/src/utils/pluginCacheSelector.js +24 -0
- package/bundled/gateway/services/gateway/src/utils/sseWriter.js +4 -2
- package/bundled/gateway/services/gateway/src/utils/upstreamErrorBody.d.ts +10 -0
- package/bundled/gateway/services/gateway/src/utils/upstreamErrorBody.js +71 -0
- package/bundled/ollama/node_modules/.package-lock.json +14 -14
- package/bundled/ollama/node_modules/form-data/CHANGELOG.md +29 -2
- package/bundled/ollama/node_modules/form-data/README.md +4 -4
- package/bundled/ollama/node_modules/form-data/lib/form_data.js +14 -2
- package/bundled/ollama/node_modules/form-data/package.json +7 -7
- package/bundled/ollama/node_modules/qs/CHANGELOG.md +16 -0
- package/bundled/ollama/node_modules/qs/README.md +1 -1
- package/bundled/ollama/node_modules/qs/dist/qs.js +15 -15
- package/bundled/ollama/node_modules/qs/eslint.config.mjs +1 -0
- package/bundled/ollama/node_modules/qs/lib/parse.js +54 -24
- package/bundled/ollama/node_modules/qs/lib/stringify.js +11 -4
- package/bundled/ollama/node_modules/qs/package.json +3 -3
- package/bundled/ollama/node_modules/qs/test/parse.js +135 -0
- package/bundled/ollama/node_modules/qs/test/stringify.js +138 -0
- package/bundled/ollama/node_modules/qs/test/utils.js +31 -3
- package/bundled/ollama/node_modules/side-channel/CHANGELOG.md +10 -0
- package/bundled/ollama/node_modules/side-channel/README.md +1 -1
- package/bundled/ollama/node_modules/side-channel/index.js +5 -2
- package/bundled/ollama/node_modules/side-channel/package.json +10 -10
- package/bundled/ollama/node_modules/side-channel/test/index.js +16 -0
- package/bundled/ollama/package-lock.json +15 -15
- package/bundled/ollama/package.json +13 -5
- package/dist/templates/api_config.template.json +266 -18
- package/node_modules/@sap-llm-gateway/service-key-parser/package.json +1 -1
- package/package.json +2 -2
|
@@ -16,11 +16,14 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Strategies: before + after + stream
|
|
18
18
|
*
|
|
19
|
-
* @see pseudonymization
|
|
19
|
+
* @see ../pseudonymization.md - Usage, activation methods, and category configuration
|
|
20
|
+
* @see ../../../../../docs/security/pseudonymization-security-assessment.md - Security assessment
|
|
20
21
|
*/
|
|
22
|
+
const logger_1 = require("@libs/logger");
|
|
21
23
|
const replacementMap_1 = require("./replacementMap");
|
|
22
24
|
const detectors_1 = require("./detectors");
|
|
23
25
|
const replacer_1 = require("./replacer");
|
|
26
|
+
const entityToggles_1 = require("./entityToggles");
|
|
24
27
|
const unmasker_1 = require("./unmasker");
|
|
25
28
|
const streamBuffer_1 = require("./streamBuffer");
|
|
26
29
|
const entityCache_1 = require("./entityCache");
|
|
@@ -75,6 +78,12 @@ function extractResponseText(response) {
|
|
|
75
78
|
if (response?.final_result?.choices?.[0]?.delta?.content) {
|
|
76
79
|
return response.final_result.choices[0].delta.content;
|
|
77
80
|
}
|
|
81
|
+
// Anthropic native streaming: content_block_delta with text_delta
|
|
82
|
+
if (response?.type === 'content_block_delta'
|
|
83
|
+
&& response?.delta?.type === 'text_delta'
|
|
84
|
+
&& typeof response.delta.text === 'string') {
|
|
85
|
+
return response.delta.text;
|
|
86
|
+
}
|
|
78
87
|
// Anthropic format
|
|
79
88
|
if (response?.content && Array.isArray(response.content)) {
|
|
80
89
|
for (const block of response.content) {
|
|
@@ -101,6 +110,11 @@ function setResponseText(response, newText) {
|
|
|
101
110
|
else if (response?.final_result?.choices?.[0]?.delta?.content !== undefined) {
|
|
102
111
|
response.final_result.choices[0].delta.content = newText;
|
|
103
112
|
}
|
|
113
|
+
else if (response?.type === 'content_block_delta'
|
|
114
|
+
&& response?.delta?.type === 'text_delta'
|
|
115
|
+
&& response.delta.text !== undefined) {
|
|
116
|
+
response.delta.text = newText;
|
|
117
|
+
}
|
|
104
118
|
else if (response?.content && Array.isArray(response.content)) {
|
|
105
119
|
for (const block of response.content) {
|
|
106
120
|
if (block.type === 'text') {
|
|
@@ -135,6 +149,10 @@ const DEFAULT_MASKING_CONFIG = {
|
|
|
135
149
|
{ type: 'profile-url' },
|
|
136
150
|
{ type: 'profile-address' },
|
|
137
151
|
{ type: 'profile-username-password' },
|
|
152
|
+
{ type: 'profile-nationalid' },
|
|
153
|
+
{ type: 'profile-passport' },
|
|
154
|
+
{ type: 'profile-driverlicense' },
|
|
155
|
+
{ type: 'profile-pronouns-gender' },
|
|
138
156
|
{ type: 'profile-nationality' },
|
|
139
157
|
{ type: 'profile-ethnicity' },
|
|
140
158
|
{ type: 'profile-gender' },
|
|
@@ -146,6 +164,38 @@ const DEFAULT_MASKING_CONFIG = {
|
|
|
146
164
|
{ type: 'profile-location' },
|
|
147
165
|
],
|
|
148
166
|
};
|
|
167
|
+
// All category types the pseudonymizationPlugin understands, for toggle validation.
|
|
168
|
+
const KNOWN_ENTITY_TYPES = (0, entityToggles_1.buildKnownEntityTypes)(DEFAULT_MASKING_CONFIG.entities);
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the DEFAULT entity set for this request by layering api_config.json
|
|
171
|
+
* toggles over DEFAULT_MASKING_CONFIG: global (api_config.pseudonymization.entities)
|
|
172
|
+
* → per-endpoint (defaultHooks[endpoint].pseudonymization.entities) → per-model
|
|
173
|
+
* (model_list_changes[model].pseudonymization.entities); later layers win.
|
|
174
|
+
* Reads dynamic config per request (hot in distributed mode; standalone requires
|
|
175
|
+
* a restart, as for all plugin config). Falls back to the code defaults on any error.
|
|
176
|
+
*/
|
|
177
|
+
function resolveDefaultEntities(req) {
|
|
178
|
+
try {
|
|
179
|
+
const configService = require('../../services/configService').default || require('../../services/configService');
|
|
180
|
+
const apiConfig = configService.getConfig()?.api_config;
|
|
181
|
+
if (!apiConfig)
|
|
182
|
+
return DEFAULT_MASKING_CONFIG.entities;
|
|
183
|
+
let entities = (0, entityToggles_1.applyEntityToggles)(DEFAULT_MASKING_CONFIG.entities, apiConfig.pseudonymization?.entities, KNOWN_ENTITY_TYPES);
|
|
184
|
+
const endpoint = req?.__endpoint;
|
|
185
|
+
if (endpoint) {
|
|
186
|
+
entities = (0, entityToggles_1.applyEntityToggles)(entities, apiConfig.defaultHooks?.[endpoint]?.pseudonymization?.entities, KNOWN_ENTITY_TYPES);
|
|
187
|
+
}
|
|
188
|
+
const modelName = req?.body?.model;
|
|
189
|
+
if (modelName) {
|
|
190
|
+
const substituted = configService.getSubstitutedModel('anthropic', modelName) || modelName;
|
|
191
|
+
entities = (0, entityToggles_1.applyEntityToggles)(entities, apiConfig.model_list_changes?.[substituted]?.pseudonymization?.entities, KNOWN_ENTITY_TYPES);
|
|
192
|
+
}
|
|
193
|
+
return entities;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return DEFAULT_MASKING_CONFIG.entities;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
149
199
|
/**
|
|
150
200
|
* Scan messages for triggerword, strip it, and return masking config if found
|
|
151
201
|
*/
|
|
@@ -197,7 +247,7 @@ function scanAndStripTriggerword(req) {
|
|
|
197
247
|
}
|
|
198
248
|
if (!found)
|
|
199
249
|
return null;
|
|
200
|
-
return { ...DEFAULT_MASKING_CONFIG, method: found };
|
|
250
|
+
return { ...DEFAULT_MASKING_CONFIG, entities: resolveDefaultEntities(req), method: found };
|
|
201
251
|
}
|
|
202
252
|
/**
|
|
203
253
|
* Check if the model has pseudonymization forced on via api_config.json.
|
|
@@ -217,7 +267,7 @@ function getModelForcedConfig(req) {
|
|
|
217
267
|
if (modelConfig?.pseudonymization?.enabled) {
|
|
218
268
|
const method = modelConfig.pseudonymization.method || 'pseudonymization';
|
|
219
269
|
const allowBypass = modelConfig.pseudonymization.allow_user_bypass === true;
|
|
220
|
-
return { config: { ...DEFAULT_MASKING_CONFIG, method }, allowBypass, source: 'model' };
|
|
270
|
+
return { config: { ...DEFAULT_MASKING_CONFIG, entities: resolveDefaultEntities(req), method }, allowBypass, source: 'model' };
|
|
221
271
|
}
|
|
222
272
|
}
|
|
223
273
|
// Per-endpoint force flag (defaultHooks[endpoint].pseudonymization.enabled)
|
|
@@ -227,7 +277,7 @@ function getModelForcedConfig(req) {
|
|
|
227
277
|
if (endpointConfig?.pseudonymization?.enabled) {
|
|
228
278
|
const method = endpointConfig.pseudonymization.method || 'pseudonymization';
|
|
229
279
|
const allowBypass = endpointConfig.pseudonymization.allow_user_bypass === true;
|
|
230
|
-
return { config: { ...DEFAULT_MASKING_CONFIG, method }, allowBypass, source: 'endpoint' };
|
|
280
|
+
return { config: { ...DEFAULT_MASKING_CONFIG, entities: resolveDefaultEntities(req), method }, allowBypass, source: 'endpoint' };
|
|
231
281
|
}
|
|
232
282
|
}
|
|
233
283
|
return null;
|
|
@@ -299,6 +349,27 @@ async function beforeHandler({ req, res, utils }) {
|
|
|
299
349
|
const allEntities = [];
|
|
300
350
|
const maskedInputs = [];
|
|
301
351
|
const messages = req.body.messages || [];
|
|
352
|
+
// Residue guard: the incoming request may contain literal MASKED_* tokens from
|
|
353
|
+
// earlier turns/sessions. With content-derived (hash) placeholders these resolve
|
|
354
|
+
// on their own whenever the underlying value is present in this request (the same
|
|
355
|
+
// value re-mints the identical token). Legacy NUMERIC residue (from the old
|
|
356
|
+
// counter scheme) can never be re-minted; reserve those numbers so anonymization
|
|
357
|
+
// counters cannot collide with them, and log the residue for visibility.
|
|
358
|
+
try {
|
|
359
|
+
const rawBody = JSON.stringify(req.body);
|
|
360
|
+
const residue = new Set(rawBody.match(/MASKED_[A-Z_]+_[0-9a-f]+|(?:https?:\/\/)?masked-url-\d+\.invalid/g) || []);
|
|
361
|
+
if (residue.size > 0) {
|
|
362
|
+
for (const token of residue) {
|
|
363
|
+
const m = token.match(/^(MASKED_[A-Z_]+)_(\d+)$/);
|
|
364
|
+
// Cap the reservation so a digits-only hash id cannot blow up the counters.
|
|
365
|
+
if (m && m[2].length <= 6)
|
|
366
|
+
map.reserveMinCount(m[1], parseInt(m[2], 10));
|
|
367
|
+
}
|
|
368
|
+
const sample = Array.from(residue).slice(0, 10).join(', ');
|
|
369
|
+
logger.info(`Request contains ${residue.size} masked token(s) carried in from earlier turns (hash-stable tokens resolve automatically when their value is present): ${sample}${residue.size > 10 ? ', …' : ''}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch { /* non-fatal: residue guard is best-effort */ }
|
|
302
373
|
// Process system messages (Anthropic format)
|
|
303
374
|
if (Array.isArray(req.body.system)) {
|
|
304
375
|
for (let i = 0; i < req.body.system.length; i++) {
|
|
@@ -344,6 +415,45 @@ async function beforeHandler({ req, res, utils }) {
|
|
|
344
415
|
}
|
|
345
416
|
}
|
|
346
417
|
}
|
|
418
|
+
// Value-consistency propagation: mask every remaining exact occurrence of each
|
|
419
|
+
// detected value across the request, including nodes where no detector fired
|
|
420
|
+
// (e.g. the same secret riding through in an `Authorization: Bearer` header).
|
|
421
|
+
// Prevents the model from seeing the raw value alongside its mask.
|
|
422
|
+
const propagated = (0, replacer_1.propagateMaskedValues)(req.body, map);
|
|
423
|
+
// Tell the model to treat placeholder tokens as opaque and copy them verbatim.
|
|
424
|
+
// A garbled id cannot be unmasked (the value is unrecoverable), so copy fidelity
|
|
425
|
+
// is part of the design; observed in practice: models occasionally rewrite ids
|
|
426
|
+
// when reproducing them in generated files unless explicitly instructed not to.
|
|
427
|
+
if (maskingConfig.method === 'pseudonymization' && map.size > 0) {
|
|
428
|
+
const copyNote = 'Some values in this conversation are pseudonymized as placeholder tokens of the form '
|
|
429
|
+
+ 'MASKED_<TYPE>_<id>. Treat each token as an opaque identifier: when you refer to a masked value, '
|
|
430
|
+
+ 'copy its token EXACTLY, character for character. Never invent, alter, shorten, or merge token ids. '
|
|
431
|
+
+ 'Only use MASKED_* tokens that appear verbatim in this conversation — NEVER invent, guess, or '
|
|
432
|
+
+ 'construct a new MASKED_* token or id under any circumstances; a token you did not see in the input '
|
|
433
|
+
+ 'is meaningless and will break the user\'s workflow. If a value you need appears unmasked in the '
|
|
434
|
+
+ 'conversation, use that unmasked value as-is; do not create a token for it. '
|
|
435
|
+
+ 'Masked URLs appear as hosts like https://masked-url-<id>.invalid — you MAY append paths and query '
|
|
436
|
+
+ 'parameters to such a host to form new URLs, but never invent a masked-url host that is not present '
|
|
437
|
+
+ 'in the conversation.';
|
|
438
|
+
if (Array.isArray(req.body.system)) {
|
|
439
|
+
req.body.system.push({ type: 'text', text: copyNote });
|
|
440
|
+
}
|
|
441
|
+
else if (typeof req.body.system === 'string') {
|
|
442
|
+
req.body.system = `${req.body.system}\n\n${copyNote}`;
|
|
443
|
+
}
|
|
444
|
+
else if (req.body.system === undefined || req.body.system === null) {
|
|
445
|
+
req.body.system = copyNote;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Capture the masking_info debug opt-in before the config is stripped. The
|
|
449
|
+
// full masked-input diagnostic is off by default for pseudonymization (it
|
|
450
|
+
// bloats every client response); attach it only when explicitly requested via
|
|
451
|
+
// `masking.debug: true` or the out-of-band `x-sail-proxy-masking-debug: on`
|
|
452
|
+
// header (same channel pattern as the bypass header).
|
|
453
|
+
const maskingDebug = req.body?.masking?.debug === true
|
|
454
|
+
|| (typeof req.headers?.['x-sail-proxy-masking-debug'] === 'string'
|
|
455
|
+
&& req.headers['x-sail-proxy-masking-debug'].toLowerCase() === 'on');
|
|
456
|
+
req.__pseudonymizationDebug = maskingDebug;
|
|
347
457
|
// Remove the masking config from the body before forwarding upstream
|
|
348
458
|
delete req.body.masking;
|
|
349
459
|
// Store state on request for after/stream handlers
|
|
@@ -362,7 +472,7 @@ async function beforeHandler({ req, res, utils }) {
|
|
|
362
472
|
if (maskingConfig.method === 'pseudonymization' && res) {
|
|
363
473
|
installSseUnmaskInterceptor(req, res, map);
|
|
364
474
|
}
|
|
365
|
-
logger.info(`Masked ${allEntities.length} entities (${map.size} unique) across ${messages.length} messages`);
|
|
475
|
+
logger.info(`Masked ${allEntities.length} entities (${map.size} unique) across ${messages.length} messages; propagated ${propagated} additional occurrence(s)`);
|
|
366
476
|
// Async: store detected entities in learned cache (non-blocking)
|
|
367
477
|
if (allEntities.length > 0) {
|
|
368
478
|
const toCache = allEntities
|
|
@@ -417,7 +527,11 @@ async function afterHandler({ req, upstreamResponse, utils }) {
|
|
|
417
527
|
logger.debug(`Unmasked response text (${responseText.length} → ${unmasked.length} chars)`);
|
|
418
528
|
}
|
|
419
529
|
unmaskToolBlocks(upstreamResponse, map);
|
|
420
|
-
|
|
530
|
+
// Residue audit (the non-streaming path had none — this is the incident path).
|
|
531
|
+
// Scan the fully-unmasked client-facing body for surviving MASKED_* tokens and
|
|
532
|
+
// log per-token detail plus one aggregated, grep-stable counter line.
|
|
533
|
+
auditResponseResidue(upstreamResponse, map, req?.debugRequestId || 'unknown', logger);
|
|
534
|
+
return maybeAttachMaskingInfo(upstreamResponse, state, req);
|
|
421
535
|
}
|
|
422
536
|
catch (error) {
|
|
423
537
|
logger.error(`Error in pseudonymization after handler: ${error.message}`, { stack: error.stack });
|
|
@@ -430,119 +544,292 @@ async function afterHandler({ req, upstreamResponse, utils }) {
|
|
|
430
544
|
* directly without going through the after-plugin chain per chunk (e.g., AWS Bedrock
|
|
431
545
|
* native streaming via BedrockStreamParser).
|
|
432
546
|
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
547
|
+
* The patched write re-frames arbitrary write boundaries into complete SSE blocks
|
|
548
|
+
* (`[event: <name>\n]data: <json>\n\n`), holding a partial tail across writes, so it
|
|
549
|
+
* also covers pipelines that pipe multiple or partial events per write (e.g. the
|
|
550
|
+
* converse-stream direct passthrough). Assistant text (text_delta), tool input
|
|
551
|
+
* (input_json_delta), and OpenAI tool_calls argument deltas are unmasked through
|
|
552
|
+
* per-content-block StreamUnmaskBuffer instances; retained fragments are flushed on
|
|
553
|
+
* content_block_stop / message_stop / finish_reason / res.end.
|
|
554
|
+
*
|
|
555
|
+
* Diagnostics: when a completed block still contains MASKED_* placeholders, an error
|
|
556
|
+
* (token was in the map — a genuine unmask miss) or warning (token unknown — residue
|
|
557
|
+
* leaked into the conversation by an earlier session) is logged, so leaks are never
|
|
558
|
+
* silent again.
|
|
436
559
|
*/
|
|
437
560
|
function installSseUnmaskInterceptor(req, res, map) {
|
|
438
561
|
if (res.__pseudoSseInterceptorInstalled)
|
|
439
562
|
return;
|
|
563
|
+
if (typeof res.write !== 'function' || typeof res.end !== 'function')
|
|
564
|
+
return;
|
|
440
565
|
res.__pseudoSseInterceptorInstalled = true;
|
|
441
|
-
|
|
566
|
+
// Patch res.json so NON-streaming responses that bypass res.write are still unmasked.
|
|
567
|
+
// The awsBedrockResponseCache plugin serves non-streaming cache hits via res.status().json()
|
|
568
|
+
// and returns { stop: true }, which short-circuits the after-handler AND never touches
|
|
569
|
+
// res.write — so without this, cached placeholders reach the client masked. The cache stores
|
|
570
|
+
// the masked upstream response (pseudonymization unmasks at the wire, after caching); for an
|
|
571
|
+
// identical request the placeholder numbers are deterministic, so this request's map resolves
|
|
572
|
+
// them. Tokens not in the map (residue) are left untouched by unmaskText.
|
|
573
|
+
const logger = (0, logger_1.getDefaultLogger)();
|
|
574
|
+
if (typeof res.json === 'function') {
|
|
575
|
+
const originalJson = res.json.bind(res);
|
|
576
|
+
res.json = function patchedJson(body) {
|
|
577
|
+
try {
|
|
578
|
+
if (body && typeof body === 'object' && map.reverse.size > 0) {
|
|
579
|
+
const serialized = JSON.stringify(body);
|
|
580
|
+
if (serialized.includes('MASKED_')) {
|
|
581
|
+
const unmasked = (0, unmasker_1.unmaskText)(serialized, map);
|
|
582
|
+
if (unmasked !== serialized) {
|
|
583
|
+
body = JSON.parse(unmasked);
|
|
584
|
+
logger.debug('Pseudonymization', `Unmasked non-streaming JSON response body (requestId=${req?.debugRequestId || 'unknown'})`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch { /* fall through with original body */ }
|
|
590
|
+
return originalJson(body);
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const requestId = req?.debugRequestId || 'unknown';
|
|
594
|
+
const buffers = new Map();
|
|
442
595
|
const getBuf = (key) => {
|
|
443
|
-
let b =
|
|
596
|
+
let b = buffers.get(key);
|
|
444
597
|
if (!b) {
|
|
445
598
|
b = new streamBuffer_1.StreamUnmaskBuffer(map);
|
|
446
|
-
|
|
599
|
+
buffers.set(key, b);
|
|
447
600
|
}
|
|
448
601
|
return b;
|
|
449
602
|
};
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
eventName = m1[1];
|
|
465
|
-
jsonStr = m1[2];
|
|
603
|
+
// Per-block emitted content, used only for the leak audit below.
|
|
604
|
+
const emitted = new Map();
|
|
605
|
+
const track = (key, s) => {
|
|
606
|
+
if (s)
|
|
607
|
+
emitted.set(key, (emitted.get(key) || '') + s);
|
|
608
|
+
};
|
|
609
|
+
const auditBlock = (key) => {
|
|
610
|
+
const content = emitted.get(key);
|
|
611
|
+
emitted.delete(key);
|
|
612
|
+
if (!content)
|
|
613
|
+
return;
|
|
614
|
+
for (const token of new Set(content.match(/MASKED_[A-Z_]+_[0-9a-f]+|(?:https?:\/\/)?masked-url-\d+\.invalid/g) || [])) {
|
|
615
|
+
if (map.reverse.has(token)) {
|
|
616
|
+
logger.error('Pseudonymization', `Unmask miss on streaming block ${key}: ${token} was in the replacement map but reached the client masked (requestId=${requestId})`);
|
|
466
617
|
}
|
|
467
618
|
else {
|
|
468
|
-
|
|
469
|
-
if (m2) {
|
|
470
|
-
jsonStr = m2[1];
|
|
471
|
-
}
|
|
619
|
+
logger.warn('Pseudonymization', `Unresolvable masked residue in streaming block ${key}: ${token} is not in this request's map — its value is absent from this request (leaked by an earlier session) (requestId=${requestId})`);
|
|
472
620
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const originalWrite = res.write.bind(res);
|
|
624
|
+
const originalEnd = res.end.bind(res);
|
|
625
|
+
// Partial SSE block carried across write() calls (re-framing buffer).
|
|
626
|
+
let pending = '';
|
|
627
|
+
// Process one complete SSE block; returns the (possibly rewritten) block.
|
|
628
|
+
const processBlock = (block) => {
|
|
629
|
+
// Two SSE formats are emitted by this gateway:
|
|
630
|
+
// 1) "event: <name>\ndata: <json>\n\n" (writeEventStream)
|
|
631
|
+
// 2) "data: <json>\n\n" (writeChunk — used by BedrockStreamParser)
|
|
632
|
+
let eventName = null;
|
|
633
|
+
let jsonStr = null;
|
|
634
|
+
const m1 = block.match(/^event: ([^\n]+)\ndata: (.+)\n\n$/s);
|
|
635
|
+
if (m1) {
|
|
636
|
+
eventName = m1[1];
|
|
637
|
+
jsonStr = m1[2];
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
const m2 = block.match(/^data: (.+)\n\n$/s);
|
|
641
|
+
if (m2) {
|
|
642
|
+
jsonStr = m2[1];
|
|
478
643
|
}
|
|
479
|
-
|
|
480
|
-
|
|
644
|
+
}
|
|
645
|
+
if (!jsonStr)
|
|
646
|
+
return block;
|
|
647
|
+
let event;
|
|
648
|
+
try {
|
|
649
|
+
event = JSON.parse(jsonStr);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
return block;
|
|
653
|
+
}
|
|
654
|
+
let modified = false;
|
|
655
|
+
let syntheticPrefix = '';
|
|
656
|
+
// Serialize a synthetic delta in the same format as the surrounding stream,
|
|
657
|
+
// emitted BEFORE the current event for correct ordering.
|
|
658
|
+
const synth = (idx, delta) => {
|
|
659
|
+
const ev = { type: 'content_block_delta', index: idx, delta };
|
|
660
|
+
const prefix = eventName ? 'event: content_block_delta\n' : '';
|
|
661
|
+
return `${prefix}data: ${JSON.stringify(ev)}\n\n`;
|
|
662
|
+
};
|
|
663
|
+
// Anthropic content_block_delta with text_delta (assistant prose)
|
|
664
|
+
if (event.type === 'content_block_delta'
|
|
665
|
+
&& event.delta?.type === 'text_delta'
|
|
666
|
+
&& typeof event.delta.text === 'string') {
|
|
667
|
+
const idx = event.index ?? 0;
|
|
668
|
+
const buf = getBuf(`text:${idx}`);
|
|
669
|
+
event.delta.text = buf.append(event.delta.text);
|
|
670
|
+
track(`text:${idx}`, event.delta.text);
|
|
671
|
+
modified = true;
|
|
672
|
+
}
|
|
673
|
+
// Anthropic content_block_delta with input_json_delta (tool call input)
|
|
674
|
+
if (event.type === 'content_block_delta'
|
|
675
|
+
&& event.delta?.type === 'input_json_delta'
|
|
676
|
+
&& typeof event.delta.partial_json === 'string') {
|
|
677
|
+
const idx = event.index ?? 0;
|
|
678
|
+
const buf = getBuf(`tool_use:${idx}`);
|
|
679
|
+
event.delta.partial_json = buf.append(event.delta.partial_json);
|
|
680
|
+
track(`tool_use:${idx}`, event.delta.partial_json);
|
|
681
|
+
modified = true;
|
|
682
|
+
}
|
|
683
|
+
// Anthropic content_block_stop — flush retained suffixes of this block's buffers
|
|
684
|
+
// as synthetic deltas emitted before the stop, then audit the block for leaks.
|
|
685
|
+
if (event.type === 'content_block_stop') {
|
|
686
|
+
const idx = event.index ?? 0;
|
|
687
|
+
const textBuf = buffers.get(`text:${idx}`);
|
|
688
|
+
if (textBuf) {
|
|
689
|
+
const remainder = textBuf.flush();
|
|
690
|
+
if (remainder) {
|
|
691
|
+
syntheticPrefix += synth(idx, { type: 'text_delta', text: remainder });
|
|
692
|
+
track(`text:${idx}`, remainder);
|
|
693
|
+
}
|
|
694
|
+
buffers.delete(`text:${idx}`);
|
|
481
695
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const idx = event.index ?? 0;
|
|
489
|
-
const buf = getBuf(`tool_use:${idx}`);
|
|
490
|
-
event.delta.partial_json = buf.append(event.delta.partial_json);
|
|
491
|
-
modified = true;
|
|
492
|
-
}
|
|
493
|
-
// Anthropic content_block_stop — flush any retained suffix as a synthetic delta
|
|
494
|
-
// emitted before the stop.
|
|
495
|
-
if (event.type === 'content_block_stop') {
|
|
496
|
-
const idx = event.index ?? 0;
|
|
497
|
-
const buf = toolBuffers.get(`tool_use:${idx}`);
|
|
498
|
-
if (buf) {
|
|
499
|
-
const remainder = buf.flush();
|
|
500
|
-
if (remainder) {
|
|
501
|
-
const synthetic = {
|
|
502
|
-
type: 'content_block_delta',
|
|
503
|
-
index: idx,
|
|
504
|
-
delta: { type: 'input_json_delta', partial_json: remainder },
|
|
505
|
-
};
|
|
506
|
-
// Match the source format (event: prefix only when present in the original write)
|
|
507
|
-
const prefix = eventName ? `event: content_block_delta\n` : '';
|
|
508
|
-
extraWrites.push(`${prefix}data: ${JSON.stringify(synthetic)}\n\n`);
|
|
509
|
-
}
|
|
510
|
-
toolBuffers.delete(`tool_use:${idx}`);
|
|
696
|
+
const toolBuf = buffers.get(`tool_use:${idx}`);
|
|
697
|
+
if (toolBuf) {
|
|
698
|
+
const remainder = toolBuf.flush();
|
|
699
|
+
if (remainder) {
|
|
700
|
+
syntheticPrefix += synth(idx, { type: 'input_json_delta', partial_json: remainder });
|
|
701
|
+
track(`tool_use:${idx}`, remainder);
|
|
511
702
|
}
|
|
703
|
+
buffers.delete(`tool_use:${idx}`);
|
|
512
704
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
|
|
705
|
+
auditBlock(`text:${idx}`);
|
|
706
|
+
auditBlock(`tool_use:${idx}`);
|
|
707
|
+
}
|
|
708
|
+
// Anthropic message_stop — safety flush of ALL remaining buffers (Anthropic streams
|
|
709
|
+
// have no choices[].finish_reason, so anything not flushed by a stop lands here).
|
|
710
|
+
if (event.type === 'message_stop') {
|
|
711
|
+
for (const [key, buf] of Array.from(buffers.entries())) {
|
|
712
|
+
const remainder = buf.flush();
|
|
713
|
+
if (remainder) {
|
|
714
|
+
const idx = parseInt(key.split(':')[1], 10) || 0;
|
|
715
|
+
const deltaType = key.startsWith('text:') ? 'text_delta' : 'input_json_delta';
|
|
716
|
+
syntheticPrefix += synth(idx, deltaType === 'text_delta'
|
|
717
|
+
? { type: 'text_delta', text: remainder }
|
|
718
|
+
: { type: 'input_json_delta', partial_json: remainder });
|
|
719
|
+
track(key, remainder);
|
|
720
|
+
}
|
|
721
|
+
buffers.delete(key);
|
|
722
|
+
}
|
|
723
|
+
for (const key of Array.from(emitted.keys()))
|
|
724
|
+
auditBlock(key);
|
|
725
|
+
}
|
|
726
|
+
// OpenAI streaming via SSE (less common with this controller, but handle just in case)
|
|
727
|
+
if (Array.isArray(event?.choices?.[0]?.delta?.tool_calls)) {
|
|
728
|
+
const finishReason = event?.choices?.[0]?.finish_reason;
|
|
729
|
+
for (const call of event.choices[0].delta.tool_calls) {
|
|
730
|
+
if (typeof call?.function?.arguments === 'string') {
|
|
731
|
+
const idx = call.index ?? 0;
|
|
732
|
+
const key = `oai_tool_call:${idx}`;
|
|
733
|
+
const buf = getBuf(key);
|
|
734
|
+
if (finishReason) {
|
|
735
|
+
call.function.arguments = buf.append(call.function.arguments) + buf.flush();
|
|
736
|
+
track(key, call.function.arguments);
|
|
737
|
+
buffers.delete(key);
|
|
738
|
+
auditBlock(key);
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
call.function.arguments = buf.append(call.function.arguments);
|
|
742
|
+
track(key, call.function.arguments);
|
|
528
743
|
}
|
|
744
|
+
modified = true;
|
|
529
745
|
}
|
|
530
746
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
747
|
+
}
|
|
748
|
+
if (modified) {
|
|
749
|
+
const newStr = eventName
|
|
750
|
+
? `event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`
|
|
751
|
+
: `data: ${JSON.stringify(event)}\n\n`;
|
|
752
|
+
return syntheticPrefix + newStr;
|
|
753
|
+
}
|
|
754
|
+
return syntheticPrefix + block;
|
|
755
|
+
};
|
|
756
|
+
// Final safety net: unmask any COMPLETE placeholder still present in outgoing bytes.
|
|
757
|
+
// The structured per-block handling above only unmasks events it recognizes as
|
|
758
|
+
// content_block_delta text_delta / input_json_delta; text emitted through other
|
|
759
|
+
// shapes (notably the BedrockStreamParser raw-text fallback, which writes bare
|
|
760
|
+
// `data: <text>` on a parse miss) would otherwise reach the client masked. This is
|
|
761
|
+
// idempotent on already-unmasked output (no reverse-map keys remain to match) and
|
|
762
|
+
// only touches whole tokens, so it never disturbs the buffer's partial-retention.
|
|
763
|
+
const safetyNetUnmask = (s) => {
|
|
764
|
+
if (map.reverse.size === 0)
|
|
765
|
+
return s;
|
|
766
|
+
if (!s.includes('MASKED_') && !s.includes('masked-url-'))
|
|
767
|
+
return s;
|
|
768
|
+
return (0, unmasker_1.unmaskText)(s, map);
|
|
769
|
+
};
|
|
770
|
+
// Split accumulated data into complete SSE blocks, keep the partial tail pending.
|
|
771
|
+
const processIncoming = (str) => {
|
|
772
|
+
pending += str;
|
|
773
|
+
let out = '';
|
|
774
|
+
let sep;
|
|
775
|
+
while ((sep = pending.indexOf('\n\n')) !== -1) {
|
|
776
|
+
const block = pending.slice(0, sep + 2);
|
|
777
|
+
pending = pending.slice(sep + 2);
|
|
778
|
+
out += processBlock(block);
|
|
779
|
+
}
|
|
780
|
+
return safetyNetUnmask(out);
|
|
781
|
+
};
|
|
782
|
+
// Patch res.write. The Express Response.write signature has overloads; we accept any.
|
|
783
|
+
res.write = function patchedWrite(chunk, ...args) {
|
|
784
|
+
try {
|
|
785
|
+
const str = typeof chunk === 'string' ? chunk : (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : null);
|
|
786
|
+
if (str === null)
|
|
787
|
+
return originalWrite(chunk, ...args);
|
|
788
|
+
const out = processIncoming(str);
|
|
789
|
+
// Strip a callback argument if present — it must fire even when we buffer.
|
|
790
|
+
const cb = typeof args[args.length - 1] === 'function' ? args.pop() : undefined;
|
|
791
|
+
if (out) {
|
|
792
|
+
const result = originalWrite(out, ...args);
|
|
793
|
+
if (cb)
|
|
794
|
+
cb();
|
|
795
|
+
return result;
|
|
539
796
|
}
|
|
540
|
-
|
|
797
|
+
// Entire chunk retained as a partial block — report success, emit on next write/end.
|
|
798
|
+
if (cb)
|
|
799
|
+
cb();
|
|
800
|
+
return true;
|
|
541
801
|
}
|
|
542
802
|
catch {
|
|
543
803
|
return originalWrite(chunk, ...args);
|
|
544
804
|
}
|
|
545
805
|
};
|
|
806
|
+
// Patch res.end so a trailing partial block (and any retained buffer content) is not lost.
|
|
807
|
+
res.end = function patchedEnd(chunk, ...args) {
|
|
808
|
+
try {
|
|
809
|
+
if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
|
|
810
|
+
const out = processIncoming(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
811
|
+
if (out)
|
|
812
|
+
originalWrite(out);
|
|
813
|
+
chunk = undefined;
|
|
814
|
+
}
|
|
815
|
+
if (pending) {
|
|
816
|
+
// Incomplete final block — pass through (best effort), still safety-net unmasked.
|
|
817
|
+
originalWrite(safetyNetUnmask(pending));
|
|
818
|
+
pending = '';
|
|
819
|
+
}
|
|
820
|
+
// Streams that ended without content_block_stop/message_stop: audit what remains.
|
|
821
|
+
for (const [key, buf] of Array.from(buffers.entries())) {
|
|
822
|
+
const remainder = buf.flush();
|
|
823
|
+
if (remainder)
|
|
824
|
+
track(key, remainder);
|
|
825
|
+
buffers.delete(key);
|
|
826
|
+
}
|
|
827
|
+
for (const key of Array.from(emitted.keys()))
|
|
828
|
+
auditBlock(key);
|
|
829
|
+
}
|
|
830
|
+
catch { /* fall through to originalEnd */ }
|
|
831
|
+
return chunk !== undefined ? originalEnd(chunk, ...args) : originalEnd(...args);
|
|
832
|
+
};
|
|
546
833
|
}
|
|
547
834
|
/**
|
|
548
835
|
* Walk a non-streaming response and unmask placeholders inside tool_use / tool_calls blocks.
|
|
@@ -638,15 +925,30 @@ function handleStreamingChunk(req, chunk, map, state, logger) {
|
|
|
638
925
|
const idx = chunk.index ?? 0;
|
|
639
926
|
const key = `tool_use:${idx}`;
|
|
640
927
|
const buf = toolBuffers.get(key);
|
|
928
|
+
let toolFlushed = false;
|
|
641
929
|
if (buf) {
|
|
642
930
|
const remainder = buf.flush();
|
|
643
931
|
if (remainder) {
|
|
644
932
|
if (!chunk.delta)
|
|
645
933
|
chunk.delta = { type: 'input_json_delta', partial_json: '' };
|
|
646
934
|
chunk.delta.partial_json = (chunk.delta.partial_json || '') + remainder;
|
|
935
|
+
toolFlushed = true;
|
|
647
936
|
}
|
|
648
937
|
toolBuffers.delete(key);
|
|
649
938
|
}
|
|
939
|
+
// Text blocks: Anthropic streams never set choices[].finish_reason, so the shared
|
|
940
|
+
// text buffer's retained tail would otherwise be stranded. Flush it here, unless
|
|
941
|
+
// this stop already carries a tool remainder (don't mix delta types).
|
|
942
|
+
if (!toolFlushed) {
|
|
943
|
+
const textRemainder = buffer.flush();
|
|
944
|
+
if (textRemainder) {
|
|
945
|
+
if (!chunk.delta)
|
|
946
|
+
chunk.delta = { type: 'text_delta', text: '' };
|
|
947
|
+
if (chunk.delta.type === 'text_delta') {
|
|
948
|
+
chunk.delta.text = (chunk.delta.text || '') + textRemainder;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
650
952
|
}
|
|
651
953
|
// ─── OpenAI streaming: choices[0].delta.tool_calls[].function.arguments ─
|
|
652
954
|
const oaiToolCalls = chunk?.choices?.[0]?.delta?.tool_calls;
|
|
@@ -709,6 +1011,53 @@ function attachMaskingInfo(response, state) {
|
|
|
709
1011
|
}
|
|
710
1012
|
return response;
|
|
711
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Attach masking_info, but for pseudonymization only when the request opted into the
|
|
1016
|
+
* debug diagnostic (`masking.debug: true` or `x-sail-proxy-masking-debug: on`).
|
|
1017
|
+
* Anonymization keeps its documented behavior of always attaching it.
|
|
1018
|
+
*/
|
|
1019
|
+
function maybeAttachMaskingInfo(response, state, req) {
|
|
1020
|
+
if (state.config.method === 'pseudonymization' && !req?.__pseudonymizationDebug) {
|
|
1021
|
+
return response;
|
|
1022
|
+
}
|
|
1023
|
+
return attachMaskingInfo(response, state);
|
|
1024
|
+
}
|
|
1025
|
+
// Placeholder-residue pattern (hash-hex ids and masked-url hosts), shared by the
|
|
1026
|
+
// streaming and non-streaming leak audits.
|
|
1027
|
+
const RESIDUE_REGEX = /MASKED_[A-Z_]+_[0-9a-f]+|(?:https?:\/\/)?masked-url-\d+\.invalid/g;
|
|
1028
|
+
/**
|
|
1029
|
+
* Scan a fully-unmasked non-streaming response for surviving MASKED_* tokens.
|
|
1030
|
+
* Logs per-token detail (ERROR if the token was in the map — a genuine unmask miss;
|
|
1031
|
+
* WARN if it is unknown residue from an earlier session) plus one aggregated,
|
|
1032
|
+
* grep-stable counter line so leak rates can be monitored.
|
|
1033
|
+
*/
|
|
1034
|
+
function auditResponseResidue(response, map, requestId, logger) {
|
|
1035
|
+
let serialized;
|
|
1036
|
+
try {
|
|
1037
|
+
serialized = JSON.stringify(response) || '';
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (!serialized.includes('MASKED_') && !serialized.includes('masked-url-'))
|
|
1043
|
+
return;
|
|
1044
|
+
const tokens = new Set(serialized.match(RESIDUE_REGEX) || []);
|
|
1045
|
+
if (tokens.size === 0)
|
|
1046
|
+
return;
|
|
1047
|
+
const byType = {};
|
|
1048
|
+
for (const token of tokens) {
|
|
1049
|
+
const type = token.match(/^(MASKED_[A-Z_]+)_/)?.[1] || 'MASKED_URL';
|
|
1050
|
+
byType[type] = (byType[type] || 0) + 1;
|
|
1051
|
+
if (map.reverse.has(token)) {
|
|
1052
|
+
logger.error('Pseudonymization', `Unmask miss on response: ${token} was in the replacement map but reached the client masked (requestId=${requestId})`);
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
logger.warn('Pseudonymization', `Unresolvable masked residue in response: ${token} is not in this request's map — its value is absent from this request (leaked by an earlier session) (requestId=${requestId})`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const byTypeStr = Object.entries(byType).map(([t, n]) => `${t}:${n}`).join(',');
|
|
1059
|
+
logger.warn('Pseudonymization', `pseudonymization_residue_unresolved_total=${tokens.size} by_type=${byTypeStr} requestId=${requestId}`);
|
|
1060
|
+
}
|
|
712
1061
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
713
1062
|
// STREAM HANDLER: Raw buffer processing (operates before SSE parsing)
|
|
714
1063
|
// ─────────────────────────────────────────────────────────────────────────────
|