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.
- package/dist/audit.d.ts +137 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +320 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/credential-chain.d.ts +84 -0
- package/dist/auth/credential-chain.d.ts.map +1 -0
- package/dist/auth/credential-chain.js +150 -0
- package/dist/auth/credential-chain.js.map +1 -0
- package/dist/auth/index.d.ts +10 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/keychain.d.ts +60 -0
- package/dist/auth/keychain.d.ts.map +1 -0
- package/dist/auth/keychain.js +104 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/oauth-pkce.d.ts +138 -0
- package/dist/auth/oauth-pkce.d.ts.map +1 -0
- package/dist/auth/oauth-pkce.js +375 -0
- package/dist/auth/oauth-pkce.js.map +1 -0
- package/dist/auth/oauth-server.d.ts +36 -0
- package/dist/auth/oauth-server.d.ts.map +1 -0
- package/dist/auth/oauth-server.js +210 -0
- package/dist/auth/oauth-server.js.map +1 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +355 -0
- package/dist/config.js.map +1 -0
- package/dist/delegation.d.ts +123 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +455 -0
- package/dist/delegation.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +45 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +318 -0
- package/dist/policy.js.map +1 -0
- package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +6 -0
- package/dist/protocol.js.map +1 -0
- package/dist/providers/adapter.d.ts +137 -0
- package/dist/providers/adapter.d.ts.map +1 -0
- package/dist/providers/adapter.js +163 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/catalog-data.d.ts +128 -0
- package/dist/providers/catalog-data.d.ts.map +1 -0
- package/dist/providers/catalog-data.js +397 -0
- package/dist/providers/catalog-data.js.map +1 -0
- package/dist/providers/catalog.d.ts +63 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/catalog.js +394 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/fake.d.ts +46 -0
- package/dist/providers/fake.d.ts.map +1 -0
- package/dist/providers/fake.js +234 -0
- package/dist/providers/fake.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +65 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -0
- package/dist/providers/openai-compatible.js +434 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/proxy/responses-mapper.d.ts +366 -0
- package/dist/proxy/responses-mapper.d.ts.map +1 -0
- package/dist/proxy/responses-mapper.js +517 -0
- package/dist/proxy/responses-mapper.js.map +1 -0
- package/dist/proxy/responses-server.d.ts +29 -0
- package/dist/proxy/responses-server.d.ts.map +1 -0
- package/dist/proxy/responses-server.js +360 -0
- package/dist/proxy/responses-server.js.map +1 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +296 -0
- package/dist/router.js.map +1 -0
- package/dist/schemas.d.ts +560 -0
- package/dist/schemas.d.ts.map +1 -0
- package/{src/schemas.ts → dist/schemas.js} +83 -103
- package/dist/schemas.js.map +1 -0
- package/dist/secrets.d.ts +112 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +326 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-file.d.ts +59 -0
- package/dist/usage-file.d.ts.map +1 -0
- package/dist/usage-file.js +316 -0
- package/dist/usage-file.js.map +1 -0
- package/dist/usage.d.ts +235 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +517 -0
- package/dist/usage.js.map +1 -0
- package/package.json +9 -4
- package/src/audit.test.ts +0 -302
- package/src/audit.ts +0 -553
- package/src/auth/credential-chain.test.ts +0 -326
- package/src/auth/credential-chain.ts +0 -235
- package/src/auth/index.ts +0 -45
- package/src/auth/keychain.test.ts +0 -265
- package/src/auth/keychain.ts +0 -168
- package/src/auth/oauth-pkce.test.ts +0 -329
- package/src/auth/oauth-pkce.ts +0 -571
- package/src/auth/oauth-server.test.ts +0 -83
- package/src/auth/oauth-server.ts +0 -296
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -505
- package/src/delegation.test.ts +0 -368
- package/src/delegation.ts +0 -605
- package/src/index.ts +0 -280
- package/src/policy.test.ts +0 -634
- package/src/policy.ts +0 -420
- package/src/providers/adapter.test.ts +0 -293
- package/src/providers/adapter.ts +0 -328
- package/src/providers/catalog-data.test.ts +0 -258
- package/src/providers/catalog-data.ts +0 -498
- package/src/providers/catalog.test.ts +0 -84
- package/src/providers/catalog.ts +0 -483
- package/src/providers/fake.ts +0 -312
- package/src/providers/openai-compatible.test.ts +0 -366
- package/src/providers/openai-compatible.ts +0 -554
- package/src/proxy/responses-mapper.test.ts +0 -290
- package/src/proxy/responses-mapper.ts +0 -736
- package/src/proxy/responses-server.test.ts +0 -322
- package/src/proxy/responses-server.ts +0 -469
- package/src/router.test.ts +0 -699
- package/src/router.ts +0 -352
- package/src/schemas.test.ts +0 -291
- package/src/secrets.test.ts +0 -271
- package/src/secrets.ts +0 -461
- package/src/types.ts +0 -173
- package/src/usage-file.test.ts +0 -243
- package/src/usage-file.ts +0 -435
- package/src/usage.test.ts +0 -335
- package/src/usage.ts +0 -859
- 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
|
-
}
|