@anuma/sdk 10.9.0 → 10.10.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.
@@ -43,1036 +43,1036 @@ function getLogger() {
43
43
  return getLoggerState().logger;
44
44
  }
45
45
 
46
- // src/lib/pii/patterns.ts
47
- function luhnCheck(digits) {
48
- let sum = 0;
49
- let alternate = false;
50
- for (let i = digits.length - 1; i >= 0; i--) {
51
- let n = parseInt(digits[i], 10);
52
- if (alternate) {
53
- n *= 2;
54
- if (n > 9) n -= 9;
55
- }
56
- sum += n;
57
- alternate = !alternate;
46
+ // src/lib/chat/endpointOverride.ts
47
+ function validateEndpointOverride(override) {
48
+ const trimmed = override.trim();
49
+ if (trimmed === "") {
50
+ return {
51
+ valid: false,
52
+ message: 'endpointOverride must be a non-empty, root-relative path (e.g. "/api/v1/utility/responses"); received an empty or whitespace-only string'
53
+ };
58
54
  }
59
- return sum % 10 === 0;
55
+ if (trimmed.startsWith("//") || trimmed.includes("://")) {
56
+ return {
57
+ valid: false,
58
+ message: `endpointOverride must be a root-relative path (e.g. "/api/v1/utility/responses"), not a protocol-relative or absolute URL; received "${trimmed}"`
59
+ };
60
+ }
61
+ return { valid: true, endpoint: trimmed.startsWith("/") ? trimmed : `/${trimmed}` };
60
62
  }
61
- var PII_PATTERNS = [
62
- {
63
- category: "EMAIL",
64
- regex: /\b[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\.[a-zA-Z]{2,24}\b/g
65
- },
66
- {
67
- category: "SSN",
68
- regex: /\b\d{3}-\d{2}-\d{4}\b/g,
69
- validate: (match) => {
70
- const parts = match.split("-");
71
- const area = parseInt(parts[0], 10);
72
- return area !== 0 && area !== 666 && area < 900;
63
+
64
+ // src/lib/internalFlowMarker.ts
65
+ var INTERNAL_FLOW_MARKER = "Anuma internal first-party flow (not user chat).";
66
+ function withInternalFlowMarker(systemPrompt) {
67
+ if (systemPrompt.includes(INTERNAL_FLOW_MARKER)) return systemPrompt;
68
+ return `${INTERNAL_FLOW_MARKER}
69
+ ${systemPrompt}`;
70
+ }
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
- category: "SSN",
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
- category: "CREDIT_CARD",
88
- regex: /\b\d{4}[\s-]\d{4}[\s-]\d{4}[\s-]\d{1,7}\b|\b\d{13,19}\b/g,
89
- validate: (match) => {
90
- const digits = match.replace(/\D/g, "");
91
- if (digits.length < 13 || digits.length > 19) return false;
92
- return luhnCheck(digits);
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
- category: "IP_ADDRESS",
97
- 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,
98
- validate: (match) => {
99
- return match !== "0.0.0.0" && match !== "127.0.0.1" && match !== "255.255.255.255";
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
- category: "IP_ADDRESS",
104
- 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,
105
- validate: (match) => match !== "::1" && match !== "::"
106
- },
107
- {
108
- category: "PHONE",
109
- regex: /(?:\+\d{1,3}[\s.-])?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b|\+\d{10,15}\b/g
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
- category: "US_ADDRESS",
122
- 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,
123
- validate: (match) => {
124
- return !/^0/.test(match.trim());
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
- category: "DATE_OF_BIRTH",
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
- // src/lib/pii/redactor.ts
145
- function escapeRegExp(value) {
146
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
147
- }
148
- function isWordChar(ch) {
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
- this.patternCategories = [...new Set(this.patterns.map((p) => String(p.category)))];
246
+ log.warn(`[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, retrying`);
247
+ if (delay > 0) await sleep(delay);
194
248
  }
195
- /**
196
- * Build the {@link storagePattern} from the union of pattern categories and
197
- * any categories minted at runtime (NER entities arrive only when redaction
198
- * runs, so this can't be fixed at construction). Bracketed `[EMAIL_1]` OR
199
- * bare `EMAIL_1`, capturing the body, case-insensitive, global. `\d+` (not a
200
- * fixed body) spans any index; `\b` on the bare arm keeps it off prose like
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
- * Returns the current number of unique PII values tracked.
211
- */
212
- get size() {
213
- return this.valueToPlaceholder.size;
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
- * Returns a snapshot of all placeholder → original value mappings.
217
- * Useful for debugging or UI display. This is a copy — mutating it (or
218
- * later redactions) does not affect the returned map and vice versa.
219
- */
220
- getMappings() {
221
- return new Map(this.placeholderToValue);
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
- * Get or create a placeholder for a PII value.
225
- * The same value always gets the same placeholder within a redactor instance.
226
- */
227
- getPlaceholder(category, value) {
228
- if (this.disposed) throw new Error("PII redactor session ended");
229
- const normalizedValue = value.trim();
230
- const existing = this.valueToPlaceholder.get(normalizedValue);
231
- if (existing) return existing;
232
- const existingCount = this.categoryCounters.get(category);
233
- const count = (existingCount ?? 0) + 1;
234
- this.categoryCounters.set(category, count);
235
- if (existingCount === void 0) this.storagePattern = void 0;
236
- const placeholder = `[${category}_${count}]`;
237
- this.valueToPlaceholder.set(normalizedValue, placeholder);
238
- this.placeholderToValue.set(placeholder, normalizedValue);
239
- this.storageLookup = null;
240
- return placeholder;
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
- * Find all matches of a single pattern in `text`, applying its `validate` and
244
- * `context` guards. Matches are non-overlapping and ascending (the global
245
- * regex advances `lastIndex` past each match). Shared by the sync {@link scan}
246
- * (sequential, per-pattern) and the async {@link collectRegexSpans} (span)
247
- * paths so the detection rules live in exactly one place.
248
- */
249
- findMatches(text, pattern) {
250
- const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
251
- const found = [];
252
- let m;
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
- return found;
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
- * Scan text for PII and rebuild it, replacing each match with whatever
266
- * `makeReplacement(category, value)` returns. Shared by `redactText`
267
- * (numbered, stateful placeholders) and `maskText` (unnumbered, stateless).
268
- *
269
- * Patterns are applied sequentially: each scans the text already rewritten by
270
- * earlier patterns, so an earlier (more specific) pattern claims a region
271
- * before a later one can — placeholders are inert and match no pattern.
272
- */
273
- scan(text, makeReplacement) {
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
- * Scan and redact PII from a single text string, using numbered, reversible
294
- * placeholders ([EMAIL_1], …) tracked on this instance.
295
- */
296
- redactText(text) {
297
- return this.scan(text, (category, value) => this.getPlaceholder(category, value));
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
- * Mask PII with unnumbered, NON-reversible tokens ([EMAIL], [SSN], …) without
301
- * mutating this instance's state. Use for one-way purposes where the value is
302
- * never restored — notably embedding inputs: it keeps real PII off the
303
- * embeddings endpoint while staying deterministic, so the same content always
304
- * masks identically and search stays consistent across conversations.
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
- /** All regex matches as spans on the ORIGINAL text (priority 0). Uses the
310
- * same {@link findMatches} guards as the sync scan. */
311
- collectRegexSpans(text) {
312
- const spans = [];
313
- this.patterns.forEach((pattern, patternIndex) => {
314
- for (const { match, index } of this.findMatches(text, pattern)) {
315
- spans.push({
316
- start: index,
317
- end: index + match.length,
318
- category: pattern.category,
319
- priority: 0,
320
- order: patternIndex
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
- * Normalize raw NER spans: clamp to the text bounds, drop empty/categoryless
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
- /** Run the configured detector defensively: failures degrade to regex-only
350
- * (fail-open on detection, not on protection) with a one-time warning. */
351
- async detectNerSpans(text) {
352
- if (!this.nerDetector) return [];
353
- try {
354
- return this.normalizeNerSpans(text, await this.nerDetector.detect(text));
355
- } catch (err) {
356
- this.warnNerFailure(err);
357
- return [];
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
- * Resolve overlaps by placing higher-priority spans first and dropping any
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
- return placed;
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
- const emitOrder = [...spans].sort((a, b) => a.start - b.start);
397
- let result = "";
398
- let cursor = 0;
399
- for (const s of emitOrder) {
400
- result += text.slice(cursor, s.start) + (placeholderByStart.get(s.start) ?? "");
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
- /** Merge regex + NER spans for `text` into a resolved, non-overlapping set. */
407
- async detectAllSpans(text) {
408
- const nerPromise = this.detectNerSpans(text);
409
- const regexSpans = this.collectRegexSpans(text);
410
- const ner = await nerPromise;
411
- return this.resolveOverlaps([...regexSpans, ...ner]);
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
- * Async counterpart to {@link redactText}: redacts structured PII (regex) AND
415
- * unstructured PII (the configured {@link NerDetector}) with numbered,
416
- * reversible placeholders. With no detector, identical to `redactText`.
417
- */
418
- async redactTextAsync(text) {
419
- if (!this.nerDetector) return this.redactText(text);
420
- const spans = await this.detectAllSpans(text);
421
- return this.rebuildSpans(
422
- text,
423
- spans,
424
- (category, value) => this.getPlaceholder(category, value)
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
- * Async counterpart to {@link maskText}: stateless `[CATEGORY]` masking over
429
- * regex + NER matches. With no detector, identical to `maskText`.
430
- */
431
- async maskTextAsync(text) {
432
- if (!this.nerDetector) return this.maskText(text);
433
- const spans = await this.detectAllSpans(text);
434
- return this.rebuildSpans(text, spans, (category) => `[${category}]`).text;
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
- * Async counterpart to {@link redactMessages}. Text parts are redacted
438
- * sequentially so placeholder numbering stays deterministic across the
439
- * conversation. With no detector, identical to `redactMessages`.
440
- */
441
- async redactMessagesAsync(messages) {
442
- if (!this.nerDetector) return this.redactMessages(messages);
443
- const allMatches = [];
444
- const redactedMessages = [];
445
- for (const msg of messages) {
446
- if (!msg.content) {
447
- redactedMessages.push(msg);
448
- continue;
449
- }
450
- const newContent = [];
451
- for (const part of msg.content) {
452
- if (part.type === "text" && part.text) {
453
- const result = await this.redactTextAsync(part.text);
454
- allMatches.push(...result.matches);
455
- newContent.push(result.matches.length > 0 ? { ...part, text: result.text } : part);
456
- } else {
457
- if (part.type !== "text") this.warnNonTextBypass();
458
- newContent.push(part);
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
- redactedMessages.push({ ...msg, content: newContent });
622
+ this.patterns = options.extraPatterns ? [...base, ...options.extraPatterns] : base;
462
623
  }
463
- return { messages: redactedMessages, matches: allMatches };
624
+ this.patternCategories = [...new Set(this.patterns.map((p) => String(p.category)))];
464
625
  }
465
626
  /**
466
- * Redact PII from an array of LlmapiMessage objects.
467
- * Returns new message objects — originals are not mutated.
468
- *
469
- * All roles' text content is redacted, not just user/system: tool results
470
- * can return PII fetched from external systems, and assistant history is
471
- * persisted with original (de-anonymized) values, so both must be re-redacted
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
- redactMessages(messages) {
476
- const allMatches = [];
477
- const redactedMessages = messages.map((msg) => {
478
- if (!msg.content) return msg;
479
- const newContent = msg.content.map((part) => {
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
- * Restore original PII values in text that contains placeholders.
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
- deAnonymize(text) {
521
- if (!text.includes("[")) return text;
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
- * De-anonymize for PERSISTENCE (auto-extraction / consolidation), tolerant of
532
- * the ways the extraction models mangle a placeholder when echoing it back:
533
- * dropped brackets (`EMAIL_1`) and changed case (`email_1` / `Email_1`).
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
- restoreForStorage(text) {
548
- if (this.storagePattern === void 0) this.storagePattern = this.buildStoragePattern();
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
- * Build the {@link restoreForStorage} body→value lookups from the assigned
574
- * placeholders. `exact` (literal body) is collision-proof; `ci` (upper-cased
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
- buildStorageLookup() {
580
- const exact = /* @__PURE__ */ new Map();
581
- const ci = /* @__PURE__ */ new Map();
582
- const ciCollisions = /* @__PURE__ */ new Set();
583
- for (const [placeholder, value] of this.placeholderToValue) {
584
- const body = placeholder.slice(1, -1);
585
- exact.set(body, value);
586
- const key = body.toUpperCase();
587
- if (ci.has(key) && ci.get(key) !== value) ciCollisions.add(key);
588
- else ci.set(key, value);
589
- }
590
- return { exact, ci, ciCollisions };
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
- * Reset all state. Useful for testing or when starting a fresh conversation.
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
- clear() {
596
- this.valueToPlaceholder.clear();
597
- this.placeholderToValue.clear();
598
- this.categoryCounters.clear();
599
- this.storageLookup = null;
600
- this.storagePattern = void 0;
601
- this.warnedNonText = false;
602
- this.warnedNerFailure = false;
603
- }
604
- /** Clear the mappings and prevent new reversible mappings. */
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
- flush() {
639
- if (buffer) {
640
- emit(redactor.deAnonymize(buffer));
641
- buffer = "";
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/chat/endpointOverride.ts
648
- function validateEndpointOverride(override) {
649
- const trimmed = override.trim();
650
- if (trimmed === "") {
651
- return {
652
- valid: false,
653
- message: 'endpointOverride must be a non-empty, root-relative path (e.g. "/api/v1/utility/responses"); received an empty or whitespace-only string'
654
- };
721
+ return { text: redacted, matches };
655
722
  }
656
- if (trimmed.startsWith("//") || trimmed.includes("://")) {
657
- return {
658
- valid: false,
659
- message: `endpointOverride must be a root-relative path (e.g. "/api/v1/utility/responses"), not a protocol-relative or absolute URL; received "${trimmed}"`
660
- };
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));
661
729
  }
662
- return { valid: true, endpoint: trimmed.startsWith("/") ? trimmed : `/${trimmed}` };
663
- }
664
-
665
- // src/lib/internalFlowMarker.ts
666
- var INTERNAL_FLOW_MARKER = "Anuma internal first-party flow (not user chat).";
667
- function withInternalFlowMarker(systemPrompt) {
668
- if (systemPrompt.includes(INTERNAL_FLOW_MARKER)) return systemPrompt;
669
- return `${INTERNAL_FLOW_MARKER}
670
- ${systemPrompt}`;
671
- }
672
-
673
- // src/lib/taskType.ts
674
- var TASK_TYPE_HEADER = "X-Anuma-Task-Type";
675
- function taskTypeHeader(taskType) {
676
- return taskType ? { [TASK_TYPE_HEADER]: taskType } : {};
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
- return out;
706
- }
707
- function splitEndpoint(endpoint) {
708
- const cut = endpoint.search(/[?#]/);
709
- const rawPath = cut === -1 ? endpoint : endpoint.slice(0, cut);
710
- return {
711
- path: rawPath.replace(/\/+$/, ""),
712
- rest: cut === -1 ? "" : endpoint.slice(cut)
713
- };
714
- }
715
- var FOREIGN_ENDPOINT_SUFFIX = {
716
- chat: "/responses",
717
- responses: "/chat/completions"
718
- };
719
- function endpointForTransport(endpoint, transport) {
720
- const { path, rest } = splitEndpoint(endpoint);
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
- if (auth.getToken) {
763
- let token;
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
- token = await auth.getToken();
785
+ return this.normalizeNerSpans(text, await this.nerDetector.detect(text));
766
786
  } catch (err) {
767
- getLogger().warn(`[${tag}] getToken threw`, err);
768
- return null;
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
- const maxAttempts = Math.max(1, req.maxAttempts ?? 3);
793
- const startedAt = Date.now();
794
- const deadlineAt = req.totalTimeoutMs === void 0 ? void 0 : startedAt + req.totalTimeoutMs;
795
- const overBudget = () => deadlineAt !== void 0 && Date.now() >= deadlineAt;
796
- let lastFailure;
797
- let reinforce = false;
798
- const giveUp = () => {
799
- req.onFailure?.(lastFailure ?? { reason: "network", attempts: maxAttempts });
800
- return null;
801
- };
802
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
803
- if (attempt > 1 && overBudget()) {
804
- log.warn(`[${req.tag}] over time budget before attempt ${attempt}, giving up`);
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
- if (outcome.kind === "ok") {
815
- req.onAttempt?.({ attempt, ok: true });
816
- return outcome.value;
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
- if (attempt >= maxAttempts) {
835
- log.warn(`[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, giving up`);
836
- break;
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 backoff = (req.backoffMs ?? defaultBackoffMs)(attempt);
839
- const delay = Math.max(backoff, outcome.retryAfterMs ?? 0);
840
- if (overBudget() || req.totalTimeoutMs !== void 0 && Date.now() - startedAt + delay >= req.totalTimeoutMs) {
841
- log.warn(
842
- `[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, over time budget, giving up`
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
- log.warn(`[${req.tag}] ${outcome.reason} \u2014 attempt ${attempt}/${maxAttempts}, retrying`);
848
- if (delay > 0) await sleep(delay);
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
- const remainingMs = deadlineAt - Date.now();
858
- if (remainingMs <= 0) return TIME_BUDGET_EXHAUSTED;
859
- let timer;
860
- try {
861
- const outcome = await Promise.race([
862
- new Promise((resolve) => {
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
- async function requestPortalJson(req, endpoint, controller, deadlineAt) {
876
- const baseUrl = req.baseUrl ?? defaultBaseUrl();
877
- const fetchImpl = req.fetchFn ?? fetch;
878
- const authHeaders = await resolvePortalAuthHeaders(req, req.tag);
879
- const remainingMs = deadlineAt === void 0 ? Infinity : deadlineAt - Date.now();
880
- if (controller.signal.aborted || remainingMs <= 0) return TIME_BUDGET_EXHAUSTED;
881
- if (authHeaders === null)
882
- return { kind: "terminal", code: "auth-unavailable", reason: "auth unavailable (no token)" };
883
- const timeoutMs = Math.min(req.timeoutMs ?? 6e4, remainingMs);
884
- const usesPrefill = req.transport !== "responses" && req.model.startsWith("anthropic/");
885
- const prefill = usesPrefill ? "{" : "";
886
- const messages = [
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
- let response;
909
- try {
910
- response = await fetchImpl(`${baseUrl}${endpoint}`, {
911
- method: "POST",
912
- headers: {
913
- ...authHeaders,
914
- ...taskTypeHeader(req.taskType),
915
- "Content-Type": "application/json"
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
- if (!response.ok) {
929
- clearTimeout(timer);
930
- const reason = `portal returned ${response.status}`;
931
- if (response.status === 401) {
932
- const tokenAuth = !req.apiKey && !!req.getToken;
933
- return tokenAuth ? {
934
- kind: "retryable",
935
- code: "http-retryable",
936
- reason: `${reason} (token may be expired; refreshing)`,
937
- httpStatus: response.status
938
- } : { kind: "terminal", code: "http-terminal", reason, httpStatus: response.status };
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
- if (!isRetryableStatus(response.status))
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
- const rawContent = extractCompletionContent(body);
972
- if (!rawContent) {
973
- return {
974
- kind: "retryable",
975
- code: "empty-content",
976
- reason: "portal response had no completion content"
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
- const looksLikeContinuation = prefill && /^\s*"/.test(rawContent);
980
- const content = looksLikeContinuation ? prefill + rawContent : rawContent;
981
- const candidate = extractJsonCandidate(content);
982
- let value;
983
- try {
984
- value = JSON.parse(candidate);
985
- } catch {
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
- if (value === null) {
993
- return { kind: "retryable", code: "null-completion", reason: "completion parsed to null" };
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
- return { kind: "ok", value };
996
- }
997
- function extractJsonCandidate(raw) {
998
- const trimmed = raw.trim();
999
- const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
1000
- const body = fence ? fence[1].trim() : trimmed;
1001
- const start = body.search(/[{[]/);
1002
- if (start < 0) return body;
1003
- const open = body[start];
1004
- const close = open === "{" ? "}" : "]";
1005
- let depth = 0;
1006
- let inString = false;
1007
- let escape = false;
1008
- for (let i = start; i < body.length; i++) {
1009
- const ch = body[i];
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
- if (ch === '"') {
1019
- inString = !inString;
1020
- continue;
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
- if (inString) continue;
1023
- if (ch === open) depth++;
1024
- else if (ch === close) {
1025
- depth--;
1026
- if (depth === 0) return body.slice(start, i + 1);
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
- return body;
1030
- }
1031
- function asRecord(value) {
1032
- return typeof value === "object" && value !== null ? value : void 0;
1033
- }
1034
- function asCount(value) {
1035
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1036
- }
1037
- function logPortalUsage(body, tag) {
1038
- try {
1039
- const root = asRecord(body);
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 isModerationResponse(body) {
1057
- return typeof body === "object" && body !== null && body.id === "moderation";
1058
- }
1059
- function extractCompletionContent(body) {
1060
- if (typeof body !== "object" || body === null) return null;
1061
- const responsesText = extractResponsesContent(body);
1062
- if (responsesText !== null) return responsesText;
1063
- const choices = body.choices;
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 extractResponsesContent(body) {
1070
- const root = asRecord(body);
1071
- if (!root) return null;
1072
- if (typeof root.output_text === "string" && root.output_text !== "") return root.output_text;
1073
- if (!Array.isArray(root.output)) return null;
1074
- 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("");
1075
- return text;
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
  };