@anuma/sdk 6.33.1 → 6.33.2

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