@anuma/sdk 6.33.0 → 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.
- package/dist/design/index.cjs +0 -2
- package/dist/design/index.d.mts +0 -7
- package/dist/design/index.d.ts +0 -7
- package/dist/design/index.mjs +0 -2
- package/dist/expo/{chunk-XD6WOQ5I.mjs → chunk-GQBTAM6Y.mjs} +0 -70
- package/dist/expo/{consolidate-7DBUHQRK.mjs → consolidate-3H2WY3FU.mjs} +1 -8
- package/dist/expo/index.cjs +15401 -16279
- package/dist/expo/index.d.mts +5735 -6661
- package/dist/expo/index.d.ts +5735 -6661
- package/dist/expo/index.mjs +15238 -16035
- package/dist/index.cjs +2258 -2411
- package/dist/index.d.mts +914 -1054
- package/dist/index.d.ts +914 -1054
- package/dist/index.mjs +2258 -2409
- package/dist/memory/context.d.mts +0 -73
- package/dist/memory/context.d.ts +0 -73
- package/dist/next/index.cjs +1 -8
- package/dist/next/index.mjs +1 -8
- package/dist/pii/transformers.d.mts +0 -32
- package/dist/pii/transformers.d.ts +0 -32
- package/dist/react/{chunk-XNQRJBIH.mjs → chunk-UFO4W3JX.mjs} +0 -70
- package/dist/react/{consolidate-5DVINCUE.mjs → consolidate-RHIAUBT4.mjs} +1 -8
- package/dist/react/index.cjs +32429 -33403
- package/dist/react/index.d.mts +13355 -14554
- package/dist/react/index.d.ts +13355 -14554
- package/dist/react/index.mjs +32555 -33450
- package/dist/server/{chunk-KSYV7UO2.mjs → chunk-4RGGAOAJ.mjs} +835 -905
- package/dist/server/{consolidate-MCNBSUXD.mjs → consolidate-YEGACWLR.mjs} +1 -8
- package/dist/server/index.cjs +22308 -23109
- package/dist/server/index.d.mts +5355 -6302
- package/dist/server/index.d.ts +5355 -6302
- package/dist/server/index.mjs +20543 -21279
- package/dist/telemetry/index.cjs +0 -9
- package/dist/telemetry/index.d.mts +88 -198
- package/dist/telemetry/index.d.ts +88 -198
- package/dist/telemetry/index.mjs +0 -9
- package/dist/tools/index.cjs +13036 -13347
- package/dist/tools/index.d.mts +2434 -2729
- package/dist/tools/index.d.ts +2434 -2729
- package/dist/tools/index.mjs +13036 -13347
- package/dist/tools/selection/index.cjs +0 -116
- package/dist/tools/selection/index.d.mts +0 -159
- package/dist/tools/selection/index.d.ts +0 -159
- package/dist/tools/selection/index.mjs +0 -116
- 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
|
-
|
|
420
|
+
return { messages: redactedMessages, matches: allMatches };
|
|
646
421
|
}
|
|
647
422
|
/**
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
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
|
-
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
665
|
-
return
|
|
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
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
*
|
|
677
|
-
*
|
|
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
|
-
|
|
680
|
-
const
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
async detectNerSpans(text) {
|
|
809
|
-
if (!this.nerDetector) return [];
|
|
729
|
+
if (auth.getToken) {
|
|
730
|
+
let token;
|
|
810
731
|
try {
|
|
811
|
-
|
|
732
|
+
token = await auth.getToken();
|
|
812
733
|
} catch (err) {
|
|
813
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
789
|
+
if (outcome.kind === "ok") {
|
|
790
|
+
req.onAttempt?.({ attempt, ok: true });
|
|
791
|
+
return outcome.value;
|
|
834
792
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
return
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
"
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
"
|
|
966
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
|
1066
|
-
|
|
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
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
return
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
};
|