@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
|
@@ -4,13 +4,37 @@
|
|
|
4
4
|
* Maintains forward (original → placeholder) and reverse (placeholder → original)
|
|
5
5
|
* mappings for the lifetime of a single request. Ensures idempotency: the same
|
|
6
6
|
* original text always gets the same placeholder within a request.
|
|
7
|
+
*
|
|
8
|
+
* Pseudonymization placeholders are CONTENT-DERIVED (truncated SHA-256 of the
|
|
9
|
+
* original value), so the same value produces the same token in every request:
|
|
10
|
+
* - tokens echoed by the model out of older conversation turns ("residue")
|
|
11
|
+
* resolve correctly as long as the underlying value is present in the
|
|
12
|
+
* current request (it re-mints the identical token);
|
|
13
|
+
* - the response cache can safely replay a masked response cached from an
|
|
14
|
+
* equivalent request — its tokens are reproducible from content alone;
|
|
15
|
+
* - masked request bodies are byte-stable across requests, which also keeps
|
|
16
|
+
* upstream prompt caching effective.
|
|
17
|
+
* Anonymization placeholders remain per-request counters: without a reverse
|
|
18
|
+
* map they are never unmasked, and a content-derived token would make the same
|
|
19
|
+
* entity linkable across requests, defeating anonymization.
|
|
7
20
|
*/
|
|
8
21
|
export declare class ReplacementMap {
|
|
9
22
|
readonly forward: Map<string, string>;
|
|
10
23
|
readonly reverse: Map<string, string>;
|
|
11
24
|
private counters;
|
|
25
|
+
private minCountersByPrefix;
|
|
12
26
|
private method;
|
|
13
27
|
constructor(method?: 'pseudonymization' | 'anonymization');
|
|
28
|
+
/**
|
|
29
|
+
* Ensure freshly minted placeholders for `prefix` start above `minCount`.
|
|
30
|
+
*
|
|
31
|
+
* Used when the incoming request already contains literal MASKED_*_n tokens
|
|
32
|
+
* ("residue" leaked into the conversation history by an earlier session).
|
|
33
|
+
* Without this, a fresh entity could mint the same number as a residue token,
|
|
34
|
+
* and unmasking the model's echo of that residue would substitute the WRONG
|
|
35
|
+
* person's name.
|
|
36
|
+
*/
|
|
37
|
+
reserveMinCount(prefix: string, minCount: number): void;
|
|
14
38
|
/**
|
|
15
39
|
* Get or assign a placeholder for an entity
|
|
16
40
|
*/
|
|
@@ -5,18 +5,66 @@
|
|
|
5
5
|
* Maintains forward (original → placeholder) and reverse (placeholder → original)
|
|
6
6
|
* mappings for the lifetime of a single request. Ensures idempotency: the same
|
|
7
7
|
* original text always gets the same placeholder within a request.
|
|
8
|
+
*
|
|
9
|
+
* Pseudonymization placeholders are CONTENT-DERIVED (truncated SHA-256 of the
|
|
10
|
+
* original value), so the same value produces the same token in every request:
|
|
11
|
+
* - tokens echoed by the model out of older conversation turns ("residue")
|
|
12
|
+
* resolve correctly as long as the underlying value is present in the
|
|
13
|
+
* current request (it re-mints the identical token);
|
|
14
|
+
* - the response cache can safely replay a masked response cached from an
|
|
15
|
+
* equivalent request — its tokens are reproducible from content alone;
|
|
16
|
+
* - masked request bodies are byte-stable across requests, which also keeps
|
|
17
|
+
* upstream prompt caching effective.
|
|
18
|
+
* Anonymization placeholders remain per-request counters: without a reverse
|
|
19
|
+
* map they are never unmasked, and a content-derived token would make the same
|
|
20
|
+
* entity linkable across requests, defeating anonymization.
|
|
8
21
|
*/
|
|
22
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
23
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
24
|
+
};
|
|
9
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
26
|
exports.ReplacementMap = void 0;
|
|
27
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
11
28
|
const types_1 = require("./types");
|
|
29
|
+
/** Digits of the content-derived placeholder id. Decimal (not hex) on purpose:
|
|
30
|
+
* models copy short digit runs far more reliably than hex strings — a garbled
|
|
31
|
+
* id is unrecoverable, so copy fidelity is part of the security design. */
|
|
32
|
+
const HASH_ID_DIGITS = 8;
|
|
33
|
+
/**
|
|
34
|
+
* Derive a decimal id from a SHA-256 digest. `level` widens the id (8, 10, 12 …
|
|
35
|
+
* digits) using more of the digest — used for deterministic collision probing:
|
|
36
|
+
* the same value always yields the same ladder of candidate ids.
|
|
37
|
+
*/
|
|
38
|
+
function idFromDigest(digest, level) {
|
|
39
|
+
const digits = HASH_ID_DIGITS + 2 * level;
|
|
40
|
+
const window = digest.slice(0, Math.min(12 + 4 * level, digest.length));
|
|
41
|
+
const num = BigInt(`0x${window}`) % (10n ** BigInt(digits));
|
|
42
|
+
return num.toString().padStart(digits, '0');
|
|
43
|
+
}
|
|
12
44
|
class ReplacementMap {
|
|
13
45
|
forward = new Map();
|
|
14
46
|
reverse = new Map();
|
|
15
47
|
counters = new Map();
|
|
48
|
+
minCountersByPrefix = new Map();
|
|
16
49
|
method;
|
|
17
50
|
constructor(method = 'pseudonymization') {
|
|
18
51
|
this.method = method;
|
|
19
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Ensure freshly minted placeholders for `prefix` start above `minCount`.
|
|
55
|
+
*
|
|
56
|
+
* Used when the incoming request already contains literal MASKED_*_n tokens
|
|
57
|
+
* ("residue" leaked into the conversation history by an earlier session).
|
|
58
|
+
* Without this, a fresh entity could mint the same number as a residue token,
|
|
59
|
+
* and unmasking the model's echo of that residue would substitute the WRONG
|
|
60
|
+
* person's name.
|
|
61
|
+
*/
|
|
62
|
+
reserveMinCount(prefix, minCount) {
|
|
63
|
+
const current = this.minCountersByPrefix.get(prefix) || 0;
|
|
64
|
+
if (minCount > current) {
|
|
65
|
+
this.minCountersByPrefix.set(prefix, minCount);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
20
68
|
/**
|
|
21
69
|
* Get or assign a placeholder for an entity
|
|
22
70
|
*/
|
|
@@ -26,14 +74,55 @@ class ReplacementMap {
|
|
|
26
74
|
return this.forward.get(originalValue);
|
|
27
75
|
}
|
|
28
76
|
const prefix = customPrefix || types_1.DEFAULT_PREFIXES[entityType] || 'MASKED_UNKNOWN';
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
77
|
+
// URL origins get a URL-SHAPED placeholder (e.g. `http://masked-url-12345678.invalid`)
|
|
78
|
+
// instead of a bare token: the model can then legitimately append paths and query
|
|
79
|
+
// parameters, and the composed URL still unmasks (the placeholder origin is replaced
|
|
80
|
+
// by the real origin wherever it appears). `.invalid` is an RFC 2606 reserved TLD,
|
|
81
|
+
// so an unresolved placeholder can never route traffic anywhere.
|
|
82
|
+
const isUrl = prefix === 'MASKED_URL';
|
|
83
|
+
const urlScheme = isUrl ? (originalValue.match(/^(?:https?|wss?|ftps?):\/\//)?.[0] ?? '') : '';
|
|
84
|
+
const render = (id) => isUrl ? `${urlScheme}masked-url-${id}.invalid` : `${prefix}_${id}`;
|
|
85
|
+
let placeholder;
|
|
86
|
+
if (this.method === 'pseudonymization') {
|
|
87
|
+
// Content-derived stable id: same value → same placeholder in every request.
|
|
88
|
+
const digest = crypto_1.default.createHash('sha256').update(originalValue, 'utf8').digest('hex');
|
|
89
|
+
let level = 0;
|
|
90
|
+
placeholder = render(idFromDigest(digest, level));
|
|
91
|
+
// In-request collision probing: two DIFFERENT values sharing an id widen it
|
|
92
|
+
// deterministically (each value always follows the same ladder of candidates,
|
|
93
|
+
// so the resolution itself is stable across requests).
|
|
94
|
+
while (this.reverse.has(placeholder) && this.reverse.get(placeholder) !== originalValue) {
|
|
95
|
+
level += 1;
|
|
96
|
+
if (12 + 4 * level > digest.length) {
|
|
97
|
+
// Full-digest collision is not realistically reachable; guard anyway.
|
|
98
|
+
placeholder = render(`${idFromDigest(digest, level)}${this.forward.size}`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
placeholder = render(idFromDigest(digest, level));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Anonymization: per-request incrementing id so the LLM can distinguish entities
|
|
106
|
+
// without the token being linkable across requests. Never collide with residue
|
|
107
|
+
// numbers already present in the request input.
|
|
108
|
+
const minForPrefix = this.minCountersByPrefix.get(prefix) || 0;
|
|
109
|
+
const count = Math.max(this.counters.get(entityType) || 0, minForPrefix) + 1;
|
|
110
|
+
this.counters.set(entityType, count);
|
|
111
|
+
placeholder = render(String(count));
|
|
112
|
+
}
|
|
33
113
|
this.forward.set(originalValue, placeholder);
|
|
34
114
|
if (this.method === 'pseudonymization') {
|
|
35
115
|
// Only store reverse mapping for pseudonymization (enables unmasking)
|
|
36
116
|
this.reverse.set(placeholder, originalValue);
|
|
117
|
+
// Scheme-less alias for URL placeholders: if the model reproduces the masked
|
|
118
|
+
// host without (or with a different) scheme, the bare form still unmasks to
|
|
119
|
+
// the bare origin, keeping whatever scheme the model chose.
|
|
120
|
+
if (isUrl && urlScheme) {
|
|
121
|
+
const bareKey = placeholder.slice(urlScheme.length);
|
|
122
|
+
if (!this.reverse.has(bareKey)) {
|
|
123
|
+
this.reverse.set(bareKey, originalValue.slice(urlScheme.length));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
37
126
|
}
|
|
38
127
|
return placeholder;
|
|
39
128
|
}
|
|
@@ -6,6 +6,28 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { EntityMatch, MaskingConfig } from './types';
|
|
8
8
|
import { ReplacementMap } from './replacementMap';
|
|
9
|
+
/**
|
|
10
|
+
* Minimum length of a detected value for it to be propagated (masked at ALL its
|
|
11
|
+
* exact occurrences across the request). Short values are masked only where a
|
|
12
|
+
* detector actually fired — propagating them risks carpet-masking common strings.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MIN_PROPAGATION_LENGTH = 12;
|
|
15
|
+
/**
|
|
16
|
+
* Propagate already-minted masks to EVERY exact occurrence of each detected value
|
|
17
|
+
* across the request's string nodes — including places no detector fired (e.g. a
|
|
18
|
+
* secret that was caught as `token=<secret>` in one node but rode through as
|
|
19
|
+
* `Authorization: Bearer <secret>` in another). This is the primary security fix:
|
|
20
|
+
* one value → one token → masked everywhere.
|
|
21
|
+
*
|
|
22
|
+
* Operates on parsed string nodes (system + messages subtree), so JSON-escaping in
|
|
23
|
+
* embedded tool_result strings is a non-issue. For values containing `"` or `\`, the
|
|
24
|
+
* JSON-escaped form is also searched (an identical secret embedded inside a
|
|
25
|
+
* JSON-in-string blob is escaped there). Mutates `body` in place; returns the number
|
|
26
|
+
* of occurrences replaced.
|
|
27
|
+
*/
|
|
28
|
+
export declare function propagateMaskedValues(body: any, map: ReplacementMap, opts?: {
|
|
29
|
+
minLength?: number;
|
|
30
|
+
}): number;
|
|
9
31
|
/**
|
|
10
32
|
* Replace all detected entities in text with their placeholders
|
|
11
33
|
* Returns the masked text
|
|
@@ -6,10 +6,87 @@
|
|
|
6
6
|
* Processes matches in reverse order of position to preserve character offsets.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.MIN_PROPAGATION_LENGTH = void 0;
|
|
10
|
+
exports.propagateMaskedValues = propagateMaskedValues;
|
|
9
11
|
exports.replaceEntities = replaceEntities;
|
|
10
12
|
exports.maskJsonValue = maskJsonValue;
|
|
11
13
|
const fabricatedData_1 = require("./fabricatedData");
|
|
12
14
|
const detectors_1 = require("./detectors");
|
|
15
|
+
/**
|
|
16
|
+
* Minimum length of a detected value for it to be propagated (masked at ALL its
|
|
17
|
+
* exact occurrences across the request). Short values are masked only where a
|
|
18
|
+
* detector actually fired — propagating them risks carpet-masking common strings.
|
|
19
|
+
*/
|
|
20
|
+
exports.MIN_PROPAGATION_LENGTH = 12;
|
|
21
|
+
const REGEX_META = /[.*+?^${}()|[\]\\]/g;
|
|
22
|
+
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
23
|
+
/**
|
|
24
|
+
* Propagate already-minted masks to EVERY exact occurrence of each detected value
|
|
25
|
+
* across the request's string nodes — including places no detector fired (e.g. a
|
|
26
|
+
* secret that was caught as `token=<secret>` in one node but rode through as
|
|
27
|
+
* `Authorization: Bearer <secret>` in another). This is the primary security fix:
|
|
28
|
+
* one value → one token → masked everywhere.
|
|
29
|
+
*
|
|
30
|
+
* Operates on parsed string nodes (system + messages subtree), so JSON-escaping in
|
|
31
|
+
* embedded tool_result strings is a non-issue. For values containing `"` or `\`, the
|
|
32
|
+
* JSON-escaped form is also searched (an identical secret embedded inside a
|
|
33
|
+
* JSON-in-string blob is escaped there). Mutates `body` in place; returns the number
|
|
34
|
+
* of occurrences replaced.
|
|
35
|
+
*/
|
|
36
|
+
function propagateMaskedValues(body, map, opts = {}) {
|
|
37
|
+
const minLength = opts.minLength ?? exports.MIN_PROPAGATION_LENGTH;
|
|
38
|
+
// Build (needle → placeholder) pairs. Include the JSON-escaped form of any value
|
|
39
|
+
// containing escapable characters so it also matches inside JSON-in-string blobs.
|
|
40
|
+
const needles = [];
|
|
41
|
+
for (const [value, placeholder] of map.forward) {
|
|
42
|
+
if (typeof value !== 'string' || value.length < minLength)
|
|
43
|
+
continue;
|
|
44
|
+
needles.push({ text: value, placeholder });
|
|
45
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
46
|
+
if (escaped !== value && escaped.length >= minLength) {
|
|
47
|
+
needles.push({ text: escaped, placeholder });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (needles.length === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
// Longest needle first so a value that is a substring of another is not shadowed.
|
|
53
|
+
needles.sort((a, b) => b.text.length - a.text.length);
|
|
54
|
+
const byText = new Map(needles.map(n => [n.text, n.placeholder]));
|
|
55
|
+
const alternation = new RegExp(needles.map(n => n.text.replace(REGEX_META, '\\$&')).join('|'), 'g');
|
|
56
|
+
let replaced = 0;
|
|
57
|
+
const replaceInString = (s) => s.replace(alternation, (matchStr, offset, whole) => {
|
|
58
|
+
// Word-boundary guard: don't fire mid-identifier when the value's own edge is
|
|
59
|
+
// a word char and the adjacent text char is too (would corrupt an identifier).
|
|
60
|
+
const before = whole[offset - 1];
|
|
61
|
+
const after = whole[offset + matchStr.length];
|
|
62
|
+
if (WORD_CHAR.test(matchStr[0]) && before !== undefined && WORD_CHAR.test(before))
|
|
63
|
+
return matchStr;
|
|
64
|
+
if (WORD_CHAR.test(matchStr[matchStr.length - 1]) && after !== undefined && WORD_CHAR.test(after))
|
|
65
|
+
return matchStr;
|
|
66
|
+
replaced++;
|
|
67
|
+
return byText.get(matchStr) ?? matchStr;
|
|
68
|
+
});
|
|
69
|
+
const walk = (value) => {
|
|
70
|
+
if (typeof value === 'string')
|
|
71
|
+
return replaceInString(value);
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
for (let i = 0; i < value.length; i++)
|
|
74
|
+
value[i] = walk(value[i]);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
if (value && typeof value === 'object') {
|
|
78
|
+
for (const k of Object.keys(value))
|
|
79
|
+
value[k] = walk(value[k]);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
};
|
|
84
|
+
if (body?.system !== undefined)
|
|
85
|
+
body.system = walk(body.system);
|
|
86
|
+
if (Array.isArray(body?.messages))
|
|
87
|
+
walk(body.messages);
|
|
88
|
+
return replaced;
|
|
89
|
+
}
|
|
13
90
|
/**
|
|
14
91
|
* Get the replacement strategy for an entity type from config
|
|
15
92
|
*/
|
|
@@ -39,8 +39,16 @@ class StreamUnmaskBuffer {
|
|
|
39
39
|
this.maxPrefixLength = p.length;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
// Add
|
|
43
|
-
|
|
42
|
+
// Add room for the id after the prefix: hash-stable ids are 8 digits
|
|
43
|
+
// (up to a few more under collision probing); legacy numeric ids are shorter.
|
|
44
|
+
this.maxPrefixLength += 16;
|
|
45
|
+
// Retention must also cover the longest exact key (URL-shaped placeholders like
|
|
46
|
+
// "https://masked-url-12345678.invalid" carry no MASKED_ prefix family).
|
|
47
|
+
for (const placeholder of map.reverse.keys()) {
|
|
48
|
+
if (placeholder.length > this.maxPrefixLength) {
|
|
49
|
+
this.maxPrefixLength = placeholder.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
44
52
|
// Build regex for complete placeholders
|
|
45
53
|
const placeholders = Array.from(map.reverse.keys())
|
|
46
54
|
.sort((a, b) => b.length - a.length);
|
|
@@ -57,13 +65,19 @@ class StreamUnmaskBuffer {
|
|
|
57
65
|
*/
|
|
58
66
|
append(text) {
|
|
59
67
|
this.buffer += text;
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
68
|
+
// Find the safe flush point on the RAW buffer first, then unmask only the
|
|
69
|
+
// flushed slice. Replacing before choosing the flush point corrupted longer
|
|
70
|
+
// placeholders split at a digit boundary: with both MASKED_PERSON_11 and
|
|
71
|
+
// MASKED_PERSON_113 in the map, a chunk ending in '…MASKED_PERSON_11'
|
|
72
|
+
// followed by '3…' must NOT be eagerly replaced as _11 plus a stray '3'.
|
|
73
|
+
// couldBePartialPlaceholder retains a complete placeholder that is a strict
|
|
74
|
+
// prefix of a longer one, so the ambiguous tail stays buffered until the
|
|
75
|
+
// next chunk (or flush) disambiguates it.
|
|
64
76
|
const safePoint = this.findSafeFlushPoint();
|
|
65
|
-
|
|
77
|
+
let flushed = this.buffer.slice(0, safePoint);
|
|
66
78
|
this.buffer = this.buffer.slice(safePoint);
|
|
79
|
+
this.placeholderRegex.lastIndex = 0;
|
|
80
|
+
flushed = flushed.replace(this.placeholderRegex, (match) => this.reverseMap.get(match) || match);
|
|
67
81
|
return flushed;
|
|
68
82
|
}
|
|
69
83
|
/**
|
|
@@ -81,7 +95,11 @@ class StreamUnmaskBuffer {
|
|
|
81
95
|
* Any suffix that could be the beginning of a known placeholder must be retained.
|
|
82
96
|
*/
|
|
83
97
|
findSafeFlushPoint() {
|
|
84
|
-
|
|
98
|
+
// No placeholders at all → nothing can ever match, flush everything.
|
|
99
|
+
// (knownPrefixes alone is not sufficient: URL-shaped placeholders like
|
|
100
|
+
// "https://masked-url-<id>.invalid" have no MASKED_*_ prefix family but
|
|
101
|
+
// still require suffix retention via the exact-key check below.)
|
|
102
|
+
if (this.knownPrefixes.length === 0 && this.reverseMap.size === 0)
|
|
85
103
|
return this.buffer.length;
|
|
86
104
|
const maxCheck = Math.min(this.buffer.length, this.maxPrefixLength);
|
|
87
105
|
for (let len = maxCheck; len >= 1; len--) {
|
|
@@ -49,6 +49,9 @@ const logger_1 = require("@libs/logger");
|
|
|
49
49
|
const logger = (0, logger_1.getDefaultLogger)();
|
|
50
50
|
const rateLimitManager_1 = __importDefault(require("./rateLimitManager"));
|
|
51
51
|
const usageTracker_1 = require("../utils/usageTracker");
|
|
52
|
+
const betaFeatureFilter_1 = require("../utils/betaFeatureFilter");
|
|
53
|
+
const betaFlagQuarantine = __importStar(require("./betaFlagQuarantine"));
|
|
54
|
+
const upstreamErrorBody_1 = require("../utils/upstreamErrorBody");
|
|
52
55
|
/**
|
|
53
56
|
* Process AWS Bedrock requests with payload transformation and routing
|
|
54
57
|
* @param options - Request processing options
|
|
@@ -92,39 +95,42 @@ async function processBedrockRequest(options) {
|
|
|
92
95
|
if (processedRequestBody.anthropic_version === undefined && isInvokeSubpath) {
|
|
93
96
|
processedRequestBody.anthropic_version = modelDetails.anthropic_version;
|
|
94
97
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
98
|
+
// Assemble anthropic_beta for invoke subpaths from all sources, then filter
|
|
99
|
+
// through the configured allowlist (supported_beta_headers) and denylist
|
|
100
|
+
// (excluded_beta_headers) so no unsupported flag can reach SAP AI Core —
|
|
101
|
+
// including flags injected via inject_beta_features or supplied in the body.
|
|
102
|
+
if (isInvokeSubpath) {
|
|
103
|
+
const headerFeatures = (0, betaFeatureFilter_1.parseAnthropicBetaHeader)(options.headers['anthropic-beta']);
|
|
104
|
+
// Header takes precedence over a body-supplied anthropic_beta (legacy behavior);
|
|
105
|
+
// body flags are now filtered too instead of passing through untouched.
|
|
106
|
+
const bodyFeatures = headerFeatures.length === 0 && Array.isArray(processedRequestBody.anthropic_beta)
|
|
107
|
+
? processedRequestBody.anthropic_beta.map(String)
|
|
108
|
+
: [];
|
|
109
|
+
const injectedFeatures = Array.isArray(modelDetails.inject_beta_features)
|
|
110
|
+
? modelDetails.inject_beta_features.map(String)
|
|
111
|
+
: [];
|
|
112
|
+
const candidates = (0, betaFeatureFilter_1.mergeBetaFeatures)(headerFeatures, bodyFeatures, injectedFeatures);
|
|
113
|
+
const filterOptions = {
|
|
114
|
+
supported: configService_1.default.getSupportedBetaHeaders(),
|
|
115
|
+
// Config denylist plus flags quarantined at runtime after upstream
|
|
116
|
+
// "invalid beta flag" 400s for this model (see betaFlagQuarantine).
|
|
117
|
+
excluded: (0, betaFeatureFilter_1.mergeBetaFeatures)(configService_1.default.getExcludedBetaHeaders(), betaFlagQuarantine.getQuarantinedFlags(modelId))
|
|
118
|
+
};
|
|
119
|
+
const betaFeatures = (0, betaFeatureFilter_1.filterBetaFeatures)(candidates, filterOptions);
|
|
120
|
+
const dropped = candidates.filter(feature => !betaFeatures.includes(feature));
|
|
121
|
+
if (dropped.length > 0) {
|
|
122
|
+
logger.debug('AwsBedrockService', `Filtered ${dropped.length} unsupported beta feature(s): ${dropped.join(', ')}`);
|
|
123
|
+
}
|
|
124
|
+
if (injectedFeatures.length > 0) {
|
|
125
|
+
logger.debug('AwsBedrockService', `Injected model-specific beta features: ${injectedFeatures.join(', ')}`);
|
|
111
126
|
}
|
|
112
127
|
if (betaFeatures.length > 0) {
|
|
113
128
|
processedRequestBody.anthropic_beta = betaFeatures;
|
|
114
129
|
logger.debug('AwsBedrockService', `Adding anthropic_beta features: ${betaFeatures.join(', ')}`);
|
|
115
130
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (isInvokeSubpath && modelDetails.inject_beta_features && Array.isArray(modelDetails.inject_beta_features)) {
|
|
119
|
-
if (!processedRequestBody.anthropic_beta) {
|
|
120
|
-
processedRequestBody.anthropic_beta = [];
|
|
121
|
-
}
|
|
122
|
-
for (const feature of modelDetails.inject_beta_features) {
|
|
123
|
-
if (!processedRequestBody.anthropic_beta.includes(feature)) {
|
|
124
|
-
processedRequestBody.anthropic_beta.push(feature);
|
|
125
|
-
}
|
|
131
|
+
else {
|
|
132
|
+
delete processedRequestBody.anthropic_beta;
|
|
126
133
|
}
|
|
127
|
-
logger.debug('AwsBedrockService', `Injected model-specific beta features: ${modelDetails.inject_beta_features.join(', ')}`);
|
|
128
134
|
}
|
|
129
135
|
if (isInvokeSubpath && isNativeSubpath) {
|
|
130
136
|
// Check if model explicitly disables prompt caching - if so, filter out cache_control parameters
|
|
@@ -320,7 +326,20 @@ async function handleNativeSubpath(options) {
|
|
|
320
326
|
catch (error) {
|
|
321
327
|
logger.error('AwsBedrockService', `Error in native request: ${error.message}`);
|
|
322
328
|
if (error.response) {
|
|
323
|
-
logger.error('AwsBedrockService', `SAP AI Core error response: ${error.response.status}`, error.response.data);
|
|
329
|
+
logger.error('AwsBedrockService', `SAP AI Core error response: ${error.response.status}`, undefined, { data: error.response.data });
|
|
330
|
+
if (debugRequestId) {
|
|
331
|
+
payloadLogger.savePayload(debugRequestId, '03_native_error_from_sap', {
|
|
332
|
+
status: error.response.status,
|
|
333
|
+
data: error.response.data
|
|
334
|
+
}, req, res);
|
|
335
|
+
}
|
|
336
|
+
const sentBetaFlags = Array.isArray(requestBody?.anthropic_beta)
|
|
337
|
+
? requestBody.anthropic_beta.map(String)
|
|
338
|
+
: [];
|
|
339
|
+
if (sentBetaFlags.length > 0
|
|
340
|
+
&& betaFlagQuarantine.isInvalidBetaFlagError(error.response.status, error.response.data)) {
|
|
341
|
+
betaFlagQuarantine.recordBetaFlagRejection(modelId, error.response.data, sentBetaFlags);
|
|
342
|
+
}
|
|
324
343
|
}
|
|
325
344
|
throw error;
|
|
326
345
|
}
|
|
@@ -412,7 +431,21 @@ async function handleEmulatedSubpath(options) {
|
|
|
412
431
|
isTimeout: error.isAxiosError && error.code === 'ECONNABORTED'
|
|
413
432
|
});
|
|
414
433
|
if (error.response) {
|
|
415
|
-
logger.error('AwsBedrockService', `SAP AI Core error response: ${error.response.status}`, error.response.data);
|
|
434
|
+
logger.error('AwsBedrockService', `SAP AI Core error response: ${error.response.status}`, undefined, { data: error.response.data });
|
|
435
|
+
if (debugRequestId) {
|
|
436
|
+
payloadLogger.savePayload(debugRequestId, '03_emulated_error_from_sap', {
|
|
437
|
+
status: error.response.status,
|
|
438
|
+
data: error.response.data
|
|
439
|
+
}, req, res);
|
|
440
|
+
}
|
|
441
|
+
// Converse payloads carry no anthropic_beta today — this hook is defense-in-depth should the transform ever forward beta flags.
|
|
442
|
+
const sentBetaFlags = Array.isArray(requestBody?.anthropic_beta)
|
|
443
|
+
? requestBody.anthropic_beta.map(String)
|
|
444
|
+
: [];
|
|
445
|
+
if (sentBetaFlags.length > 0
|
|
446
|
+
&& betaFlagQuarantine.isInvalidBetaFlagError(error.response.status, error.response.data)) {
|
|
447
|
+
betaFlagQuarantine.recordBetaFlagRejection(modelId, error.response.data, sentBetaFlags);
|
|
448
|
+
}
|
|
416
449
|
}
|
|
417
450
|
throw error;
|
|
418
451
|
}
|
|
@@ -926,14 +959,32 @@ async function handleNativeStreamingRequest(options) {
|
|
|
926
959
|
code: error.code,
|
|
927
960
|
isTimeout: error.isAxiosError && error.code === 'ECONNABORTED'
|
|
928
961
|
});
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
962
|
+
if (error.response) {
|
|
963
|
+
// responseType:'stream' delivers the error body as a Readable — drain it so
|
|
964
|
+
// SAP's actual error is loggable, persisted, and quarantine-matchable.
|
|
965
|
+
const upstreamErrorBody = await (0, upstreamErrorBody_1.readUpstreamErrorBody)(error.response);
|
|
966
|
+
logger.error('AwsBedrockService', `SAP AI Core error response: ${error.response.status}`, undefined, { data: upstreamErrorBody });
|
|
967
|
+
if (debugRequestId) {
|
|
968
|
+
payloadLogger.savePayload(debugRequestId, '03_native_streaming_error_from_sap', {
|
|
969
|
+
status: error.response.status,
|
|
970
|
+
data: upstreamErrorBody
|
|
971
|
+
}, req, res);
|
|
972
|
+
}
|
|
973
|
+
// Enhanced error logging for auth issues
|
|
974
|
+
if (error.response.status === 401) {
|
|
975
|
+
logger.error('AwsBedrockService', `Authentication failed (401) for request to: ${targetUrl}`);
|
|
976
|
+
// Log auth details if DEBUG is enabled
|
|
977
|
+
if (process.env.DEBUG === 'true') {
|
|
978
|
+
logger.debug('AwsBedrockService', 'DEBUG - Auth token used:', { token: authToken });
|
|
979
|
+
logger.debug('AwsBedrockService', 'DEBUG - Response headers: ' + (0, logger_1.createHeadersPreview)(error.response.headers));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const sentBetaFlags = Array.isArray(requestBody?.anthropic_beta)
|
|
983
|
+
? requestBody.anthropic_beta.map(String)
|
|
984
|
+
: [];
|
|
985
|
+
if (sentBetaFlags.length > 0
|
|
986
|
+
&& betaFlagQuarantine.isInvalidBetaFlagError(error.response.status, upstreamErrorBody)) {
|
|
987
|
+
betaFlagQuarantine.recordBetaFlagRejection(modelId, upstreamErrorBody, sentBetaFlags);
|
|
937
988
|
}
|
|
938
989
|
}
|
|
939
990
|
if (!res.writableEnded) {
|
|
@@ -1300,18 +1351,39 @@ async function handleEmulatedStreamingRequest(options) {
|
|
|
1300
1351
|
code: error.code,
|
|
1301
1352
|
isTimeout: error.isAxiosError && error.code === 'ECONNABORTED'
|
|
1302
1353
|
});
|
|
1303
|
-
|
|
1304
|
-
if (error.response
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1354
|
+
let upstreamErrorBody;
|
|
1355
|
+
if (error.response) {
|
|
1356
|
+
// responseType:'stream' delivers the error body as a Readable — drain it so
|
|
1357
|
+
// SAP's actual error is loggable, persisted, and quarantine-matchable.
|
|
1358
|
+
upstreamErrorBody = await (0, upstreamErrorBody_1.readUpstreamErrorBody)(error.response);
|
|
1359
|
+
logger.error('AwsBedrockService', `SAP AI Core error response: ${error.response.status}`, undefined, { data: upstreamErrorBody });
|
|
1360
|
+
if (debugRequestId) {
|
|
1361
|
+
payloadLogger.savePayload(debugRequestId, '03_emulated_streaming_error_from_sap', {
|
|
1362
|
+
status: error.response.status,
|
|
1363
|
+
data: upstreamErrorBody
|
|
1364
|
+
}, req, res);
|
|
1365
|
+
}
|
|
1366
|
+
// Enhanced error logging for auth issues
|
|
1367
|
+
if (error.response.status === 401) {
|
|
1368
|
+
logger.error('AwsBedrockService', `Authentication failed (401) for request to: ${targetUrl}`);
|
|
1369
|
+
// Log auth details if DEBUG is enabled
|
|
1370
|
+
if (process.env.DEBUG === 'true') {
|
|
1371
|
+
logger.debug('AwsBedrockService', 'DEBUG - Auth token used:', { token: authToken });
|
|
1372
|
+
logger.debug('AwsBedrockService', 'DEBUG - Response headers: ' + (0, logger_1.createHeadersPreview)(error.response.headers));
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// Converse payloads carry no anthropic_beta today — this hook is defense-in-depth should the transform ever forward beta flags.
|
|
1376
|
+
const sentBetaFlags = Array.isArray(requestBody?.anthropic_beta)
|
|
1377
|
+
? requestBody.anthropic_beta.map(String)
|
|
1378
|
+
: [];
|
|
1379
|
+
if (sentBetaFlags.length > 0
|
|
1380
|
+
&& betaFlagQuarantine.isInvalidBetaFlagError(error.response.status, upstreamErrorBody)) {
|
|
1381
|
+
betaFlagQuarantine.recordBetaFlagRejection(modelId, upstreamErrorBody, sentBetaFlags);
|
|
1311
1382
|
}
|
|
1312
1383
|
}
|
|
1313
1384
|
if (!res.writableEnded) {
|
|
1314
|
-
const message = error
|
|
1385
|
+
const message = upstreamErrorBody?.error?.message || upstreamErrorBody?.message
|
|
1386
|
+
|| error.message || "Failed to establish stream";
|
|
1315
1387
|
const status = error.response?.status || 500;
|
|
1316
1388
|
const errToSend = new Error(message);
|
|
1317
1389
|
errToSend.status = status;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function getQuarantinedFlags(modelId: string): string[];
|
|
2
|
+
export declare function quarantineFlags(modelId: string, flags: string[]): void;
|
|
3
|
+
export declare function clearQuarantine(): void;
|
|
4
|
+
/**
|
|
5
|
+
* Whether an upstream error response is a beta-flag rejection.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isInvalidBetaFlagError(status: number | undefined, errorData: any): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Flags to quarantine: those explicitly named in the error body when present
|
|
10
|
+
* (first-party-style errors name them), else all flags that were sent
|
|
11
|
+
* (Bedrock's bare "invalid beta flag" names none).
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveRejectedFlags(errorData: any, sentFlags: string[]): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Record a beta-flag rejection so subsequent requests for this model omit
|
|
16
|
+
* the offending flags. Returns the quarantined flags.
|
|
17
|
+
*/
|
|
18
|
+
export declare function recordBetaFlagRejection(modelId: string, errorData: any, sentFlags: string[]): string[];
|