offrouter-core 0.2.0 → 0.2.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 (139) hide show
  1. package/dist/audit.d.ts +137 -0
  2. package/dist/audit.d.ts.map +1 -0
  3. package/dist/audit.js +320 -0
  4. package/dist/audit.js.map +1 -0
  5. package/dist/auth/credential-chain.d.ts +84 -0
  6. package/dist/auth/credential-chain.d.ts.map +1 -0
  7. package/dist/auth/credential-chain.js +150 -0
  8. package/dist/auth/credential-chain.js.map +1 -0
  9. package/dist/auth/index.d.ts +10 -0
  10. package/dist/auth/index.d.ts.map +1 -0
  11. package/dist/auth/index.js +7 -0
  12. package/dist/auth/index.js.map +1 -0
  13. package/dist/auth/keychain.d.ts +60 -0
  14. package/dist/auth/keychain.d.ts.map +1 -0
  15. package/dist/auth/keychain.js +104 -0
  16. package/dist/auth/keychain.js.map +1 -0
  17. package/dist/auth/oauth-pkce.d.ts +138 -0
  18. package/dist/auth/oauth-pkce.d.ts.map +1 -0
  19. package/dist/auth/oauth-pkce.js +375 -0
  20. package/dist/auth/oauth-pkce.js.map +1 -0
  21. package/dist/auth/oauth-server.d.ts +36 -0
  22. package/dist/auth/oauth-server.d.ts.map +1 -0
  23. package/dist/auth/oauth-server.js +210 -0
  24. package/dist/auth/oauth-server.js.map +1 -0
  25. package/dist/config.d.ts +76 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +355 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/delegation.d.ts +123 -0
  30. package/dist/delegation.d.ts.map +1 -0
  31. package/dist/delegation.js +455 -0
  32. package/dist/delegation.js.map +1 -0
  33. package/dist/index.d.ts +41 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +26 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/policy.d.ts +45 -0
  38. package/dist/policy.d.ts.map +1 -0
  39. package/dist/policy.js +318 -0
  40. package/dist/policy.js.map +1 -0
  41. package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
  42. package/dist/protocol.d.ts.map +1 -0
  43. package/dist/protocol.js +6 -0
  44. package/dist/protocol.js.map +1 -0
  45. package/dist/providers/adapter.d.ts +137 -0
  46. package/dist/providers/adapter.d.ts.map +1 -0
  47. package/dist/providers/adapter.js +163 -0
  48. package/dist/providers/adapter.js.map +1 -0
  49. package/dist/providers/catalog-data.d.ts +128 -0
  50. package/dist/providers/catalog-data.d.ts.map +1 -0
  51. package/dist/providers/catalog-data.js +397 -0
  52. package/dist/providers/catalog-data.js.map +1 -0
  53. package/dist/providers/catalog.d.ts +63 -0
  54. package/dist/providers/catalog.d.ts.map +1 -0
  55. package/dist/providers/catalog.js +394 -0
  56. package/dist/providers/catalog.js.map +1 -0
  57. package/dist/providers/fake.d.ts +46 -0
  58. package/dist/providers/fake.d.ts.map +1 -0
  59. package/dist/providers/fake.js +234 -0
  60. package/dist/providers/fake.js.map +1 -0
  61. package/dist/providers/openai-compatible.d.ts +65 -0
  62. package/dist/providers/openai-compatible.d.ts.map +1 -0
  63. package/dist/providers/openai-compatible.js +434 -0
  64. package/dist/providers/openai-compatible.js.map +1 -0
  65. package/dist/proxy/responses-mapper.d.ts +366 -0
  66. package/dist/proxy/responses-mapper.d.ts.map +1 -0
  67. package/dist/proxy/responses-mapper.js +517 -0
  68. package/dist/proxy/responses-mapper.js.map +1 -0
  69. package/dist/proxy/responses-server.d.ts +29 -0
  70. package/dist/proxy/responses-server.d.ts.map +1 -0
  71. package/dist/proxy/responses-server.js +360 -0
  72. package/dist/proxy/responses-server.js.map +1 -0
  73. package/dist/router.d.ts +18 -0
  74. package/dist/router.d.ts.map +1 -0
  75. package/dist/router.js +296 -0
  76. package/dist/router.js.map +1 -0
  77. package/dist/schemas.d.ts +560 -0
  78. package/dist/schemas.d.ts.map +1 -0
  79. package/{src/schemas.ts → dist/schemas.js} +83 -103
  80. package/dist/schemas.js.map +1 -0
  81. package/dist/secrets.d.ts +112 -0
  82. package/dist/secrets.d.ts.map +1 -0
  83. package/dist/secrets.js +326 -0
  84. package/dist/secrets.js.map +1 -0
  85. package/dist/types.d.ts +117 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +6 -0
  88. package/dist/types.js.map +1 -0
  89. package/dist/usage-file.d.ts +59 -0
  90. package/dist/usage-file.d.ts.map +1 -0
  91. package/dist/usage-file.js +316 -0
  92. package/dist/usage-file.js.map +1 -0
  93. package/dist/usage.d.ts +235 -0
  94. package/dist/usage.d.ts.map +1 -0
  95. package/dist/usage.js +517 -0
  96. package/dist/usage.js.map +1 -0
  97. package/package.json +9 -4
  98. package/src/audit.test.ts +0 -302
  99. package/src/audit.ts +0 -553
  100. package/src/auth/credential-chain.test.ts +0 -326
  101. package/src/auth/credential-chain.ts +0 -235
  102. package/src/auth/index.ts +0 -45
  103. package/src/auth/keychain.test.ts +0 -265
  104. package/src/auth/keychain.ts +0 -168
  105. package/src/auth/oauth-pkce.test.ts +0 -329
  106. package/src/auth/oauth-pkce.ts +0 -571
  107. package/src/auth/oauth-server.test.ts +0 -83
  108. package/src/auth/oauth-server.ts +0 -296
  109. package/src/config.test.ts +0 -479
  110. package/src/config.ts +0 -505
  111. package/src/delegation.test.ts +0 -368
  112. package/src/delegation.ts +0 -605
  113. package/src/index.ts +0 -280
  114. package/src/policy.test.ts +0 -634
  115. package/src/policy.ts +0 -420
  116. package/src/providers/adapter.test.ts +0 -293
  117. package/src/providers/adapter.ts +0 -328
  118. package/src/providers/catalog-data.test.ts +0 -258
  119. package/src/providers/catalog-data.ts +0 -498
  120. package/src/providers/catalog.test.ts +0 -84
  121. package/src/providers/catalog.ts +0 -483
  122. package/src/providers/fake.ts +0 -312
  123. package/src/providers/openai-compatible.test.ts +0 -366
  124. package/src/providers/openai-compatible.ts +0 -554
  125. package/src/proxy/responses-mapper.test.ts +0 -290
  126. package/src/proxy/responses-mapper.ts +0 -736
  127. package/src/proxy/responses-server.test.ts +0 -322
  128. package/src/proxy/responses-server.ts +0 -469
  129. package/src/router.test.ts +0 -699
  130. package/src/router.ts +0 -352
  131. package/src/schemas.test.ts +0 -291
  132. package/src/secrets.test.ts +0 -271
  133. package/src/secrets.ts +0 -461
  134. package/src/types.ts +0 -173
  135. package/src/usage-file.test.ts +0 -243
  136. package/src/usage-file.ts +0 -435
  137. package/src/usage.test.ts +0 -335
  138. package/src/usage.ts +0 -859
  139. package/tsconfig.json +0 -9
package/src/audit.ts DELETED
@@ -1,553 +0,0 @@
1
- /**
2
- * Redacted audit log store for OffRouter V1.
3
- *
4
- * V1 keeps only digests, short redacted previews (opt-in), and decision
5
- * summaries. Raw prompt text and secrets are never persisted by default.
6
- *
7
- * In-memory only for this task; file/JSONL backend comes after the interface
8
- * stabilizes (see design: ~/.offrouter/audit/*.jsonl).
9
- */
10
- import { randomBytes } from "node:crypto";
11
- import { redact, REDACTED } from "./secrets.js";
12
- import type {
13
- FallbackChainEntry,
14
- FallbackReason,
15
- PolicyDenial,
16
- RouteDecision,
17
- RouteRequest,
18
- SelectedRoute,
19
- } from "./types.js";
20
-
21
- /** Default max chars kept for an opt-in redacted prompt preview. */
22
- export const DEFAULT_PREVIEW_MAX_CHARS = 80;
23
-
24
- /** Cancellation lifecycle for an audited request/decision in-process. */
25
- export type CancellationState = "active" | "cancelled" | "completed";
26
-
27
- /** High-level audit event kind. */
28
- export type AuditRecordKind =
29
- | "route_decision"
30
- | "api_key_fallback"
31
- | "cancellation";
32
-
33
- /**
34
- * Append-only audit record. Intentionally omits raw prompt text.
35
- * `promptPreviewRedacted` appears only when explicitly requested at append.
36
- */
37
- export interface AuditRecord {
38
- recordId: string;
39
- kind: AuditRecordKind;
40
- timestamp: string;
41
- requestId: string;
42
- decisionId?: string;
43
- profile?: string;
44
- harnessKind?: string;
45
- /** Prompt content login digest (sha256:..., etc.). Never the prompt itself. */
46
- promptDigest?: string;
47
- /**
48
- * Short, redacted preview. Present only when the append opted into previews.
49
- * Never the full raw user prompt.
50
- */
51
- promptPreviewRedacted?: string;
52
- /** Digest-oriented decision summary from routing. */
53
- auditSummary?: string;
54
- reason: string;
55
- explanation: string;
56
- route?: SelectedRoute | null;
57
- fallbackChain?: FallbackChainEntry[];
58
- /** Explicit API-key fallback reason when paid capacity was used. */
59
- apiKeyFallbackReason?: FallbackReason | string;
60
- policyDenials?: PolicyDenial[];
61
- blocked?: boolean;
62
- needsConfiguration?: boolean;
63
- cancellationState?: CancellationState;
64
- cancelledAt?: string;
65
- }
66
-
67
- export interface AppendDecisionOptions {
68
- /**
69
- * When true, store a short redacted preview derived from promptPreview.
70
- * Default false — digests and summaries only.
71
- */
72
- includePromptPreview?: boolean;
73
- /** Max chars of redacted preview when includePromptPreview is true. */
74
- previewMaxChars?: number;
75
- /**
76
- * Known secret values to strip in addition to pattern-based redaction.
77
- * Never stored; used only during redaction.
78
- */
79
- knownSecrets?: Iterable<string>;
80
- /** Initial cancellation state; default "active" for successful routes. */
81
- cancellationState?: CancellationState;
82
- /** Override clock for tests. */
83
- now?: () => Date;
84
- }
85
-
86
- export interface ListAuditOptions {
87
- requestId?: string;
88
- decisionId?: string;
89
- kind?: AuditRecordKind;
90
- /** Max records to return (most recent first). */
91
- limit?: number;
92
- }
93
-
94
- export interface MarkCancelledOptions {
95
- reason?: string;
96
- now?: () => Date;
97
- }
98
-
99
- /**
100
- * Audit log contract. Implementations must never persist raw secrets or full
101
- * prompt bodies by default.
102
- */
103
- export interface AuditStore {
104
- appendDecision(
105
- request: RouteRequest,
106
- decision: RouteDecision,
107
- options?: AppendDecisionOptions,
108
- ): Promise<AuditRecord>;
109
-
110
- /**
111
- * Record that an API-key fallback was used, with an audited reason.
112
- * Always redacted; never log the delegated prompt or credential material.
113
- */
114
- recordApiKeyFallback(input: {
115
- requestId: string;
116
- decisionId?: string;
117
- reason: FallbackReason | string;
118
- explanation: string;
119
- route?: SelectedRoute | null;
120
- /** Optional digest already present on the route request/decision. */
121
- promptDigest?: string;
122
- knownSecrets?: Iterable<string>;
123
- now?: () => Date;
124
- }): Promise<AuditRecord>;
125
-
126
- markCancelled(
127
- requestId: string,
128
- options?: MarkCancelledOptions,
129
- ): Promise<AuditRecord | undefined>;
130
-
131
- markCompleted(
132
- requestId: string,
133
- options?: { now?: () => Date },
134
- ): Promise<AuditRecord | undefined>;
135
-
136
- getCancellationState(
137
- requestId: string,
138
- ): Promise<CancellationState | undefined>;
139
-
140
- list(options?: ListAuditOptions): Promise<AuditRecord[]>;
141
-
142
- get(recordId: string): Promise<AuditRecord | undefined>;
143
-
144
- /**
145
- * JSON-safe diagnostic view. Must never include raw prompt text or secrets.
146
- */
147
- toJSON(): Record<string, unknown>;
148
- }
149
-
150
- export interface InMemoryAuditStoreOptions {
151
- /** Optional hard cap on retained records (FIFO eviction of oldest). */
152
- maxRecords?: number;
153
- /** Default known secrets used for every redaction pass. */
154
- knownSecrets?: Iterable<string>;
155
- now?: () => Date;
156
- }
157
-
158
- function newId(prefix: string): string {
159
- return `${prefix}_${randomBytes(8).toString("hex")}`;
160
- }
161
-
162
- function clampPreview(text: string, maxChars: number): string {
163
- if (text.length <= maxChars) return text;
164
- return `${text.slice(0, Math.max(0, maxChars - 1))}…`;
165
- }
166
-
167
- /**
168
- * Strip secret-like patterns and optionally known secrets from free text.
169
- * Safe for empty/undefined values.
170
- */
171
- function safeRedact(
172
- value: string | undefined,
173
- knownSecrets: Iterable<string>,
174
- ): string {
175
- if (value == null || value === "") return value ?? "";
176
- return redact(value, knownSecrets);
177
- }
178
-
179
- function looksLikeApiKeyFallback(decision: RouteDecision): boolean {
180
- if (decision.route?.authTier === "api-key") {
181
- if (decision.reason === "api_key_fallback") return true;
182
- if (
183
- decision.fallbackChain?.some(
184
- (e) => e.reason === "api_key_fallback" || e.authTier === "api-key",
185
- )
186
- ) {
187
- return true;
188
- }
189
- // Any API-key selection with a subscription fallback reason counts.
190
- if (
191
- decision.fallbackChain?.some(
192
- (e) =>
193
- e.reason === "subscription_exhausted" ||
194
- e.reason === "subscription_degraded" ||
195
- e.reason === "subscription_unconfigured" ||
196
- e.reason === "provider_error",
197
- )
198
- ) {
199
- return true;
200
- }
201
- }
202
- return decision.reason === "api_key_fallback";
203
- }
204
-
205
- function extractApiKeyFallbackReason(
206
- decision: RouteDecision,
207
- ): FallbackReason | string | undefined {
208
- if (!looksLikeApiKeyFallback(decision)) return undefined;
209
- const chainHit = decision.fallbackChain?.find(
210
- (e) => e.reason === "api_key_fallback",
211
- );
212
- if (chainHit) return chainHit.reason;
213
- const exhausted = decision.fallbackChain?.find(
214
- (e) =>
215
- e.reason === "subscription_exhausted" ||
216
- e.reason === "subscription_degraded" ||
217
- e.reason === "subscription_unconfigured" ||
218
- e.reason === "provider_error",
219
- );
220
- if (exhausted) return exhausted.reason;
221
- if (decision.route?.authTier === "api-key") return "api_key_fallback";
222
- return decision.reason;
223
- }
224
-
225
- /**
226
- * In-memory audit store for tests and single-process V1 use.
227
- * Appends are serialized so concurrent writers cannot interleave corruptly.
228
- */
229
- export class InMemoryAuditStore implements AuditStore {
230
- readonly #records: AuditRecord[] = [];
231
- readonly #byId = new Map<string, AuditRecord>();
232
- /** Latest cancellation-bearing record per requestId. */
233
- readonly #latestByRequest = new Map<string, AuditRecord>();
234
- readonly #maxRecords: number | undefined;
235
- readonly #defaultKnownSecrets: string[];
236
- readonly #now: () => Date;
237
- #chain: Promise<unknown> = Promise.resolve();
238
-
239
- constructor(options: InMemoryAuditStoreOptions = {}) {
240
- this.#maxRecords = options.maxRecords;
241
- this.#defaultKnownSecrets = options.knownSecrets
242
- ? [...options.knownSecrets].filter((s) => typeof s === "string" && s.length > 0)
243
- : [];
244
- this.#now = options.now ?? (() => new Date());
245
- }
246
-
247
- /** Serialize mutations so concurrent appends stay ordered and consistent. */
248
- #exclusive<T>(fn: () => T | Promise<T>): Promise<T> {
249
- const run = this.#chain.then(fn, fn);
250
- this.#chain = run.then(
251
- () => undefined,
252
- () => undefined,
253
- );
254
- return run;
255
- }
256
-
257
- async appendDecision(
258
- request: RouteRequest,
259
- decision: RouteDecision,
260
- options: AppendDecisionOptions = {},
261
- ): Promise<AuditRecord> {
262
- return this.#exclusive(() => {
263
- const known = mergeKnown(
264
- this.#defaultKnownSecrets,
265
- options.knownSecrets,
266
- );
267
- const now = (options.now ?? this.#now)();
268
- const includePreview = options.includePromptPreview === true;
269
- const maxChars = options.previewMaxChars ?? DEFAULT_PREVIEW_MAX_CHARS;
270
-
271
- const apiKeyFallbackReason = extractApiKeyFallbackReason(decision);
272
- const kind: AuditRecordKind = apiKeyFallbackReason
273
- ? "api_key_fallback"
274
- : "route_decision";
275
-
276
- const initialCancellation: CancellationState | undefined =
277
- options.cancellationState ??
278
- (decision.blocked || decision.route == null ? "completed" : "active");
279
-
280
- // Drop any raw-looking fields that might have leaked into reason/explanation.
281
- const reason = safeRedact(decision.reason, known);
282
- const explanation = safeRedact(decision.explanation, known);
283
- const auditSummary = decision.auditSummary
284
- ? safeRedact(decision.auditSummary, known)
285
- : safeRedact(
286
- `decision=${decision.decisionId} digest=${request.task.promptDigest}`,
287
- known,
288
- );
289
-
290
- // Guard: never let raw promptPreview appear in stored free text.
291
- const rawPreview = request.task.promptPreview;
292
- const redactedStoredPreview = includePreview
293
- ? clampPreview(safeRedact(rawPreview, known), maxChars)
294
- : undefined;
295
-
296
- const record: AuditRecord = {
297
- recordId: newId("aud"),
298
- kind,
299
- timestamp: now.toISOString(),
300
- requestId: request.requestId,
301
- decisionId: decision.decisionId,
302
- profile: request.harness.profile,
303
- harnessKind: request.harness.kind,
304
- promptDigest: request.task.promptDigest,
305
- auditSummary,
306
- reason,
307
- explanation,
308
- route: decision.route ?? null,
309
- fallbackChain: decision.fallbackChain
310
- ? decision.fallbackChain.map((e) => ({ ...e }))
311
- : undefined,
312
- apiKeyFallbackReason,
313
- policyDenials: decision.policyDenials
314
- ? decision.policyDenials.map((d) => ({
315
- ...d,
316
- message: safeRedact(d.message, known),
317
- }))
318
- : undefined,
319
- blocked: decision.blocked,
320
- needsConfiguration: decision.needsConfiguration,
321
- cancellationState: initialCancellation,
322
- };
323
-
324
- if (redactedStoredPreview !== undefined) {
325
- // If redaction wiped the value entirely, keep a stable redacted token.
326
- record.promptPreviewRedacted =
327
- redactedStoredPreview.length > 0
328
- ? redactedStoredPreview
329
- : REDACTED;
330
- }
331
-
332
- // Assert default path never embeds raw preview (defensive for tests + misuse).
333
- this.#assertNoRawLeak(record, rawPreview, known);
334
-
335
- this.#push(record);
336
- return structuredClone(record);
337
- });
338
- }
339
-
340
- async recordApiKeyFallback(input: {
341
- requestId: string;
342
- decisionId?: string;
343
- reason: FallbackReason | string;
344
- explanation: string;
345
- route?: SelectedRoute | null;
346
- promptDigest?: string;
347
- knownSecrets?: Iterable<string>;
348
- now?: () => Date;
349
- }): Promise<AuditRecord> {
350
- return this.#exclusive(() => {
351
- const known = mergeKnown(
352
- this.#defaultKnownSecrets,
353
- input.knownSecrets,
354
- );
355
- const now = (input.now ?? this.#now)();
356
- const record: AuditRecord = {
357
- recordId: newId("aud"),
358
- kind: "api_key_fallback",
359
- timestamp: now.toISOString(),
360
- requestId: input.requestId,
361
- decisionId: input.decisionId,
362
- promptDigest: input.promptDigest,
363
- reason: safeRedact(String(input.reason), known),
364
- explanation: safeRedact(input.explanation, known),
365
- route: input.route ?? null,
366
- apiKeyFallbackReason: safeRedact(String(input.reason), known),
367
- cancellationState: "active",
368
- };
369
- this.#push(record);
370
- return structuredClone(record);
371
- });
372
- }
373
-
374
- async markCancelled(
375
- requestId: string,
376
- options: MarkCancelledOptions = {},
377
- ): Promise<AuditRecord | undefined> {
378
- return this.#exclusive(() => {
379
- const latest = this.#latestByRequest.get(requestId);
380
- if (!latest) return undefined;
381
- if (latest.cancellationState === "cancelled") {
382
- return structuredClone(latest);
383
- }
384
- const now = (options.now ?? this.#now)();
385
- const known = this.#defaultKnownSecrets;
386
- const update: AuditRecord = {
387
- ...latest,
388
- recordId: newId("aud"),
389
- kind: "cancellation",
390
- timestamp: now.toISOString(),
391
- cancellationState: "cancelled",
392
- cancelledAt: now.toISOString(),
393
- reason: options.reason
394
- ? safeRedact(options.reason, known)
395
- : "cancelled",
396
- explanation: options.reason
397
- ? safeRedact(options.reason, known)
398
- : "Request cancelled in-process.",
399
- };
400
- this.#push(update);
401
- return structuredClone(update);
402
- });
403
- }
404
-
405
- async markCompleted(
406
- requestId: string,
407
- options: { now?: () => Date } = {},
408
- ): Promise<AuditRecord | undefined> {
409
- return this.#exclusive(() => {
410
- const latest = this.#latestByRequest.get(requestId);
411
- if (!latest) return undefined;
412
- if (
413
- latest.cancellationState === "completed" ||
414
- latest.cancellationState === "cancelled"
415
- ) {
416
- return structuredClone(latest);
417
- }
418
- const now = (options.now ?? this.#now)();
419
- const update: AuditRecord = {
420
- ...latest,
421
- recordId: newId("aud"),
422
- kind: latest.kind === "cancellation" ? "cancellation" : latest.kind,
423
- timestamp: now.toISOString(),
424
- cancellationState: "completed",
425
- };
426
- this.#push(update);
427
- return structuredClone(update);
428
- });
429
- }
430
-
431
- async getCancellationState(
432
- requestId: string,
433
- ): Promise<CancellationState | undefined> {
434
- const latest = this.#latestByRequest.get(requestId);
435
- return latest?.cancellationState;
436
- }
437
-
438
- async list(options: ListAuditOptions = {}): Promise<AuditRecord[]> {
439
- let rows = this.#records.slice();
440
- if (options.requestId) {
441
- rows = rows.filter((r) => r.requestId === options.requestId);
442
- }
443
- if (options.decisionId) {
444
- rows = rows.filter((r) => r.decisionId === options.decisionId);
445
- }
446
- if (options.kind) {
447
- rows = rows.filter((r) => r.kind === options.kind);
448
- }
449
- // Most recent first.
450
- rows = rows.slice().reverse();
451
- if (options.limit !== undefined && options.limit >= 0) {
452
- rows = rows.slice(0, options.limit);
453
- }
454
- return rows.map((r) => structuredClone(r));
455
- }
456
-
457
- async get(recordId: string): Promise<AuditRecord | undefined> {
458
- const hit = this.#byId.get(recordId);
459
- return hit ? structuredClone(hit) : undefined;
460
- }
461
-
462
- toJSON(): Record<string, unknown> {
463
- // Never surface free-text that might contain secrets beyond redacted fields.
464
- return {
465
- kind: "in-memory-audit",
466
- recordCount: this.#records.length,
467
- requestIds: [...new Set(this.#records.map((r) => r.requestId))],
468
- // Digests only — no previews in the diagnostic surface.
469
- digests: this.#records
470
- .map((r) => r.promptDigest)
471
- .filter((d): d is string => typeof d === "string"),
472
- kinds: this.#records.reduce<Record<string, number>>((acc, r) => {
473
- acc[r.kind] = (acc[r.kind] ?? 0) + 1;
474
- return acc;
475
- }, {}),
476
- };
477
- }
478
-
479
- #push(record: AuditRecord): void {
480
- this.#records.push(record);
481
- this.#byId.set(record.recordId, record);
482
- this.#latestByRequest.set(record.requestId, record);
483
-
484
- if (
485
- this.#maxRecords !== undefined &&
486
- this.#records.length > this.#maxRecords
487
- ) {
488
- const overflow = this.#records.length - this.#maxRecords;
489
- const removed = this.#records.splice(0, overflow);
490
- for (const old of removed) {
491
- this.#byId.delete(old.recordId);
492
- // Only drop latest mapping if it still points at the evicted record.
493
- const latest = this.#latestByRequest.get(old.requestId);
494
- if (latest?.recordId === old.recordId) {
495
- this.#latestByRequest.delete(old.requestId);
496
- }
497
- }
498
- }
499
- }
500
-
501
- #assertNoRawLeak(
502
- record: AuditRecord,
503
- rawPreview: string,
504
- knownSecrets: string[],
505
- ): void {
506
- if (!rawPreview || rawPreview.length < 8) return;
507
- const blobs = [
508
- record.auditSummary,
509
- record.reason,
510
- record.explanation,
511
- record.promptPreviewRedacted,
512
- ...(record.policyDenials?.map((d) => d.message) ?? []),
513
- ]
514
- .filter((s): s is string => typeof s === "string")
515
- .join("\n");
516
-
517
- // Allow the case where the "preview" is only a digest-like token or was fully redacted.
518
- if (blobs.includes(rawPreview)) {
519
- // Self-heal by scrubbing — should not happen under normal use.
520
- if (record.auditSummary?.includes(rawPreview)) {
521
- record.auditSummary = redact(record.auditSummary, knownSecrets).replaceAll(
522
- rawPreview,
523
- REDACTED,
524
- );
525
- }
526
- if (record.explanation.includes(rawPreview)) {
527
- record.explanation = redact(record.explanation, knownSecrets).replaceAll(
528
- rawPreview,
529
- REDACTED,
530
- );
531
- }
532
- if (record.reason.includes(rawPreview)) {
533
- record.reason = redact(record.reason, knownSecrets).replaceAll(
534
- rawPreview,
535
- REDACTED,
536
- );
537
- }
538
- }
539
- }
540
- }
541
-
542
- function mergeKnown(
543
- base: readonly string[],
544
- extra: Iterable<string> | undefined,
545
- ): string[] {
546
- const out = [...base];
547
- if (extra) {
548
- for (const s of extra) {
549
- if (typeof s === "string" && s.length > 0) out.push(s);
550
- }
551
- }
552
- return out;
553
- }