@anuma/sdk 10.6.2 → 10.8.0
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/dist/expo/index.cjs +30 -2
- package/dist/expo/index.d.mts +163 -153
- package/dist/expo/index.d.ts +163 -153
- package/dist/expo/index.mjs +30 -2
- package/dist/index.d.mts +157 -153
- package/dist/index.d.ts +157 -153
- package/dist/react/{consolidate-RASCVKD2.mjs → consolidate-KM26Y2LT.mjs} +1 -1
- package/dist/react/index.cjs +6502 -6199
- package/dist/react/index.d.mts +477 -368
- package/dist/react/index.d.ts +477 -368
- package/dist/react/index.mjs +6020 -5721
- package/dist/server/index.cjs +7 -1
- package/dist/server/index.d.mts +10 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.mjs +7 -1
- package/package.json +1 -1
- package/dist/react/{chunk-6QCUNXU6.mjs → chunk-3VZG3QO5.mjs} +957 -957
|
@@ -43,1036 +43,1036 @@ function getLogger() {
|
|
|
43
43
|
return getLoggerState().logger;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// src/lib/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
// src/lib/internalFlowMarker.ts
|
|
47
|
+
var INTERNAL_FLOW_MARKER = "Anuma internal first-party flow (not user chat).";
|
|
48
|
+
function withInternalFlowMarker(systemPrompt) {
|
|
49
|
+
if (systemPrompt.includes(INTERNAL_FLOW_MARKER)) return systemPrompt;
|
|
50
|
+
return `${INTERNAL_FLOW_MARKER}
|
|
51
|
+
${systemPrompt}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/lib/chat/endpointOverride.ts
|
|
55
|
+
function validateEndpointOverride(override) {
|
|
56
|
+
const trimmed = override.trim();
|
|
57
|
+
if (trimmed === "") {
|
|
58
|
+
return {
|
|
59
|
+
valid: false,
|
|
60
|
+
message: 'endpointOverride must be a non-empty, root-relative path (e.g. "/api/v1/utility/responses"); received an empty or whitespace-only string'
|
|
61
|
+
};
|
|
58
62
|
}
|
|
59
|
-
|
|
63
|
+
if (trimmed.startsWith("//") || trimmed.includes("://")) {
|
|
64
|
+
return {
|
|
65
|
+
valid: false,
|
|
66
|
+
message: `endpointOverride must be a root-relative path (e.g. "/api/v1/utility/responses"), not a protocol-relative or absolute URL; received "${trimmed}"`
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { valid: true, endpoint: trimmed.startsWith("/") ? trimmed : `/${trimmed}` };
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
|
|
72
|
+
// src/lib/taskType.ts
|
|
73
|
+
var TASK_TYPE_HEADER = "X-Anuma-Task-Type";
|
|
74
|
+
function taskTypeHeader(taskType) {
|
|
75
|
+
return taskType ? { [TASK_TYPE_HEADER]: taskType } : {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/lib/memory/portalLlm.ts
|
|
79
|
+
function defaultBaseUrl() {
|
|
80
|
+
return typeof process !== "undefined" && process.env?.ANUMA_PORTAL_BASE_URL || BASE_URL;
|
|
81
|
+
}
|
|
82
|
+
var RESPONSE_FORMAT_OK = /* @__PURE__ */ new Set(["openai", "inclusionai", "deepseek"]);
|
|
83
|
+
var RESPONSE_SCHEMA_OK = /* @__PURE__ */ new Set(["openai"]);
|
|
84
|
+
var JSON_CONTRACT_REMINDER = "Output ONLY the strict JSON object requested. No prose, no explanation, no markdown.";
|
|
85
|
+
function supportsResponseFormat(model, variant = "json_object") {
|
|
86
|
+
const allow = variant === "json_schema" ? RESPONSE_SCHEMA_OK : RESPONSE_FORMAT_OK;
|
|
87
|
+
return model.split("/").some((seg) => allow.has(seg));
|
|
88
|
+
}
|
|
89
|
+
var RESPONSES_ONLY_MODEL_PREFIXES = ["gpt-5.6", "gpt-6-sol", "gpt-6-luna", "gpt-6-astra"];
|
|
90
|
+
function requiresResponsesTransport(model) {
|
|
91
|
+
return model.split("/").some((seg) => RESPONSES_ONLY_MODEL_PREFIXES.some((prefix) => seg.startsWith(prefix)));
|
|
92
|
+
}
|
|
93
|
+
var CHAT_TO_RESPONSES_FIELDS = {
|
|
94
|
+
max_completion_tokens: "max_output_tokens"
|
|
95
|
+
};
|
|
96
|
+
function responsesExtra(extra) {
|
|
97
|
+
if (!extra) return {};
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
100
|
+
const target = CHAT_TO_RESPONSES_FIELDS[key] ?? key;
|
|
101
|
+
if (target !== key && target in extra) continue;
|
|
102
|
+
out[target] = value;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function splitEndpoint(endpoint) {
|
|
107
|
+
const cut = endpoint.search(/[?#]/);
|
|
108
|
+
const rawPath = cut === -1 ? endpoint : endpoint.slice(0, cut);
|
|
109
|
+
return {
|
|
110
|
+
path: rawPath.replace(/\/+$/, ""),
|
|
111
|
+
rest: cut === -1 ? "" : endpoint.slice(cut)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
var FOREIGN_ENDPOINT_SUFFIX = {
|
|
115
|
+
chat: "/responses",
|
|
116
|
+
responses: "/chat/completions"
|
|
117
|
+
};
|
|
118
|
+
function endpointForTransport(endpoint, transport) {
|
|
119
|
+
const { path, rest } = splitEndpoint(endpoint);
|
|
120
|
+
const foreign = FOREIGN_ENDPOINT_SUFFIX[transport];
|
|
121
|
+
if (!path.endsWith(foreign)) return endpoint;
|
|
122
|
+
const own = FOREIGN_ENDPOINT_SUFFIX[transport === "chat" ? "responses" : "chat"];
|
|
123
|
+
return `${path.slice(0, -foreign.length)}${own}${rest}`;
|
|
124
|
+
}
|
|
125
|
+
function assertTransportMatchesEndpoint(transport, endpoint) {
|
|
126
|
+
const foreign = FOREIGN_ENDPOINT_SUFFIX[transport];
|
|
127
|
+
if (!splitEndpoint(endpoint).path.endsWith(foreign)) return;
|
|
128
|
+
throw new Error(
|
|
129
|
+
`endpointOverride "${endpoint}" is the other transport's endpoint, but transport is "${transport}". The request body is built for the transport, so this sends the wrong shape and 400s without retry.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
var TIME_BUDGET_EXHAUSTED = {
|
|
133
|
+
kind: "terminal",
|
|
134
|
+
code: "time-budget-exhausted",
|
|
135
|
+
reason: "time budget exhausted"
|
|
136
|
+
};
|
|
137
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
138
|
+
var RETRYABLE_HTTP = /* @__PURE__ */ new Set([408, 409, 425, 429]);
|
|
139
|
+
function isRetryableStatus(status) {
|
|
140
|
+
return status >= 500 || RETRYABLE_HTTP.has(status);
|
|
141
|
+
}
|
|
142
|
+
function parseRetryAfterMs(header) {
|
|
143
|
+
if (!header) return null;
|
|
144
|
+
const secs = Number(header);
|
|
145
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
|
|
146
|
+
const dateMs = Date.parse(header);
|
|
147
|
+
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function defaultBackoffMs(attempt) {
|
|
151
|
+
const base = Math.min(2e3, 250 * 2 ** (attempt - 1));
|
|
152
|
+
return base + Math.floor(Math.random() * 100);
|
|
153
|
+
}
|
|
154
|
+
function sleep(ms) {
|
|
155
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
156
|
+
}
|
|
157
|
+
async function resolvePortalAuthHeaders(auth, tag) {
|
|
158
|
+
if (auth.apiKey) {
|
|
159
|
+
return { "x-api-key": auth.apiKey };
|
|
160
|
+
}
|
|
161
|
+
if (auth.getToken) {
|
|
162
|
+
let token;
|
|
163
|
+
try {
|
|
164
|
+
token = await auth.getToken();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
getLogger().warn(`[${tag}] getToken threw`, err);
|
|
167
|
+
return null;
|
|
73
168
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
regex: /\b\d{3}[ ]?\d{2}[ ]?\d{4}\b/g,
|
|
78
|
-
context: /(?:\bssn\b|\bsocial\s+security(?:\s+(?:number|no\.?|#))?\b)[^\d\n]{0,12}$/i,
|
|
79
|
-
validate: (match) => {
|
|
80
|
-
const digits = match.replace(/\D/g, "");
|
|
81
|
-
if (digits.length !== 9) return false;
|
|
82
|
-
const area = parseInt(digits.slice(0, 3), 10);
|
|
83
|
-
return area !== 0 && area !== 666 && area < 900;
|
|
169
|
+
if (!token) {
|
|
170
|
+
getLogger().warn(`[${tag}] getToken returned no token`);
|
|
171
|
+
return null;
|
|
84
172
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
173
|
+
return { Authorization: `Bearer ${token}` };
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`[${tag}] Either apiKey or getToken must be provided`);
|
|
176
|
+
}
|
|
177
|
+
async function callPortalJsonCompletion(req) {
|
|
178
|
+
const log = getLogger();
|
|
179
|
+
const transport = req.transport ?? (requiresResponsesTransport(req.model) ? "responses" : "chat");
|
|
180
|
+
const autoUpgraded = req.transport === void 0 && transport === "responses";
|
|
181
|
+
let endpoint = transport === "responses" ? "/api/v1/responses" : "/api/v1/chat/completions";
|
|
182
|
+
if (req.endpointOverride !== void 0) {
|
|
183
|
+
const overrideValidation = validateEndpointOverride(req.endpointOverride);
|
|
184
|
+
if (!overrideValidation.valid) {
|
|
185
|
+
throw new Error(overrideValidation.message);
|
|
93
186
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
187
|
+
endpoint = overrideValidation.endpoint;
|
|
188
|
+
if (autoUpgraded) endpoint = endpointForTransport(endpoint, transport);
|
|
189
|
+
assertTransportMatchesEndpoint(transport, endpoint);
|
|
190
|
+
}
|
|
191
|
+
const maxAttempts = Math.max(1, req.maxAttempts ?? 3);
|
|
192
|
+
const startedAt = Date.now();
|
|
193
|
+
const deadlineAt = req.totalTimeoutMs === void 0 ? void 0 : startedAt + req.totalTimeoutMs;
|
|
194
|
+
const overBudget = () => deadlineAt !== void 0 && Date.now() >= deadlineAt;
|
|
195
|
+
let lastFailure;
|
|
196
|
+
let reinforce = false;
|
|
197
|
+
const giveUp = () => {
|
|
198
|
+
req.onFailure?.(lastFailure ?? { reason: "network", attempts: maxAttempts });
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
202
|
+
if (attempt > 1 && overBudget()) {
|
|
203
|
+
log.warn(`[${req.tag}] over time budget before attempt ${attempt}, giving up`);
|
|
204
|
+
lastFailure = { reason: "time-budget-exhausted", attempts: attempt - 1 };
|
|
205
|
+
break;
|
|
100
206
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
category: "API_KEY",
|
|
113
|
-
regex: /\b(?:sk|pk|api|key|token|secret|bearer|access)[_-]?[a-zA-Z0-9_-]{20,}\b/gi,
|
|
114
|
-
validate: (match) => {
|
|
115
|
-
const hasDigit = /\d/.test(match);
|
|
116
|
-
const hasMixedCase = /[a-z]/.test(match) && /[A-Z]/.test(match);
|
|
117
|
-
return hasDigit || hasMixedCase;
|
|
207
|
+
const attemptReq = reinforce ? { ...req, reinforceJsonContract: true } : req;
|
|
208
|
+
const outcome = await attemptPortalJson(
|
|
209
|
+
{ ...attemptReq, transport },
|
|
210
|
+
endpoint,
|
|
211
|
+
deadlineAt
|
|
212
|
+
);
|
|
213
|
+
if (outcome.kind === "ok") {
|
|
214
|
+
req.onAttempt?.({ attempt, ok: true });
|
|
215
|
+
return outcome.value;
|
|
118
216
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
217
|
+
req.onAttempt?.({
|
|
218
|
+
attempt,
|
|
219
|
+
ok: false,
|
|
220
|
+
reason: outcome.code,
|
|
221
|
+
...outcome.httpStatus !== void 0 && { httpStatus: outcome.httpStatus }
|
|
222
|
+
});
|
|
223
|
+
lastFailure = {
|
|
224
|
+
reason: outcome.code,
|
|
225
|
+
...outcome.httpStatus !== void 0 && { httpStatus: outcome.httpStatus },
|
|
226
|
+
attempts: attempt
|
|
227
|
+
};
|
|
228
|
+
if (outcome.code === "invalid-json" || outcome.code === "null-completion") reinforce = true;
|
|
229
|
+
if (outcome.kind === "terminal") {
|
|
230
|
+
log.warn(`[${req.tag}] ${outcome.reason}`);
|
|
231
|
+
return giveUp();
|
|
125
232
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
regex: /\b(?:(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12]\d|3[01])[/-](?:19|20)\d{2})\b/g,
|
|
130
|
-
context: /(?:\bdob\b|\bd\.?o\.?b\.?|\bborn\b|\bbirth\s?date\b|\bbirthdate\b|\bbirthday\b|\bdate\s+of\s+birth\b)[^\d\n]{0,12}$/i,
|
|
131
|
-
validate: (match) => {
|
|
132
|
-
const m = match.match(/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/);
|
|
133
|
-
if (!m) return false;
|
|
134
|
-
const month = parseInt(m[1], 10);
|
|
135
|
-
const day = parseInt(m[2], 10);
|
|
136
|
-
const year = parseInt(m[3], 10);
|
|
137
|
-
const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
138
|
-
const daysInMonth = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
139
|
-
return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[month - 1];
|
|
233
|
+
if (attempt >= maxAttempts) {
|
|
234
|
+
log.warn(`[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, giving up`);
|
|
235
|
+
break;
|
|
140
236
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return /[A-Za-z0-9_]/.test(ch) || ch.charCodeAt(0) > 127;
|
|
150
|
-
}
|
|
151
|
-
var PiiRedactor = class {
|
|
152
|
-
constructor(options = {}) {
|
|
153
|
-
this.disposed = false;
|
|
154
|
-
/** Maps original PII value → placeholder tag. */
|
|
155
|
-
this.valueToPlaceholder = /* @__PURE__ */ new Map();
|
|
156
|
-
/** Maps placeholder tag → original PII value (reverse lookup for de-anonymization). */
|
|
157
|
-
this.placeholderToValue = /* @__PURE__ */ new Map();
|
|
158
|
-
/** Next index per category for placeholder numbering. */
|
|
159
|
-
this.categoryCounters = /* @__PURE__ */ new Map();
|
|
160
|
-
/**
|
|
161
|
-
* Matches any placeholder-shaped token for this redactor's categories —
|
|
162
|
-
* bracketed `[EMAIL_1]` OR bare `EMAIL_1`, case-insensitive — capturing the
|
|
163
|
-
* body. Drives {@link restoreForStorage}: the extraction / consolidation
|
|
164
|
-
* models echo placeholders mangled (brackets dropped, re-cased), and a single
|
|
165
|
-
* pass with this matcher restores the assigned ones and flags the rest in one
|
|
166
|
-
* go. Built lazily from the union of pattern categories AND any categories
|
|
167
|
-
* minted at runtime (e.g. NER `PERSON`/`LOCATION`/`ORG`): `undefined` = stale
|
|
168
|
-
* (rebuild on next use), `null` = built but there are no categories.
|
|
169
|
-
*/
|
|
170
|
-
this.storagePattern = void 0;
|
|
171
|
-
/**
|
|
172
|
-
* Cached body→value lookups for {@link restoreForStorage}, built lazily from
|
|
173
|
-
* the assigned placeholders and invalidated on each mint. `exact` is keyed by
|
|
174
|
-
* the literal body (collision-proof); `ci` by upper-cased body for re-cased
|
|
175
|
-
* echoes, minus any `ciCollisions` where two distinct bodies share a form.
|
|
176
|
-
*/
|
|
177
|
-
this.storageLookup = null;
|
|
178
|
-
/** Whether the non-text-content bypass warning has already been emitted. */
|
|
179
|
-
this.warnedNonText = false;
|
|
180
|
-
/** Whether the NER-detector-failure warning has already been emitted. */
|
|
181
|
-
this.warnedNerFailure = false;
|
|
182
|
-
this.nerDetector = options.nerDetector;
|
|
183
|
-
if (options.patterns) {
|
|
184
|
-
this.patterns = options.patterns;
|
|
185
|
-
} else {
|
|
186
|
-
let base = PII_PATTERNS;
|
|
187
|
-
if (options.excludeCategories?.length) {
|
|
188
|
-
const excluded = new Set(options.excludeCategories);
|
|
189
|
-
base = base.filter((p) => !excluded.has(p.category));
|
|
190
|
-
}
|
|
191
|
-
this.patterns = options.extraPatterns ? [...base, ...options.extraPatterns] : base;
|
|
237
|
+
const backoff = (req.backoffMs ?? defaultBackoffMs)(attempt);
|
|
238
|
+
const delay = Math.max(backoff, outcome.retryAfterMs ?? 0);
|
|
239
|
+
if (overBudget() || req.totalTimeoutMs !== void 0 && Date.now() - startedAt + delay >= req.totalTimeoutMs) {
|
|
240
|
+
log.warn(
|
|
241
|
+
`[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, over time budget, giving up`
|
|
242
|
+
);
|
|
243
|
+
lastFailure = { reason: "time-budget-exhausted", attempts: attempt };
|
|
244
|
+
break;
|
|
192
245
|
}
|
|
193
|
-
|
|
246
|
+
log.warn(`[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, retrying`);
|
|
247
|
+
if (delay > 0) await sleep(delay);
|
|
194
248
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
* "email 1 of 3".
|
|
202
|
-
*/
|
|
203
|
-
buildStoragePattern() {
|
|
204
|
-
const categories = [.../* @__PURE__ */ new Set([...this.patternCategories, ...this.categoryCounters.keys()])];
|
|
205
|
-
if (categories.length === 0) return null;
|
|
206
|
-
const categoryAlt = categories.sort((a, b) => b.length - a.length).map(escapeRegExp).join("|");
|
|
207
|
-
return new RegExp(`\\[((?:${categoryAlt})_\\d+)\\]|\\b((?:${categoryAlt})_\\d+)\\b`, "gi");
|
|
249
|
+
return giveUp();
|
|
250
|
+
}
|
|
251
|
+
async function attemptPortalJson(req, endpoint, deadlineAt) {
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
if (deadlineAt === void 0 || deadlineAt - Date.now() > MAX_TIMER_DELAY_MS) {
|
|
254
|
+
return requestPortalJson(req, endpoint, controller);
|
|
208
255
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
256
|
+
const remainingMs = deadlineAt - Date.now();
|
|
257
|
+
if (remainingMs <= 0) return TIME_BUDGET_EXHAUSTED;
|
|
258
|
+
let timer;
|
|
259
|
+
try {
|
|
260
|
+
const outcome = await Promise.race([
|
|
261
|
+
new Promise((resolve) => {
|
|
262
|
+
timer = setTimeout(() => {
|
|
263
|
+
resolve(TIME_BUDGET_EXHAUSTED);
|
|
264
|
+
controller.abort();
|
|
265
|
+
}, remainingMs);
|
|
266
|
+
}),
|
|
267
|
+
requestPortalJson(req, endpoint, controller, deadlineAt)
|
|
268
|
+
]);
|
|
269
|
+
return Date.now() >= deadlineAt ? TIME_BUDGET_EXHAUSTED : outcome;
|
|
270
|
+
} finally {
|
|
271
|
+
clearTimeout(timer);
|
|
214
272
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
273
|
+
}
|
|
274
|
+
async function requestPortalJson(req, endpoint, controller, deadlineAt) {
|
|
275
|
+
const baseUrl = req.baseUrl ?? defaultBaseUrl();
|
|
276
|
+
const fetchImpl = req.fetchFn ?? fetch;
|
|
277
|
+
const authHeaders = await resolvePortalAuthHeaders(req, req.tag);
|
|
278
|
+
const remainingMs = deadlineAt === void 0 ? Infinity : deadlineAt - Date.now();
|
|
279
|
+
if (controller.signal.aborted || remainingMs <= 0) return TIME_BUDGET_EXHAUSTED;
|
|
280
|
+
if (authHeaders === null)
|
|
281
|
+
return { kind: "terminal", code: "auth-unavailable", reason: "auth unavailable (no token)" };
|
|
282
|
+
const timeoutMs = Math.min(req.timeoutMs ?? 6e4, remainingMs);
|
|
283
|
+
const usesPrefill = req.transport !== "responses" && req.model.startsWith("anthropic/");
|
|
284
|
+
const prefill = usesPrefill ? "{" : "";
|
|
285
|
+
const messages = [
|
|
286
|
+
{ role: "system", content: withInternalFlowMarker(req.systemPrompt) },
|
|
287
|
+
...req.reinforceJsonContract ? [{ role: "system", content: JSON_CONTRACT_REMINDER }] : [],
|
|
288
|
+
{ role: "user", content: req.userMessage }
|
|
289
|
+
];
|
|
290
|
+
if (prefill) messages.push({ role: "assistant", content: prefill });
|
|
291
|
+
const supportsJsonObjectFormat = supportsResponseFormat(req.model);
|
|
292
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
293
|
+
const requestBody = req.transport === "responses" ? {
|
|
294
|
+
model: req.model,
|
|
295
|
+
input: messages,
|
|
296
|
+
...req.reasoning && { reasoning: req.reasoning },
|
|
297
|
+
...responsesExtra(req.extra)
|
|
298
|
+
} : {
|
|
299
|
+
model: req.model,
|
|
300
|
+
messages,
|
|
301
|
+
...supportsJsonObjectFormat && { response_format: { type: "json_object" } },
|
|
302
|
+
...req.extra
|
|
303
|
+
};
|
|
304
|
+
if (req.transport === "responses" || !supportsJsonObjectFormat) {
|
|
305
|
+
delete requestBody.response_format;
|
|
222
306
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
307
|
+
let response;
|
|
308
|
+
try {
|
|
309
|
+
response = await fetchImpl(`${baseUrl}${endpoint}`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
...authHeaders,
|
|
313
|
+
...taskTypeHeader(req.taskType),
|
|
314
|
+
"Content-Type": "application/json"
|
|
315
|
+
},
|
|
316
|
+
body: JSON.stringify(requestBody),
|
|
317
|
+
signal: controller.signal
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
return {
|
|
322
|
+
kind: "retryable",
|
|
323
|
+
code: "network",
|
|
324
|
+
reason: `fetch failed: ${err.message}`
|
|
325
|
+
};
|
|
241
326
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
while ((m = regex.exec(text)) !== null) {
|
|
254
|
-
const value = m[0];
|
|
255
|
-
if (pattern.validate && !pattern.validate(value)) continue;
|
|
256
|
-
if (pattern.context) {
|
|
257
|
-
const before = text.slice(Math.max(0, m.index - 40), m.index);
|
|
258
|
-
if (!pattern.context.test(before)) continue;
|
|
259
|
-
}
|
|
260
|
-
found.push({ match: value, index: m.index });
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
const reason = `portal returned ${response.status}`;
|
|
330
|
+
if (response.status === 401) {
|
|
331
|
+
const tokenAuth = !req.apiKey && !!req.getToken;
|
|
332
|
+
return tokenAuth ? {
|
|
333
|
+
kind: "retryable",
|
|
334
|
+
code: "http-retryable",
|
|
335
|
+
reason: `${reason} (token may be expired; refreshing)`,
|
|
336
|
+
httpStatus: response.status
|
|
337
|
+
} : { kind: "terminal", code: "http-terminal", reason, httpStatus: response.status };
|
|
261
338
|
}
|
|
262
|
-
|
|
339
|
+
if (!isRetryableStatus(response.status))
|
|
340
|
+
return { kind: "terminal", code: "http-terminal", reason, httpStatus: response.status };
|
|
341
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
342
|
+
return {
|
|
343
|
+
kind: "retryable",
|
|
344
|
+
code: "http-retryable",
|
|
345
|
+
reason,
|
|
346
|
+
httpStatus: response.status,
|
|
347
|
+
...retryAfterMs !== null && { retryAfterMs }
|
|
348
|
+
};
|
|
263
349
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const matches = [];
|
|
275
|
-
let redacted = text;
|
|
276
|
-
for (const pattern of this.patterns) {
|
|
277
|
-
const found = this.findMatches(redacted, pattern);
|
|
278
|
-
if (found.length === 0) continue;
|
|
279
|
-
let result = "";
|
|
280
|
-
let cursor = 0;
|
|
281
|
-
for (const { match, index } of found) {
|
|
282
|
-
const placeholder = makeReplacement(pattern.category, match);
|
|
283
|
-
matches.push({ category: pattern.category, original: match, placeholder });
|
|
284
|
-
result += redacted.slice(cursor, index) + placeholder;
|
|
285
|
-
cursor = index + match.length;
|
|
286
|
-
}
|
|
287
|
-
result += redacted.slice(cursor);
|
|
288
|
-
redacted = result;
|
|
289
|
-
}
|
|
290
|
-
return { text: redacted, matches };
|
|
350
|
+
let body;
|
|
351
|
+
try {
|
|
352
|
+
body = await response.json();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
return {
|
|
356
|
+
kind: "retryable",
|
|
357
|
+
code: "body-parse-failed",
|
|
358
|
+
reason: `response body parse failed: ${err.message}`
|
|
359
|
+
};
|
|
291
360
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
logPortalUsage(body, req.tag);
|
|
363
|
+
if (isModerationResponse(body)) {
|
|
364
|
+
return {
|
|
365
|
+
kind: "terminal",
|
|
366
|
+
code: "content-flagged",
|
|
367
|
+
reason: "portal moderation refused the request"
|
|
368
|
+
};
|
|
298
369
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
maskText(text) {
|
|
307
|
-
return this.scan(text, (category) => `[${category}]`).text;
|
|
370
|
+
const rawContent = extractCompletionContent(body);
|
|
371
|
+
if (!rawContent) {
|
|
372
|
+
return {
|
|
373
|
+
kind: "retryable",
|
|
374
|
+
code: "empty-content",
|
|
375
|
+
reason: "portal response had no completion content"
|
|
376
|
+
};
|
|
308
377
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
return spans;
|
|
378
|
+
const looksLikeContinuation = prefill && /^\s*"/.test(rawContent);
|
|
379
|
+
const content = looksLikeContinuation ? prefill + rawContent : rawContent;
|
|
380
|
+
const candidate = extractJsonCandidate(content);
|
|
381
|
+
let value;
|
|
382
|
+
try {
|
|
383
|
+
value = JSON.parse(candidate);
|
|
384
|
+
} catch {
|
|
385
|
+
return {
|
|
386
|
+
kind: "retryable",
|
|
387
|
+
code: "invalid-json",
|
|
388
|
+
reason: "invalid-json"
|
|
389
|
+
};
|
|
325
390
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
* spans, and snap each to whole-word boundaries. Snapping prevents a detector
|
|
329
|
-
* that tagged only part of a word (a common WordPiece artifact — e.g. "Strip"
|
|
330
|
-
* of "Stripe") from producing a corrupting partial placeholder like
|
|
331
|
-
* `[ORG_1]e`.
|
|
332
|
-
*/
|
|
333
|
-
normalizeNerSpans(text, raw) {
|
|
334
|
-
if (!Array.isArray(raw)) return [];
|
|
335
|
-
const out = [];
|
|
336
|
-
raw.forEach((s, i) => {
|
|
337
|
-
if (!Number.isFinite(s.start) || !Number.isFinite(s.end)) return;
|
|
338
|
-
let start = Math.max(0, Math.min(Math.trunc(s.start), text.length));
|
|
339
|
-
let end = Math.max(0, Math.min(Math.trunc(s.end), text.length));
|
|
340
|
-
if (end <= start) return;
|
|
341
|
-
while (start > 0 && isWordChar(text[start - 1])) start--;
|
|
342
|
-
while (end < text.length && isWordChar(text[end])) end++;
|
|
343
|
-
const category = String(s.category ?? "").trim();
|
|
344
|
-
if (!category) return;
|
|
345
|
-
out.push({ start, end, category, priority: 1, order: i });
|
|
346
|
-
});
|
|
347
|
-
return out;
|
|
391
|
+
if (value === null) {
|
|
392
|
+
return { kind: "retryable", code: "null-completion", reason: "completion parsed to null" };
|
|
348
393
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
394
|
+
return { kind: "ok", value };
|
|
395
|
+
}
|
|
396
|
+
function extractJsonCandidate(raw) {
|
|
397
|
+
const trimmed = raw.trim();
|
|
398
|
+
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
399
|
+
const body = fence ? fence[1].trim() : trimmed;
|
|
400
|
+
const start = body.search(/[{[]/);
|
|
401
|
+
if (start < 0) return body;
|
|
402
|
+
const open = body[start];
|
|
403
|
+
const close = open === "{" ? "}" : "]";
|
|
404
|
+
let depth = 0;
|
|
405
|
+
let inString = false;
|
|
406
|
+
let escape = false;
|
|
407
|
+
for (let i = start; i < body.length; i++) {
|
|
408
|
+
const ch = body[i];
|
|
409
|
+
if (escape) {
|
|
410
|
+
escape = false;
|
|
411
|
+
continue;
|
|
358
412
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
* span that overlaps one already placed. Regex (priority 0) is placed before
|
|
363
|
-
* NER (priority 1), and within regex by ascending pattern index — so regex
|
|
364
|
-
* always wins on overlap and the regex-only result reproduces the sync scan's
|
|
365
|
-
* sequential "earlier pattern wins". O(n²) but n (spans per message) is tiny.
|
|
366
|
-
*
|
|
367
|
-
* The returned spans stay in `(priority, order, start)` order — i.e. mint
|
|
368
|
-
* order — which {@link rebuildSpans} relies on.
|
|
369
|
-
*/
|
|
370
|
-
resolveOverlaps(spans) {
|
|
371
|
-
const ordered = [...spans].sort(
|
|
372
|
-
(a, b) => a.priority - b.priority || a.order - b.order || a.start - b.start
|
|
373
|
-
);
|
|
374
|
-
const placed = [];
|
|
375
|
-
for (const s of ordered) {
|
|
376
|
-
if (!placed.some((p) => s.start < p.end && p.start < s.end)) placed.push(s);
|
|
413
|
+
if (ch === "\\" && inString) {
|
|
414
|
+
escape = true;
|
|
415
|
+
continue;
|
|
377
416
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
* Rebuild `text` from resolved (non-overlapping) spans. `spans` must arrive in
|
|
382
|
-
* mint order — `(priority, order, start)`, as {@link resolveOverlaps} returns —
|
|
383
|
-
* so placeholders are MINTED by iterating it directly (regex categories first,
|
|
384
|
-
* in pattern then document order, matching the sync scan's numbering). The
|
|
385
|
-
* string is then EMITTED in document order.
|
|
386
|
-
*/
|
|
387
|
-
rebuildSpans(text, spans, makeReplacement) {
|
|
388
|
-
const placeholderByStart = /* @__PURE__ */ new Map();
|
|
389
|
-
const matches = [];
|
|
390
|
-
for (const s of spans) {
|
|
391
|
-
const value = text.slice(s.start, s.end);
|
|
392
|
-
const placeholder = makeReplacement(s.category, value);
|
|
393
|
-
placeholderByStart.set(s.start, placeholder);
|
|
394
|
-
matches.push({ category: s.category, original: value, placeholder });
|
|
417
|
+
if (ch === '"') {
|
|
418
|
+
inString = !inString;
|
|
419
|
+
continue;
|
|
395
420
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
cursor = s.end;
|
|
421
|
+
if (inString) continue;
|
|
422
|
+
if (ch === open) depth++;
|
|
423
|
+
else if (ch === close) {
|
|
424
|
+
depth--;
|
|
425
|
+
if (depth === 0) return body.slice(start, i + 1);
|
|
402
426
|
}
|
|
403
|
-
result += text.slice(cursor);
|
|
404
|
-
return { text: result, matches };
|
|
405
427
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
428
|
+
return body;
|
|
429
|
+
}
|
|
430
|
+
function asRecord(value) {
|
|
431
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
432
|
+
}
|
|
433
|
+
function asCount(value) {
|
|
434
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
435
|
+
}
|
|
436
|
+
function logPortalUsage(body, tag) {
|
|
437
|
+
try {
|
|
438
|
+
const root = asRecord(body);
|
|
439
|
+
if (!root) return;
|
|
440
|
+
const usage = asRecord(root.usage);
|
|
441
|
+
const promptTokens = asCount(usage?.prompt_tokens) ?? asCount(usage?.input_tokens);
|
|
442
|
+
const completionTokens = asCount(usage?.completion_tokens) ?? asCount(usage?.output_tokens);
|
|
443
|
+
if (promptTokens === void 0 && completionTokens === void 0) return;
|
|
444
|
+
const cachedTokens = asCount(asRecord(root.portal)?.cached_tokens) ?? asCount(asRecord(usage?.prompt_tokens_details)?.cached_tokens) ?? asCount(asRecord(usage?.input_tokens_details)?.cached_tokens) ?? 0;
|
|
445
|
+
const reasoningTokens = asCount(asRecord(usage?.output_tokens_details)?.reasoning_tokens) ?? asCount(asRecord(usage?.completion_tokens_details)?.reasoning_tokens);
|
|
446
|
+
const parts = [];
|
|
447
|
+
if (promptTokens !== void 0) parts.push(`prompt=${promptTokens}`);
|
|
448
|
+
if (completionTokens !== void 0) parts.push(`completion=${completionTokens}`);
|
|
449
|
+
if (reasoningTokens !== void 0) parts.push(`reasoning=${reasoningTokens}`);
|
|
450
|
+
parts.push(`cached=${cachedTokens}`);
|
|
451
|
+
getLogger().debug(`[${tag}] usage ${parts.join(" ")}`);
|
|
452
|
+
} catch {
|
|
412
453
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
454
|
+
}
|
|
455
|
+
function isModerationResponse(body) {
|
|
456
|
+
return typeof body === "object" && body !== null && body.id === "moderation";
|
|
457
|
+
}
|
|
458
|
+
function extractCompletionContent(body) {
|
|
459
|
+
if (typeof body !== "object" || body === null) return null;
|
|
460
|
+
const responsesText = extractResponsesContent(body);
|
|
461
|
+
if (responsesText !== null) return responsesText;
|
|
462
|
+
const choices = body.choices;
|
|
463
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
464
|
+
const first = choices[0];
|
|
465
|
+
const content = first?.message?.content;
|
|
466
|
+
return typeof content === "string" ? content : null;
|
|
467
|
+
}
|
|
468
|
+
function extractResponsesContent(body) {
|
|
469
|
+
const root = asRecord(body);
|
|
470
|
+
if (!root) return null;
|
|
471
|
+
if (typeof root.output_text === "string" && root.output_text !== "") return root.output_text;
|
|
472
|
+
if (!Array.isArray(root.output)) return null;
|
|
473
|
+
const text = root.output.filter((item) => asRecord(item)?.type === "message").flatMap((item) => Array.isArray(item.content) ? item.content : []).map((part) => asRecord(part)?.text).filter((t) => typeof t === "string").join("");
|
|
474
|
+
return text;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/lib/pii/patterns.ts
|
|
478
|
+
function luhnCheck(digits) {
|
|
479
|
+
let sum = 0;
|
|
480
|
+
let alternate = false;
|
|
481
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
482
|
+
let n = parseInt(digits[i], 10);
|
|
483
|
+
if (alternate) {
|
|
484
|
+
n *= 2;
|
|
485
|
+
if (n > 9) n -= 9;
|
|
486
|
+
}
|
|
487
|
+
sum += n;
|
|
488
|
+
alternate = !alternate;
|
|
426
489
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
490
|
+
return sum % 10 === 0;
|
|
491
|
+
}
|
|
492
|
+
var PII_PATTERNS = [
|
|
493
|
+
{
|
|
494
|
+
category: "EMAIL",
|
|
495
|
+
regex: /\b[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\.[a-zA-Z]{2,24}\b/g
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
category: "SSN",
|
|
499
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
500
|
+
validate: (match) => {
|
|
501
|
+
const parts = match.split("-");
|
|
502
|
+
const area = parseInt(parts[0], 10);
|
|
503
|
+
return area !== 0 && area !== 666 && area < 900;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
category: "SSN",
|
|
508
|
+
regex: /\b\d{3}[ ]?\d{2}[ ]?\d{4}\b/g,
|
|
509
|
+
context: /(?:\bssn\b|\bsocial\s+security(?:\s+(?:number|no\.?|#))?\b)[^\d\n]{0,12}$/i,
|
|
510
|
+
validate: (match) => {
|
|
511
|
+
const digits = match.replace(/\D/g, "");
|
|
512
|
+
if (digits.length !== 9) return false;
|
|
513
|
+
const area = parseInt(digits.slice(0, 3), 10);
|
|
514
|
+
return area !== 0 && area !== 666 && area < 900;
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
category: "CREDIT_CARD",
|
|
519
|
+
regex: /\b\d{4}[\s-]\d{4}[\s-]\d{4}[\s-]\d{1,7}\b|\b\d{13,19}\b/g,
|
|
520
|
+
validate: (match) => {
|
|
521
|
+
const digits = match.replace(/\D/g, "");
|
|
522
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
523
|
+
return luhnCheck(digits);
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
category: "IP_ADDRESS",
|
|
528
|
+
regex: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g,
|
|
529
|
+
validate: (match) => {
|
|
530
|
+
return match !== "0.0.0.0" && match !== "127.0.0.1" && match !== "255.255.255.255";
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
category: "IP_ADDRESS",
|
|
535
|
+
regex: /(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))/g,
|
|
536
|
+
validate: (match) => match !== "::1" && match !== "::"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
category: "PHONE",
|
|
540
|
+
regex: /(?:\+\d{1,3}[\s.-])?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b|\+\d{10,15}\b/g
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
category: "API_KEY",
|
|
544
|
+
regex: /\b(?:sk|pk|api|key|token|secret|bearer|access)[_-]?[a-zA-Z0-9_-]{20,}\b/gi,
|
|
545
|
+
validate: (match) => {
|
|
546
|
+
const hasDigit = /\d/.test(match);
|
|
547
|
+
const hasMixedCase = /[a-z]/.test(match) && /[A-Z]/.test(match);
|
|
548
|
+
return hasDigit || hasMixedCase;
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
category: "US_ADDRESS",
|
|
553
|
+
regex: /\b\d{1,5}[ \t]+(?:[A-Z][a-z]+[ \t]+){1,3}(?:St(?:reet)?|Ave(?:nue)?|Blvd|Boulevard|Dr(?:ive)?|Ln|Lane|Rd|Road|Ct|Court|Pl(?:ace)?|Way|Cir(?:cle)?|Pkwy|Parkway|Hwy|Highway)\.?(?=[\s,.;:!?)]|$)/g,
|
|
554
|
+
validate: (match) => {
|
|
555
|
+
return !/^0/.test(match.trim());
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
category: "DATE_OF_BIRTH",
|
|
560
|
+
regex: /\b(?:(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12]\d|3[01])[/-](?:19|20)\d{2})\b/g,
|
|
561
|
+
context: /(?:\bdob\b|\bd\.?o\.?b\.?|\bborn\b|\bbirth\s?date\b|\bbirthdate\b|\bbirthday\b|\bdate\s+of\s+birth\b)[^\d\n]{0,12}$/i,
|
|
562
|
+
validate: (match) => {
|
|
563
|
+
const m = match.match(/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/);
|
|
564
|
+
if (!m) return false;
|
|
565
|
+
const month = parseInt(m[1], 10);
|
|
566
|
+
const day = parseInt(m[2], 10);
|
|
567
|
+
const year = parseInt(m[3], 10);
|
|
568
|
+
const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
569
|
+
const daysInMonth = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
570
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[month - 1];
|
|
571
|
+
}
|
|
435
572
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
// src/lib/pii/redactor.ts
|
|
576
|
+
function escapeRegExp(value) {
|
|
577
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
578
|
+
}
|
|
579
|
+
function isWordChar(ch) {
|
|
580
|
+
return /[A-Za-z0-9_]/.test(ch) || ch.charCodeAt(0) > 127;
|
|
581
|
+
}
|
|
582
|
+
var PiiRedactor = class {
|
|
583
|
+
constructor(options = {}) {
|
|
584
|
+
this.disposed = false;
|
|
585
|
+
/** Maps original PII value → placeholder tag. */
|
|
586
|
+
this.valueToPlaceholder = /* @__PURE__ */ new Map();
|
|
587
|
+
/** Maps placeholder tag → original PII value (reverse lookup for de-anonymization). */
|
|
588
|
+
this.placeholderToValue = /* @__PURE__ */ new Map();
|
|
589
|
+
/** Next index per category for placeholder numbering. */
|
|
590
|
+
this.categoryCounters = /* @__PURE__ */ new Map();
|
|
591
|
+
/**
|
|
592
|
+
* Matches any placeholder-shaped token for this redactor's categories —
|
|
593
|
+
* bracketed `[EMAIL_1]` OR bare `EMAIL_1`, case-insensitive — capturing the
|
|
594
|
+
* body. Drives {@link restoreForStorage}: the extraction / consolidation
|
|
595
|
+
* models echo placeholders mangled (brackets dropped, re-cased), and a single
|
|
596
|
+
* pass with this matcher restores the assigned ones and flags the rest in one
|
|
597
|
+
* go. Built lazily from the union of pattern categories AND any categories
|
|
598
|
+
* minted at runtime (e.g. NER `PERSON`/`LOCATION`/`ORG`): `undefined` = stale
|
|
599
|
+
* (rebuild on next use), `null` = built but there are no categories.
|
|
600
|
+
*/
|
|
601
|
+
this.storagePattern = void 0;
|
|
602
|
+
/**
|
|
603
|
+
* Cached body→value lookups for {@link restoreForStorage}, built lazily from
|
|
604
|
+
* the assigned placeholders and invalidated on each mint. `exact` is keyed by
|
|
605
|
+
* the literal body (collision-proof); `ci` by upper-cased body for re-cased
|
|
606
|
+
* echoes, minus any `ciCollisions` where two distinct bodies share a form.
|
|
607
|
+
*/
|
|
608
|
+
this.storageLookup = null;
|
|
609
|
+
/** Whether the non-text-content bypass warning has already been emitted. */
|
|
610
|
+
this.warnedNonText = false;
|
|
611
|
+
/** Whether the NER-detector-failure warning has already been emitted. */
|
|
612
|
+
this.warnedNerFailure = false;
|
|
613
|
+
this.nerDetector = options.nerDetector;
|
|
614
|
+
if (options.patterns) {
|
|
615
|
+
this.patterns = options.patterns;
|
|
616
|
+
} else {
|
|
617
|
+
let base = PII_PATTERNS;
|
|
618
|
+
if (options.excludeCategories?.length) {
|
|
619
|
+
const excluded = new Set(options.excludeCategories);
|
|
620
|
+
base = base.filter((p) => !excluded.has(p.category));
|
|
460
621
|
}
|
|
461
|
-
|
|
622
|
+
this.patterns = options.extraPatterns ? [...base, ...options.extraPatterns] : base;
|
|
462
623
|
}
|
|
463
|
-
|
|
624
|
+
this.patternCategories = [...new Set(this.patterns.map((p) => String(p.category)))];
|
|
464
625
|
}
|
|
465
626
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
* before they go back over the wire. Placeholders the model already emitted
|
|
473
|
-
* are inert here (they don't match any PII pattern), so re-redaction is safe.
|
|
627
|
+
* Build the {@link storagePattern} from the union of pattern categories and
|
|
628
|
+
* any categories minted at runtime (NER entities arrive only when redaction
|
|
629
|
+
* runs, so this can't be fixed at construction). Bracketed `[EMAIL_1]` OR
|
|
630
|
+
* bare `EMAIL_1`, capturing the body, case-insensitive, global. `\d+` (not a
|
|
631
|
+
* fixed body) spans any index; `\b` on the bare arm keeps it off prose like
|
|
632
|
+
* "email 1 of 3".
|
|
474
633
|
*/
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (part.type === "text" && part.text) {
|
|
481
|
-
const result = this.redactText(part.text);
|
|
482
|
-
allMatches.push(...result.matches);
|
|
483
|
-
if (result.matches.length > 0) {
|
|
484
|
-
return { ...part, text: result.text };
|
|
485
|
-
}
|
|
486
|
-
} else if (part.type !== "text") {
|
|
487
|
-
this.warnNonTextBypass();
|
|
488
|
-
}
|
|
489
|
-
return part;
|
|
490
|
-
});
|
|
491
|
-
return { ...msg, content: newContent };
|
|
492
|
-
});
|
|
493
|
-
return { messages: redactedMessages, matches: allMatches };
|
|
494
|
-
}
|
|
495
|
-
/** Emit the non-text bypass warning at most once per redactor instance. */
|
|
496
|
-
warnNonTextBypass() {
|
|
497
|
-
if (this.warnedNonText) return;
|
|
498
|
-
this.warnedNonText = true;
|
|
499
|
-
console.warn(
|
|
500
|
-
"[PiiRedactor] A message contains non-text content (images, files, or attachments) that is NOT scanned for PII \u2014 only text parts are redacted."
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
/** Emit the NER-detector-failure warning at most once per redactor instance. */
|
|
504
|
-
warnNerFailure(err) {
|
|
505
|
-
if (this.warnedNerFailure) return;
|
|
506
|
-
this.warnedNerFailure = true;
|
|
507
|
-
console.warn(
|
|
508
|
-
"[PiiRedactor] The NER detector threw; falling back to regex-only redaction for this call's unstructured PII. The detector is still retried on subsequent calls; this warning is emitted only once.",
|
|
509
|
-
err
|
|
510
|
-
);
|
|
634
|
+
buildStoragePattern() {
|
|
635
|
+
const categories = [.../* @__PURE__ */ new Set([...this.patternCategories, ...this.categoryCounters.keys()])];
|
|
636
|
+
if (categories.length === 0) return null;
|
|
637
|
+
const categoryAlt = categories.sort((a, b) => b.length - a.length).map(escapeRegExp).join("|");
|
|
638
|
+
return new RegExp(`\\[((?:${categoryAlt})_\\d+)\\]|\\b((?:${categoryAlt})_\\d+)\\b`, "gi");
|
|
511
639
|
}
|
|
512
640
|
/**
|
|
513
|
-
*
|
|
514
|
-
* Used to de-anonymize LLM responses before displaying to the user.
|
|
515
|
-
*
|
|
516
|
-
* Exact: matches the literal `[EMAIL_1]` form only. The storage paths use
|
|
517
|
-
* {@link restoreForStorage} instead, which tolerates the mangled echoes the
|
|
518
|
-
* extraction models produce.
|
|
641
|
+
* Returns the current number of unique PII values tracked.
|
|
519
642
|
*/
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
let restored = text;
|
|
523
|
-
for (const [placeholder, original] of this.placeholderToValue) {
|
|
524
|
-
if (restored.includes(placeholder)) {
|
|
525
|
-
restored = restored.split(placeholder).join(original);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
return restored;
|
|
643
|
+
get size() {
|
|
644
|
+
return this.valueToPlaceholder.size;
|
|
529
645
|
}
|
|
530
646
|
/**
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* Returns the restored text plus `unresolved` — true when a placeholder-shaped
|
|
536
|
-
* token for one of this redactor's categories was NOT assigned during
|
|
537
|
-
* redaction (a model hallucination, or an ambiguous re-cased collision).
|
|
538
|
-
* Callers drop/degrade such facts so an opaque token never reaches the vault.
|
|
539
|
-
*
|
|
540
|
-
* One left-to-right pass over the ORIGINAL text. Two properties matter:
|
|
541
|
-
* - a restored value is never re-scanned — so a real value that happens to
|
|
542
|
-
* contain a `<CATEGORY>_<n>` substring (e.g. the email `ssn_1@example.com`,
|
|
543
|
-
* or an API key containing `EMAIL_1`) is neither corrupted nor false-flagged;
|
|
544
|
-
* - `unresolved` is derived from the lookup miss during this pass, not from a
|
|
545
|
-
* regex re-scan of the output — so it can't trip over a restored value.
|
|
647
|
+
* Returns a snapshot of all placeholder → original value mappings.
|
|
648
|
+
* Useful for debugging or UI display. This is a copy — mutating it (or
|
|
649
|
+
* later redactions) does not affect the returned map and vice versa.
|
|
546
650
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (!this.storagePattern || this.placeholderToValue.size === 0) {
|
|
550
|
-
return { text, unresolved: false };
|
|
551
|
-
}
|
|
552
|
-
this.storageLookup ?? (this.storageLookup = this.buildStorageLookup());
|
|
553
|
-
const { exact, ci, ciCollisions } = this.storageLookup;
|
|
554
|
-
let unresolved = false;
|
|
555
|
-
const restored = text.replace(
|
|
556
|
-
this.storagePattern,
|
|
557
|
-
(match, bracketed, bare) => {
|
|
558
|
-
const body = bracketed ?? bare ?? "";
|
|
559
|
-
const exactHit = exact.get(body);
|
|
560
|
-
if (exactHit !== void 0) return exactHit;
|
|
561
|
-
const key = body.toUpperCase();
|
|
562
|
-
if (!ciCollisions.has(key)) {
|
|
563
|
-
const ciHit = ci.get(key);
|
|
564
|
-
if (ciHit !== void 0) return ciHit;
|
|
565
|
-
}
|
|
566
|
-
unresolved = true;
|
|
567
|
-
return match;
|
|
568
|
-
}
|
|
569
|
-
);
|
|
570
|
-
return { text: restored, unresolved };
|
|
651
|
+
getMappings() {
|
|
652
|
+
return new Map(this.placeholderToValue);
|
|
571
653
|
}
|
|
572
654
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* body) handles re-cased echoes, except where two distinct bodies share an
|
|
576
|
-
* upper-cased form — those are recorded in `ciCollisions` so a re-cased echo
|
|
577
|
-
* of them is left unresolved rather than resolved to the wrong value.
|
|
655
|
+
* Get or create a placeholder for a PII value.
|
|
656
|
+
* The same value always gets the same placeholder within a redactor instance.
|
|
578
657
|
*/
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
658
|
+
getPlaceholder(category, value) {
|
|
659
|
+
if (this.disposed) throw new Error("PII redactor session ended");
|
|
660
|
+
const normalizedValue = value.trim();
|
|
661
|
+
const existing = this.valueToPlaceholder.get(normalizedValue);
|
|
662
|
+
if (existing) return existing;
|
|
663
|
+
const existingCount = this.categoryCounters.get(category);
|
|
664
|
+
const count = (existingCount ?? 0) + 1;
|
|
665
|
+
this.categoryCounters.set(category, count);
|
|
666
|
+
if (existingCount === void 0) this.storagePattern = void 0;
|
|
667
|
+
const placeholder = `[${category}_${count}]`;
|
|
668
|
+
this.valueToPlaceholder.set(normalizedValue, placeholder);
|
|
669
|
+
this.placeholderToValue.set(placeholder, normalizedValue);
|
|
670
|
+
this.storageLookup = null;
|
|
671
|
+
return placeholder;
|
|
591
672
|
}
|
|
592
673
|
/**
|
|
593
|
-
*
|
|
674
|
+
* Find all matches of a single pattern in `text`, applying its `validate` and
|
|
675
|
+
* `context` guards. Matches are non-overlapping and ascending (the global
|
|
676
|
+
* regex advances `lastIndex` past each match). Shared by the sync {@link scan}
|
|
677
|
+
* (sequential, per-pattern) and the async {@link collectRegexSpans} (span)
|
|
678
|
+
* paths so the detection rules live in exactly one place.
|
|
594
679
|
*/
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
dispose() {
|
|
606
|
-
this.disposed = true;
|
|
607
|
-
this.clear();
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
function isPiiRedactor(value) {
|
|
611
|
-
return typeof value === "object" && value !== null && typeof value.redactMessages === "function" && typeof value.deAnonymize === "function";
|
|
612
|
-
}
|
|
613
|
-
function resolvePiiRedactor(piiRedaction) {
|
|
614
|
-
if (piiRedaction === true) return new PiiRedactor();
|
|
615
|
-
if (!piiRedaction) return void 0;
|
|
616
|
-
if (isPiiRedactor(piiRedaction)) return piiRedaction;
|
|
617
|
-
console.warn(
|
|
618
|
-
"[PiiRedactor] `piiRedaction` is neither `true` nor a PiiRedactor instance; PII redaction is DISABLED for this request."
|
|
619
|
-
);
|
|
620
|
-
return void 0;
|
|
621
|
-
}
|
|
622
|
-
function createStreamingDeAnonymizer(redactor, emit) {
|
|
623
|
-
let buffer = "";
|
|
624
|
-
const trailingFragment = /\[[A-Za-z0-9_]*$/;
|
|
625
|
-
return {
|
|
626
|
-
push(chunk) {
|
|
627
|
-
if (!chunk) return;
|
|
628
|
-
buffer += chunk;
|
|
629
|
-
const m = buffer.match(trailingFragment);
|
|
630
|
-
if (!m || m.index === void 0) {
|
|
631
|
-
emit(redactor.deAnonymize(buffer));
|
|
632
|
-
buffer = "";
|
|
633
|
-
} else if (m.index > 0) {
|
|
634
|
-
emit(redactor.deAnonymize(buffer.slice(0, m.index)));
|
|
635
|
-
buffer = buffer.slice(m.index);
|
|
680
|
+
findMatches(text, pattern) {
|
|
681
|
+
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
|
|
682
|
+
const found = [];
|
|
683
|
+
let m;
|
|
684
|
+
while ((m = regex.exec(text)) !== null) {
|
|
685
|
+
const value = m[0];
|
|
686
|
+
if (pattern.validate && !pattern.validate(value)) continue;
|
|
687
|
+
if (pattern.context) {
|
|
688
|
+
const before = text.slice(Math.max(0, m.index - 40), m.index);
|
|
689
|
+
if (!pattern.context.test(before)) continue;
|
|
636
690
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
691
|
+
found.push({ match: value, index: m.index });
|
|
692
|
+
}
|
|
693
|
+
return found;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Scan text for PII and rebuild it, replacing each match with whatever
|
|
697
|
+
* `makeReplacement(category, value)` returns. Shared by `redactText`
|
|
698
|
+
* (numbered, stateful placeholders) and `maskText` (unnumbered, stateless).
|
|
699
|
+
*
|
|
700
|
+
* Patterns are applied sequentially: each scans the text already rewritten by
|
|
701
|
+
* earlier patterns, so an earlier (more specific) pattern claims a region
|
|
702
|
+
* before a later one can — placeholders are inert and match no pattern.
|
|
703
|
+
*/
|
|
704
|
+
scan(text, makeReplacement) {
|
|
705
|
+
const matches = [];
|
|
706
|
+
let redacted = text;
|
|
707
|
+
for (const pattern of this.patterns) {
|
|
708
|
+
const found = this.findMatches(redacted, pattern);
|
|
709
|
+
if (found.length === 0) continue;
|
|
710
|
+
let result = "";
|
|
711
|
+
let cursor = 0;
|
|
712
|
+
for (const { match, index } of found) {
|
|
713
|
+
const placeholder = makeReplacement(pattern.category, match);
|
|
714
|
+
matches.push({ category: pattern.category, original: match, placeholder });
|
|
715
|
+
result += redacted.slice(cursor, index) + placeholder;
|
|
716
|
+
cursor = index + match.length;
|
|
642
717
|
}
|
|
718
|
+
result += redacted.slice(cursor);
|
|
719
|
+
redacted = result;
|
|
643
720
|
}
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// src/lib/internalFlowMarker.ts
|
|
648
|
-
var INTERNAL_FLOW_MARKER = "Anuma internal first-party flow (not user chat).";
|
|
649
|
-
function withInternalFlowMarker(systemPrompt) {
|
|
650
|
-
if (systemPrompt.includes(INTERNAL_FLOW_MARKER)) return systemPrompt;
|
|
651
|
-
return `${INTERNAL_FLOW_MARKER}
|
|
652
|
-
${systemPrompt}`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// src/lib/chat/endpointOverride.ts
|
|
656
|
-
function validateEndpointOverride(override) {
|
|
657
|
-
const trimmed = override.trim();
|
|
658
|
-
if (trimmed === "") {
|
|
659
|
-
return {
|
|
660
|
-
valid: false,
|
|
661
|
-
message: 'endpointOverride must be a non-empty, root-relative path (e.g. "/api/v1/utility/responses"); received an empty or whitespace-only string'
|
|
662
|
-
};
|
|
721
|
+
return { text: redacted, matches };
|
|
663
722
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
723
|
+
/**
|
|
724
|
+
* Scan and redact PII from a single text string, using numbered, reversible
|
|
725
|
+
* placeholders ([EMAIL_1], …) tracked on this instance.
|
|
726
|
+
*/
|
|
727
|
+
redactText(text) {
|
|
728
|
+
return this.scan(text, (category, value) => this.getPlaceholder(category, value));
|
|
669
729
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
// src/lib/memory/portalLlm.ts
|
|
680
|
-
function defaultBaseUrl() {
|
|
681
|
-
return typeof process !== "undefined" && process.env?.ANUMA_PORTAL_BASE_URL || BASE_URL;
|
|
682
|
-
}
|
|
683
|
-
var RESPONSE_FORMAT_OK = /* @__PURE__ */ new Set(["openai", "inclusionai", "deepseek"]);
|
|
684
|
-
var RESPONSE_SCHEMA_OK = /* @__PURE__ */ new Set(["openai"]);
|
|
685
|
-
var JSON_CONTRACT_REMINDER = "Output ONLY the strict JSON object requested. No prose, no explanation, no markdown.";
|
|
686
|
-
function supportsResponseFormat(model, variant = "json_object") {
|
|
687
|
-
const allow = variant === "json_schema" ? RESPONSE_SCHEMA_OK : RESPONSE_FORMAT_OK;
|
|
688
|
-
return model.split("/").some((seg) => allow.has(seg));
|
|
689
|
-
}
|
|
690
|
-
var RESPONSES_ONLY_MODEL_PREFIXES = ["gpt-5.6", "gpt-6-sol", "gpt-6-luna", "gpt-6-astra"];
|
|
691
|
-
function requiresResponsesTransport(model) {
|
|
692
|
-
return model.split("/").some((seg) => RESPONSES_ONLY_MODEL_PREFIXES.some((prefix) => seg.startsWith(prefix)));
|
|
693
|
-
}
|
|
694
|
-
var CHAT_TO_RESPONSES_FIELDS = {
|
|
695
|
-
max_completion_tokens: "max_output_tokens"
|
|
696
|
-
};
|
|
697
|
-
function responsesExtra(extra) {
|
|
698
|
-
if (!extra) return {};
|
|
699
|
-
const out = {};
|
|
700
|
-
for (const [key, value] of Object.entries(extra)) {
|
|
701
|
-
const target = CHAT_TO_RESPONSES_FIELDS[key] ?? key;
|
|
702
|
-
if (target !== key && target in extra) continue;
|
|
703
|
-
out[target] = value;
|
|
730
|
+
/**
|
|
731
|
+
* Mask PII with unnumbered, NON-reversible tokens ([EMAIL], [SSN], …) without
|
|
732
|
+
* mutating this instance's state. Use for one-way purposes where the value is
|
|
733
|
+
* never restored — notably embedding inputs: it keeps real PII off the
|
|
734
|
+
* embeddings endpoint while staying deterministic, so the same content always
|
|
735
|
+
* masks identically and search stays consistent across conversations.
|
|
736
|
+
*/
|
|
737
|
+
maskText(text) {
|
|
738
|
+
return this.scan(text, (category) => `[${category}]`).text;
|
|
704
739
|
}
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const foreign = FOREIGN_ENDPOINT_SUFFIX[transport];
|
|
722
|
-
if (!path.endsWith(foreign)) return endpoint;
|
|
723
|
-
const own = FOREIGN_ENDPOINT_SUFFIX[transport === "chat" ? "responses" : "chat"];
|
|
724
|
-
return `${path.slice(0, -foreign.length)}${own}${rest}`;
|
|
725
|
-
}
|
|
726
|
-
function assertTransportMatchesEndpoint(transport, endpoint) {
|
|
727
|
-
const foreign = FOREIGN_ENDPOINT_SUFFIX[transport];
|
|
728
|
-
if (!splitEndpoint(endpoint).path.endsWith(foreign)) return;
|
|
729
|
-
throw new Error(
|
|
730
|
-
`endpointOverride "${endpoint}" is the other transport's endpoint, but transport is "${transport}". The request body is built for the transport, so this sends the wrong shape and 400s without retry.`
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
var TIME_BUDGET_EXHAUSTED = {
|
|
734
|
-
kind: "terminal",
|
|
735
|
-
code: "time-budget-exhausted",
|
|
736
|
-
reason: "time budget exhausted"
|
|
737
|
-
};
|
|
738
|
-
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
739
|
-
var RETRYABLE_HTTP = /* @__PURE__ */ new Set([408, 409, 425, 429]);
|
|
740
|
-
function isRetryableStatus(status) {
|
|
741
|
-
return status >= 500 || RETRYABLE_HTTP.has(status);
|
|
742
|
-
}
|
|
743
|
-
function parseRetryAfterMs(header) {
|
|
744
|
-
if (!header) return null;
|
|
745
|
-
const secs = Number(header);
|
|
746
|
-
if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
|
|
747
|
-
const dateMs = Date.parse(header);
|
|
748
|
-
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
749
|
-
return null;
|
|
750
|
-
}
|
|
751
|
-
function defaultBackoffMs(attempt) {
|
|
752
|
-
const base = Math.min(2e3, 250 * 2 ** (attempt - 1));
|
|
753
|
-
return base + Math.floor(Math.random() * 100);
|
|
754
|
-
}
|
|
755
|
-
function sleep(ms) {
|
|
756
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
757
|
-
}
|
|
758
|
-
async function resolvePortalAuthHeaders(auth, tag) {
|
|
759
|
-
if (auth.apiKey) {
|
|
760
|
-
return { "x-api-key": auth.apiKey };
|
|
740
|
+
/** All regex matches as spans on the ORIGINAL text (priority 0). Uses the
|
|
741
|
+
* same {@link findMatches} guards as the sync scan. */
|
|
742
|
+
collectRegexSpans(text) {
|
|
743
|
+
const spans = [];
|
|
744
|
+
this.patterns.forEach((pattern, patternIndex) => {
|
|
745
|
+
for (const { match, index } of this.findMatches(text, pattern)) {
|
|
746
|
+
spans.push({
|
|
747
|
+
start: index,
|
|
748
|
+
end: index + match.length,
|
|
749
|
+
category: pattern.category,
|
|
750
|
+
priority: 0,
|
|
751
|
+
order: patternIndex
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
return spans;
|
|
761
756
|
}
|
|
762
|
-
|
|
763
|
-
|
|
757
|
+
/**
|
|
758
|
+
* Normalize raw NER spans: clamp to the text bounds, drop empty/categoryless
|
|
759
|
+
* spans, and snap each to whole-word boundaries. Snapping prevents a detector
|
|
760
|
+
* that tagged only part of a word (a common WordPiece artifact — e.g. "Strip"
|
|
761
|
+
* of "Stripe") from producing a corrupting partial placeholder like
|
|
762
|
+
* `[ORG_1]e`.
|
|
763
|
+
*/
|
|
764
|
+
normalizeNerSpans(text, raw) {
|
|
765
|
+
if (!Array.isArray(raw)) return [];
|
|
766
|
+
const out = [];
|
|
767
|
+
raw.forEach((s, i) => {
|
|
768
|
+
if (!Number.isFinite(s.start) || !Number.isFinite(s.end)) return;
|
|
769
|
+
let start = Math.max(0, Math.min(Math.trunc(s.start), text.length));
|
|
770
|
+
let end = Math.max(0, Math.min(Math.trunc(s.end), text.length));
|
|
771
|
+
if (end <= start) return;
|
|
772
|
+
while (start > 0 && isWordChar(text[start - 1])) start--;
|
|
773
|
+
while (end < text.length && isWordChar(text[end])) end++;
|
|
774
|
+
const category = String(s.category ?? "").trim();
|
|
775
|
+
if (!category) return;
|
|
776
|
+
out.push({ start, end, category, priority: 1, order: i });
|
|
777
|
+
});
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
/** Run the configured detector defensively: failures degrade to regex-only
|
|
781
|
+
* (fail-open on detection, not on protection) with a one-time warning. */
|
|
782
|
+
async detectNerSpans(text) {
|
|
783
|
+
if (!this.nerDetector) return [];
|
|
764
784
|
try {
|
|
765
|
-
|
|
785
|
+
return this.normalizeNerSpans(text, await this.nerDetector.detect(text));
|
|
766
786
|
} catch (err) {
|
|
767
|
-
|
|
768
|
-
return
|
|
769
|
-
}
|
|
770
|
-
if (!token) {
|
|
771
|
-
getLogger().warn(`[${tag}] getToken returned no token`);
|
|
772
|
-
return null;
|
|
773
|
-
}
|
|
774
|
-
return { Authorization: `Bearer ${token}` };
|
|
775
|
-
}
|
|
776
|
-
throw new Error(`[${tag}] Either apiKey or getToken must be provided`);
|
|
777
|
-
}
|
|
778
|
-
async function callPortalJsonCompletion(req) {
|
|
779
|
-
const log = getLogger();
|
|
780
|
-
const transport = req.transport ?? (requiresResponsesTransport(req.model) ? "responses" : "chat");
|
|
781
|
-
const autoUpgraded = req.transport === void 0 && transport === "responses";
|
|
782
|
-
let endpoint = transport === "responses" ? "/api/v1/responses" : "/api/v1/chat/completions";
|
|
783
|
-
if (req.endpointOverride !== void 0) {
|
|
784
|
-
const overrideValidation = validateEndpointOverride(req.endpointOverride);
|
|
785
|
-
if (!overrideValidation.valid) {
|
|
786
|
-
throw new Error(overrideValidation.message);
|
|
787
|
+
this.warnNerFailure(err);
|
|
788
|
+
return [];
|
|
787
789
|
}
|
|
788
|
-
endpoint = overrideValidation.endpoint;
|
|
789
|
-
if (autoUpgraded) endpoint = endpointForTransport(endpoint, transport);
|
|
790
|
-
assertTransportMatchesEndpoint(transport, endpoint);
|
|
791
790
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
lastFailure = { reason: "time-budget-exhausted", attempts: attempt - 1 };
|
|
806
|
-
break;
|
|
807
|
-
}
|
|
808
|
-
const attemptReq = reinforce ? { ...req, reinforceJsonContract: true } : req;
|
|
809
|
-
const outcome = await attemptPortalJson(
|
|
810
|
-
{ ...attemptReq, transport },
|
|
811
|
-
endpoint,
|
|
812
|
-
deadlineAt
|
|
791
|
+
/**
|
|
792
|
+
* Resolve overlaps by placing higher-priority spans first and dropping any
|
|
793
|
+
* span that overlaps one already placed. Regex (priority 0) is placed before
|
|
794
|
+
* NER (priority 1), and within regex by ascending pattern index — so regex
|
|
795
|
+
* always wins on overlap and the regex-only result reproduces the sync scan's
|
|
796
|
+
* sequential "earlier pattern wins". O(n²) but n (spans per message) is tiny.
|
|
797
|
+
*
|
|
798
|
+
* The returned spans stay in `(priority, order, start)` order — i.e. mint
|
|
799
|
+
* order — which {@link rebuildSpans} relies on.
|
|
800
|
+
*/
|
|
801
|
+
resolveOverlaps(spans) {
|
|
802
|
+
const ordered = [...spans].sort(
|
|
803
|
+
(a, b) => a.priority - b.priority || a.order - b.order || a.start - b.start
|
|
813
804
|
);
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
req.onAttempt?.({
|
|
819
|
-
attempt,
|
|
820
|
-
ok: false,
|
|
821
|
-
reason: outcome.code,
|
|
822
|
-
...outcome.httpStatus !== void 0 && { httpStatus: outcome.httpStatus }
|
|
823
|
-
});
|
|
824
|
-
lastFailure = {
|
|
825
|
-
reason: outcome.code,
|
|
826
|
-
...outcome.httpStatus !== void 0 && { httpStatus: outcome.httpStatus },
|
|
827
|
-
attempts: attempt
|
|
828
|
-
};
|
|
829
|
-
if (outcome.code === "invalid-json" || outcome.code === "null-completion") reinforce = true;
|
|
830
|
-
if (outcome.kind === "terminal") {
|
|
831
|
-
log.warn(`[${req.tag}] ${outcome.reason}`);
|
|
832
|
-
return giveUp();
|
|
805
|
+
const placed = [];
|
|
806
|
+
for (const s of ordered) {
|
|
807
|
+
if (!placed.some((p) => s.start < p.end && p.start < s.end)) placed.push(s);
|
|
833
808
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
809
|
+
return placed;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Rebuild `text` from resolved (non-overlapping) spans. `spans` must arrive in
|
|
813
|
+
* mint order — `(priority, order, start)`, as {@link resolveOverlaps} returns —
|
|
814
|
+
* so placeholders are MINTED by iterating it directly (regex categories first,
|
|
815
|
+
* in pattern then document order, matching the sync scan's numbering). The
|
|
816
|
+
* string is then EMITTED in document order.
|
|
817
|
+
*/
|
|
818
|
+
rebuildSpans(text, spans, makeReplacement) {
|
|
819
|
+
const placeholderByStart = /* @__PURE__ */ new Map();
|
|
820
|
+
const matches = [];
|
|
821
|
+
for (const s of spans) {
|
|
822
|
+
const value = text.slice(s.start, s.end);
|
|
823
|
+
const placeholder = makeReplacement(s.category, value);
|
|
824
|
+
placeholderByStart.set(s.start, placeholder);
|
|
825
|
+
matches.push({ category: s.category, original: value, placeholder });
|
|
837
826
|
}
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
lastFailure = { reason: "time-budget-exhausted", attempts: attempt };
|
|
845
|
-
break;
|
|
827
|
+
const emitOrder = [...spans].sort((a, b) => a.start - b.start);
|
|
828
|
+
let result = "";
|
|
829
|
+
let cursor = 0;
|
|
830
|
+
for (const s of emitOrder) {
|
|
831
|
+
result += text.slice(cursor, s.start) + (placeholderByStart.get(s.start) ?? "");
|
|
832
|
+
cursor = s.end;
|
|
846
833
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
return giveUp();
|
|
851
|
-
}
|
|
852
|
-
async function attemptPortalJson(req, endpoint, deadlineAt) {
|
|
853
|
-
const controller = new AbortController();
|
|
854
|
-
if (deadlineAt === void 0 || deadlineAt - Date.now() > MAX_TIMER_DELAY_MS) {
|
|
855
|
-
return requestPortalJson(req, endpoint, controller);
|
|
834
|
+
result += text.slice(cursor);
|
|
835
|
+
return { text: result, matches };
|
|
856
836
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
timer = setTimeout(() => {
|
|
864
|
-
resolve(TIME_BUDGET_EXHAUSTED);
|
|
865
|
-
controller.abort();
|
|
866
|
-
}, remainingMs);
|
|
867
|
-
}),
|
|
868
|
-
requestPortalJson(req, endpoint, controller, deadlineAt)
|
|
869
|
-
]);
|
|
870
|
-
return Date.now() >= deadlineAt ? TIME_BUDGET_EXHAUSTED : outcome;
|
|
871
|
-
} finally {
|
|
872
|
-
clearTimeout(timer);
|
|
837
|
+
/** Merge regex + NER spans for `text` into a resolved, non-overlapping set. */
|
|
838
|
+
async detectAllSpans(text) {
|
|
839
|
+
const nerPromise = this.detectNerSpans(text);
|
|
840
|
+
const regexSpans = this.collectRegexSpans(text);
|
|
841
|
+
const ner = await nerPromise;
|
|
842
|
+
return this.resolveOverlaps([...regexSpans, ...ner]);
|
|
873
843
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
return
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
{ role: "system", content: withInternalFlowMarker(req.systemPrompt) },
|
|
888
|
-
...req.reinforceJsonContract ? [{ role: "system", content: JSON_CONTRACT_REMINDER }] : [],
|
|
889
|
-
{ role: "user", content: req.userMessage }
|
|
890
|
-
];
|
|
891
|
-
if (prefill) messages.push({ role: "assistant", content: prefill });
|
|
892
|
-
const supportsJsonObjectFormat = supportsResponseFormat(req.model);
|
|
893
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
894
|
-
const requestBody = req.transport === "responses" ? {
|
|
895
|
-
model: req.model,
|
|
896
|
-
input: messages,
|
|
897
|
-
...req.reasoning && { reasoning: req.reasoning },
|
|
898
|
-
...responsesExtra(req.extra)
|
|
899
|
-
} : {
|
|
900
|
-
model: req.model,
|
|
901
|
-
messages,
|
|
902
|
-
...supportsJsonObjectFormat && { response_format: { type: "json_object" } },
|
|
903
|
-
...req.extra
|
|
904
|
-
};
|
|
905
|
-
if (req.transport === "responses" || !supportsJsonObjectFormat) {
|
|
906
|
-
delete requestBody.response_format;
|
|
844
|
+
/**
|
|
845
|
+
* Async counterpart to {@link redactText}: redacts structured PII (regex) AND
|
|
846
|
+
* unstructured PII (the configured {@link NerDetector}) with numbered,
|
|
847
|
+
* reversible placeholders. With no detector, identical to `redactText`.
|
|
848
|
+
*/
|
|
849
|
+
async redactTextAsync(text) {
|
|
850
|
+
if (!this.nerDetector) return this.redactText(text);
|
|
851
|
+
const spans = await this.detectAllSpans(text);
|
|
852
|
+
return this.rebuildSpans(
|
|
853
|
+
text,
|
|
854
|
+
spans,
|
|
855
|
+
(category, value) => this.getPlaceholder(category, value)
|
|
856
|
+
);
|
|
907
857
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
},
|
|
917
|
-
body: JSON.stringify(requestBody),
|
|
918
|
-
signal: controller.signal
|
|
919
|
-
});
|
|
920
|
-
} catch (err) {
|
|
921
|
-
clearTimeout(timer);
|
|
922
|
-
return {
|
|
923
|
-
kind: "retryable",
|
|
924
|
-
code: "network",
|
|
925
|
-
reason: `fetch failed: ${err.message}`
|
|
926
|
-
};
|
|
858
|
+
/**
|
|
859
|
+
* Async counterpart to {@link maskText}: stateless `[CATEGORY]` masking over
|
|
860
|
+
* regex + NER matches. With no detector, identical to `maskText`.
|
|
861
|
+
*/
|
|
862
|
+
async maskTextAsync(text) {
|
|
863
|
+
if (!this.nerDetector) return this.maskText(text);
|
|
864
|
+
const spans = await this.detectAllSpans(text);
|
|
865
|
+
return this.rebuildSpans(text, spans, (category) => `[${category}]`).text;
|
|
927
866
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
867
|
+
/**
|
|
868
|
+
* Async counterpart to {@link redactMessages}. Text parts are redacted
|
|
869
|
+
* sequentially so placeholder numbering stays deterministic across the
|
|
870
|
+
* conversation. With no detector, identical to `redactMessages`.
|
|
871
|
+
*/
|
|
872
|
+
async redactMessagesAsync(messages) {
|
|
873
|
+
if (!this.nerDetector) return this.redactMessages(messages);
|
|
874
|
+
const allMatches = [];
|
|
875
|
+
const redactedMessages = [];
|
|
876
|
+
for (const msg of messages) {
|
|
877
|
+
if (!msg.content) {
|
|
878
|
+
redactedMessages.push(msg);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
const newContent = [];
|
|
882
|
+
for (const part of msg.content) {
|
|
883
|
+
if (part.type === "text" && part.text) {
|
|
884
|
+
const result = await this.redactTextAsync(part.text);
|
|
885
|
+
allMatches.push(...result.matches);
|
|
886
|
+
newContent.push(result.matches.length > 0 ? { ...part, text: result.text } : part);
|
|
887
|
+
} else {
|
|
888
|
+
if (part.type !== "text") this.warnNonTextBypass();
|
|
889
|
+
newContent.push(part);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
redactedMessages.push({ ...msg, content: newContent });
|
|
939
893
|
}
|
|
940
|
-
|
|
941
|
-
return { kind: "terminal", code: "http-terminal", reason, httpStatus: response.status };
|
|
942
|
-
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
943
|
-
return {
|
|
944
|
-
kind: "retryable",
|
|
945
|
-
code: "http-retryable",
|
|
946
|
-
reason,
|
|
947
|
-
httpStatus: response.status,
|
|
948
|
-
...retryAfterMs !== null && { retryAfterMs }
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
let body;
|
|
952
|
-
try {
|
|
953
|
-
body = await response.json();
|
|
954
|
-
} catch (err) {
|
|
955
|
-
clearTimeout(timer);
|
|
956
|
-
return {
|
|
957
|
-
kind: "retryable",
|
|
958
|
-
code: "body-parse-failed",
|
|
959
|
-
reason: `response body parse failed: ${err.message}`
|
|
960
|
-
};
|
|
961
|
-
}
|
|
962
|
-
clearTimeout(timer);
|
|
963
|
-
logPortalUsage(body, req.tag);
|
|
964
|
-
if (isModerationResponse(body)) {
|
|
965
|
-
return {
|
|
966
|
-
kind: "terminal",
|
|
967
|
-
code: "content-flagged",
|
|
968
|
-
reason: "portal moderation refused the request"
|
|
969
|
-
};
|
|
894
|
+
return { messages: redactedMessages, matches: allMatches };
|
|
970
895
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
896
|
+
/**
|
|
897
|
+
* Redact PII from an array of LlmapiMessage objects.
|
|
898
|
+
* Returns new message objects — originals are not mutated.
|
|
899
|
+
*
|
|
900
|
+
* All roles' text content is redacted, not just user/system: tool results
|
|
901
|
+
* can return PII fetched from external systems, and assistant history is
|
|
902
|
+
* persisted with original (de-anonymized) values, so both must be re-redacted
|
|
903
|
+
* before they go back over the wire. Placeholders the model already emitted
|
|
904
|
+
* are inert here (they don't match any PII pattern), so re-redaction is safe.
|
|
905
|
+
*/
|
|
906
|
+
redactMessages(messages) {
|
|
907
|
+
const allMatches = [];
|
|
908
|
+
const redactedMessages = messages.map((msg) => {
|
|
909
|
+
if (!msg.content) return msg;
|
|
910
|
+
const newContent = msg.content.map((part) => {
|
|
911
|
+
if (part.type === "text" && part.text) {
|
|
912
|
+
const result = this.redactText(part.text);
|
|
913
|
+
allMatches.push(...result.matches);
|
|
914
|
+
if (result.matches.length > 0) {
|
|
915
|
+
return { ...part, text: result.text };
|
|
916
|
+
}
|
|
917
|
+
} else if (part.type !== "text") {
|
|
918
|
+
this.warnNonTextBypass();
|
|
919
|
+
}
|
|
920
|
+
return part;
|
|
921
|
+
});
|
|
922
|
+
return { ...msg, content: newContent };
|
|
923
|
+
});
|
|
924
|
+
return { messages: redactedMessages, matches: allMatches };
|
|
978
925
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
return {
|
|
987
|
-
kind: "retryable",
|
|
988
|
-
code: "invalid-json",
|
|
989
|
-
reason: "invalid-json"
|
|
990
|
-
};
|
|
926
|
+
/** Emit the non-text bypass warning at most once per redactor instance. */
|
|
927
|
+
warnNonTextBypass() {
|
|
928
|
+
if (this.warnedNonText) return;
|
|
929
|
+
this.warnedNonText = true;
|
|
930
|
+
console.warn(
|
|
931
|
+
"[PiiRedactor] A message contains non-text content (images, files, or attachments) that is NOT scanned for PII \u2014 only text parts are redacted."
|
|
932
|
+
);
|
|
991
933
|
}
|
|
992
|
-
|
|
993
|
-
|
|
934
|
+
/** Emit the NER-detector-failure warning at most once per redactor instance. */
|
|
935
|
+
warnNerFailure(err) {
|
|
936
|
+
if (this.warnedNerFailure) return;
|
|
937
|
+
this.warnedNerFailure = true;
|
|
938
|
+
console.warn(
|
|
939
|
+
"[PiiRedactor] The NER detector threw; falling back to regex-only redaction for this call's unstructured PII. The detector is still retried on subsequent calls; this warning is emitted only once.",
|
|
940
|
+
err
|
|
941
|
+
);
|
|
994
942
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
if (escape) {
|
|
1011
|
-
escape = false;
|
|
1012
|
-
continue;
|
|
1013
|
-
}
|
|
1014
|
-
if (ch === "\\" && inString) {
|
|
1015
|
-
escape = true;
|
|
1016
|
-
continue;
|
|
943
|
+
/**
|
|
944
|
+
* Restore original PII values in text that contains placeholders.
|
|
945
|
+
* Used to de-anonymize LLM responses before displaying to the user.
|
|
946
|
+
*
|
|
947
|
+
* Exact: matches the literal `[EMAIL_1]` form only. The storage paths use
|
|
948
|
+
* {@link restoreForStorage} instead, which tolerates the mangled echoes the
|
|
949
|
+
* extraction models produce.
|
|
950
|
+
*/
|
|
951
|
+
deAnonymize(text) {
|
|
952
|
+
if (!text.includes("[")) return text;
|
|
953
|
+
let restored = text;
|
|
954
|
+
for (const [placeholder, original] of this.placeholderToValue) {
|
|
955
|
+
if (restored.includes(placeholder)) {
|
|
956
|
+
restored = restored.split(placeholder).join(original);
|
|
957
|
+
}
|
|
1017
958
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
959
|
+
return restored;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* De-anonymize for PERSISTENCE (auto-extraction / consolidation), tolerant of
|
|
963
|
+
* the ways the extraction models mangle a placeholder when echoing it back:
|
|
964
|
+
* dropped brackets (`EMAIL_1`) and changed case (`email_1` / `Email_1`).
|
|
965
|
+
*
|
|
966
|
+
* Returns the restored text plus `unresolved` — true when a placeholder-shaped
|
|
967
|
+
* token for one of this redactor's categories was NOT assigned during
|
|
968
|
+
* redaction (a model hallucination, or an ambiguous re-cased collision).
|
|
969
|
+
* Callers drop/degrade such facts so an opaque token never reaches the vault.
|
|
970
|
+
*
|
|
971
|
+
* One left-to-right pass over the ORIGINAL text. Two properties matter:
|
|
972
|
+
* - a restored value is never re-scanned — so a real value that happens to
|
|
973
|
+
* contain a `<CATEGORY>_<n>` substring (e.g. the email `ssn_1@example.com`,
|
|
974
|
+
* or an API key containing `EMAIL_1`) is neither corrupted nor false-flagged;
|
|
975
|
+
* - `unresolved` is derived from the lookup miss during this pass, not from a
|
|
976
|
+
* regex re-scan of the output — so it can't trip over a restored value.
|
|
977
|
+
*/
|
|
978
|
+
restoreForStorage(text) {
|
|
979
|
+
if (this.storagePattern === void 0) this.storagePattern = this.buildStoragePattern();
|
|
980
|
+
if (!this.storagePattern || this.placeholderToValue.size === 0) {
|
|
981
|
+
return { text, unresolved: false };
|
|
1021
982
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
983
|
+
this.storageLookup ?? (this.storageLookup = this.buildStorageLookup());
|
|
984
|
+
const { exact, ci, ciCollisions } = this.storageLookup;
|
|
985
|
+
let unresolved = false;
|
|
986
|
+
const restored = text.replace(
|
|
987
|
+
this.storagePattern,
|
|
988
|
+
(match, bracketed, bare) => {
|
|
989
|
+
const body = bracketed ?? bare ?? "";
|
|
990
|
+
const exactHit = exact.get(body);
|
|
991
|
+
if (exactHit !== void 0) return exactHit;
|
|
992
|
+
const key = body.toUpperCase();
|
|
993
|
+
if (!ciCollisions.has(key)) {
|
|
994
|
+
const ciHit = ci.get(key);
|
|
995
|
+
if (ciHit !== void 0) return ciHit;
|
|
996
|
+
}
|
|
997
|
+
unresolved = true;
|
|
998
|
+
return match;
|
|
999
|
+
}
|
|
1000
|
+
);
|
|
1001
|
+
return { text: restored, unresolved };
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Build the {@link restoreForStorage} body→value lookups from the assigned
|
|
1005
|
+
* placeholders. `exact` (literal body) is collision-proof; `ci` (upper-cased
|
|
1006
|
+
* body) handles re-cased echoes, except where two distinct bodies share an
|
|
1007
|
+
* upper-cased form — those are recorded in `ciCollisions` so a re-cased echo
|
|
1008
|
+
* of them is left unresolved rather than resolved to the wrong value.
|
|
1009
|
+
*/
|
|
1010
|
+
buildStorageLookup() {
|
|
1011
|
+
const exact = /* @__PURE__ */ new Map();
|
|
1012
|
+
const ci = /* @__PURE__ */ new Map();
|
|
1013
|
+
const ciCollisions = /* @__PURE__ */ new Set();
|
|
1014
|
+
for (const [placeholder, value] of this.placeholderToValue) {
|
|
1015
|
+
const body = placeholder.slice(1, -1);
|
|
1016
|
+
exact.set(body, value);
|
|
1017
|
+
const key = body.toUpperCase();
|
|
1018
|
+
if (ci.has(key) && ci.get(key) !== value) ciCollisions.add(key);
|
|
1019
|
+
else ci.set(key, value);
|
|
1027
1020
|
}
|
|
1021
|
+
return { exact, ci, ciCollisions };
|
|
1028
1022
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (!root) return;
|
|
1041
|
-
const usage = asRecord(root.usage);
|
|
1042
|
-
const promptTokens = asCount(usage?.prompt_tokens) ?? asCount(usage?.input_tokens);
|
|
1043
|
-
const completionTokens = asCount(usage?.completion_tokens) ?? asCount(usage?.output_tokens);
|
|
1044
|
-
if (promptTokens === void 0 && completionTokens === void 0) return;
|
|
1045
|
-
const cachedTokens = asCount(asRecord(root.portal)?.cached_tokens) ?? asCount(asRecord(usage?.prompt_tokens_details)?.cached_tokens) ?? asCount(asRecord(usage?.input_tokens_details)?.cached_tokens) ?? 0;
|
|
1046
|
-
const reasoningTokens = asCount(asRecord(usage?.output_tokens_details)?.reasoning_tokens) ?? asCount(asRecord(usage?.completion_tokens_details)?.reasoning_tokens);
|
|
1047
|
-
const parts = [];
|
|
1048
|
-
if (promptTokens !== void 0) parts.push(`prompt=${promptTokens}`);
|
|
1049
|
-
if (completionTokens !== void 0) parts.push(`completion=${completionTokens}`);
|
|
1050
|
-
if (reasoningTokens !== void 0) parts.push(`reasoning=${reasoningTokens}`);
|
|
1051
|
-
parts.push(`cached=${cachedTokens}`);
|
|
1052
|
-
getLogger().debug(`[${tag}] usage ${parts.join(" ")}`);
|
|
1053
|
-
} catch {
|
|
1023
|
+
/**
|
|
1024
|
+
* Reset all state. Useful for testing or when starting a fresh conversation.
|
|
1025
|
+
*/
|
|
1026
|
+
clear() {
|
|
1027
|
+
this.valueToPlaceholder.clear();
|
|
1028
|
+
this.placeholderToValue.clear();
|
|
1029
|
+
this.categoryCounters.clear();
|
|
1030
|
+
this.storageLookup = null;
|
|
1031
|
+
this.storagePattern = void 0;
|
|
1032
|
+
this.warnedNonText = false;
|
|
1033
|
+
this.warnedNerFailure = false;
|
|
1054
1034
|
}
|
|
1035
|
+
/** Clear the mappings and prevent new reversible mappings. */
|
|
1036
|
+
dispose() {
|
|
1037
|
+
this.disposed = true;
|
|
1038
|
+
this.clear();
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
function isPiiRedactor(value) {
|
|
1042
|
+
return typeof value === "object" && value !== null && typeof value.redactMessages === "function" && typeof value.deAnonymize === "function";
|
|
1055
1043
|
}
|
|
1056
|
-
function
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
1065
|
-
const first = choices[0];
|
|
1066
|
-
const content = first?.message?.content;
|
|
1067
|
-
return typeof content === "string" ? content : null;
|
|
1044
|
+
function resolvePiiRedactor(piiRedaction) {
|
|
1045
|
+
if (piiRedaction === true) return new PiiRedactor();
|
|
1046
|
+
if (!piiRedaction) return void 0;
|
|
1047
|
+
if (isPiiRedactor(piiRedaction)) return piiRedaction;
|
|
1048
|
+
console.warn(
|
|
1049
|
+
"[PiiRedactor] `piiRedaction` is neither `true` nor a PiiRedactor instance; PII redaction is DISABLED for this request."
|
|
1050
|
+
);
|
|
1051
|
+
return void 0;
|
|
1068
1052
|
}
|
|
1069
|
-
function
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1053
|
+
function createStreamingDeAnonymizer(redactor, emit) {
|
|
1054
|
+
let buffer = "";
|
|
1055
|
+
const trailingFragment = /\[[A-Za-z0-9_]*$/;
|
|
1056
|
+
return {
|
|
1057
|
+
push(chunk) {
|
|
1058
|
+
if (!chunk) return;
|
|
1059
|
+
buffer += chunk;
|
|
1060
|
+
const m = buffer.match(trailingFragment);
|
|
1061
|
+
if (!m || m.index === void 0) {
|
|
1062
|
+
emit(redactor.deAnonymize(buffer));
|
|
1063
|
+
buffer = "";
|
|
1064
|
+
} else if (m.index > 0) {
|
|
1065
|
+
emit(redactor.deAnonymize(buffer.slice(0, m.index)));
|
|
1066
|
+
buffer = buffer.slice(m.index);
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
flush() {
|
|
1070
|
+
if (buffer) {
|
|
1071
|
+
emit(redactor.deAnonymize(buffer));
|
|
1072
|
+
buffer = "";
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
1076
|
}
|
|
1077
1077
|
|
|
1078
1078
|
// src/lib/memory/consolidationFallback.ts
|
|
@@ -1100,11 +1100,6 @@ export {
|
|
|
1100
1100
|
setLogger,
|
|
1101
1101
|
getLogger,
|
|
1102
1102
|
taskTypeHeader,
|
|
1103
|
-
PII_PATTERNS,
|
|
1104
|
-
PiiRedactor,
|
|
1105
|
-
isPiiRedactor,
|
|
1106
|
-
resolvePiiRedactor,
|
|
1107
|
-
createStreamingDeAnonymizer,
|
|
1108
1103
|
validateEndpointOverride,
|
|
1109
1104
|
INTERNAL_FLOW_MARKER,
|
|
1110
1105
|
withInternalFlowMarker,
|
|
@@ -1116,5 +1111,10 @@ export {
|
|
|
1116
1111
|
callPortalJsonCompletion,
|
|
1117
1112
|
extractJsonCandidate,
|
|
1118
1113
|
extractCompletionContent,
|
|
1114
|
+
PII_PATTERNS,
|
|
1115
|
+
PiiRedactor,
|
|
1116
|
+
isPiiRedactor,
|
|
1117
|
+
resolvePiiRedactor,
|
|
1118
|
+
createStreamingDeAnonymizer,
|
|
1119
1119
|
notifyConsolidationFallback
|
|
1120
1120
|
};
|