usertrust 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +17 -1
  2. package/dist/anomaly/detector.d.ts.map +1 -1
  3. package/dist/anomaly/detector.js +36 -11
  4. package/dist/anomaly/detector.js.map +1 -1
  5. package/dist/anomaly/index.d.ts +2 -2
  6. package/dist/anomaly/index.d.ts.map +1 -1
  7. package/dist/anomaly/index.js +1 -1
  8. package/dist/anomaly/index.js.map +1 -1
  9. package/dist/anomaly/signals/spend-velocity.d.ts +39 -12
  10. package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -1
  11. package/dist/anomaly/signals/spend-velocity.js +66 -43
  12. package/dist/anomaly/signals/spend-velocity.js.map +1 -1
  13. package/dist/anomaly/signals/token-rate.d.ts +33 -13
  14. package/dist/anomaly/signals/token-rate.d.ts.map +1 -1
  15. package/dist/anomaly/signals/token-rate.js +124 -38
  16. package/dist/anomaly/signals/token-rate.js.map +1 -1
  17. package/dist/anomaly/types.d.ts +28 -6
  18. package/dist/anomaly/types.d.ts.map +1 -1
  19. package/dist/audit/anchor-doctor.d.ts +42 -0
  20. package/dist/audit/anchor-doctor.d.ts.map +1 -0
  21. package/dist/audit/anchor-doctor.js +285 -0
  22. package/dist/audit/anchor-doctor.js.map +1 -0
  23. package/dist/audit/anchor-verify.d.ts +229 -0
  24. package/dist/audit/anchor-verify.d.ts.map +1 -0
  25. package/dist/audit/anchor-verify.js +809 -0
  26. package/dist/audit/anchor-verify.js.map +1 -0
  27. package/dist/audit/anchor.d.ts +177 -0
  28. package/dist/audit/anchor.d.ts.map +1 -0
  29. package/dist/audit/anchor.js +922 -0
  30. package/dist/audit/anchor.js.map +1 -0
  31. package/dist/audit/canonical.js +1 -1
  32. package/dist/audit/canonical.js.map +1 -1
  33. package/dist/audit/chain.d.ts.map +1 -1
  34. package/dist/audit/chain.js +21 -7
  35. package/dist/audit/chain.js.map +1 -1
  36. package/dist/audit/read.d.ts +19 -0
  37. package/dist/audit/read.d.ts.map +1 -0
  38. package/dist/audit/read.js +94 -0
  39. package/dist/audit/read.js.map +1 -0
  40. package/dist/audit/rekor-verify.d.ts +116 -0
  41. package/dist/audit/rekor-verify.d.ts.map +1 -0
  42. package/dist/audit/rekor-verify.js +533 -0
  43. package/dist/audit/rekor-verify.js.map +1 -0
  44. package/dist/audit/rekor.d.ts +71 -0
  45. package/dist/audit/rekor.d.ts.map +1 -0
  46. package/dist/audit/rekor.js +556 -0
  47. package/dist/audit/rekor.js.map +1 -0
  48. package/dist/audit/rotation.js +1 -1
  49. package/dist/audit/rotation.js.map +1 -1
  50. package/dist/audit/sigv4.d.ts +44 -0
  51. package/dist/audit/sigv4.d.ts.map +1 -0
  52. package/dist/audit/sigv4.js +134 -0
  53. package/dist/audit/sigv4.js.map +1 -0
  54. package/dist/audit/verify.d.ts +78 -0
  55. package/dist/audit/verify.d.ts.map +1 -1
  56. package/dist/audit/verify.js +195 -1
  57. package/dist/audit/verify.js.map +1 -1
  58. package/dist/cli/anchor.d.ts +3 -0
  59. package/dist/cli/anchor.d.ts.map +1 -0
  60. package/dist/cli/anchor.js +626 -0
  61. package/dist/cli/anchor.js.map +1 -0
  62. package/dist/cli/completions.js +1 -1
  63. package/dist/cli/export.d.ts +3 -0
  64. package/dist/cli/export.d.ts.map +1 -0
  65. package/dist/cli/export.js +58 -0
  66. package/dist/cli/export.js.map +1 -0
  67. package/dist/cli/health.js.map +1 -1
  68. package/dist/cli/init.d.ts.map +1 -1
  69. package/dist/cli/init.js +99 -2
  70. package/dist/cli/init.js.map +1 -1
  71. package/dist/cli/inspect.d.ts.map +1 -1
  72. package/dist/cli/inspect.js +4 -34
  73. package/dist/cli/inspect.js.map +1 -1
  74. package/dist/cli/main.d.ts +2 -2
  75. package/dist/cli/main.d.ts.map +1 -1
  76. package/dist/cli/main.js +20 -1
  77. package/dist/cli/main.js.map +1 -1
  78. package/dist/cli/pricing.js +1 -1
  79. package/dist/cli/pricing.js.map +1 -1
  80. package/dist/cli/ui.d.ts +3 -0
  81. package/dist/cli/ui.d.ts.map +1 -0
  82. package/dist/cli/ui.js +35 -0
  83. package/dist/cli/ui.js.map +1 -0
  84. package/dist/cli/verify.d.ts.map +1 -1
  85. package/dist/cli/verify.js +342 -3
  86. package/dist/cli/verify.js.map +1 -1
  87. package/dist/config.d.ts.map +1 -1
  88. package/dist/config.js.map +1 -1
  89. package/dist/detect.d.ts +71 -16
  90. package/dist/detect.d.ts.map +1 -1
  91. package/dist/detect.js +141 -6
  92. package/dist/detect.js.map +1 -1
  93. package/dist/export/markdown.d.ts +12 -0
  94. package/dist/export/markdown.d.ts.map +1 -0
  95. package/dist/export/markdown.js +142 -0
  96. package/dist/export/markdown.js.map +1 -0
  97. package/dist/govern.d.ts +67 -3
  98. package/dist/govern.d.ts.map +1 -1
  99. package/dist/govern.js +1011 -219
  100. package/dist/govern.js.map +1 -1
  101. package/dist/headless.d.ts +41 -2
  102. package/dist/headless.d.ts.map +1 -1
  103. package/dist/headless.js +58 -7
  104. package/dist/headless.js.map +1 -1
  105. package/dist/index.d.ts +33 -25
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +30 -23
  108. package/dist/index.js.map +1 -1
  109. package/dist/ledger/client.d.ts.map +1 -1
  110. package/dist/ledger/client.js +57 -46
  111. package/dist/ledger/client.js.map +1 -1
  112. package/dist/ledger/engine.d.ts.map +1 -1
  113. package/dist/ledger/engine.js +22 -13
  114. package/dist/ledger/engine.js.map +1 -1
  115. package/dist/ledger/pricing.d.ts +54 -0
  116. package/dist/ledger/pricing.d.ts.map +1 -1
  117. package/dist/ledger/pricing.js +128 -8
  118. package/dist/ledger/pricing.js.map +1 -1
  119. package/dist/shared/types.d.ts +154 -340
  120. package/dist/shared/types.d.ts.map +1 -1
  121. package/dist/shared/types.js +67 -17
  122. package/dist/shared/types.js.map +1 -1
  123. package/dist/snapshot/checkpoint.d.ts.map +1 -1
  124. package/dist/snapshot/checkpoint.js +20 -2
  125. package/dist/snapshot/checkpoint.js.map +1 -1
  126. package/dist/streaming.d.ts +13 -3
  127. package/dist/streaming.d.ts.map +1 -1
  128. package/dist/streaming.js +88 -30
  129. package/dist/streaming.js.map +1 -1
  130. package/dist/supply-chain/sign.d.ts.map +1 -1
  131. package/dist/supply-chain/sign.js +3 -0
  132. package/dist/supply-chain/sign.js.map +1 -1
  133. package/package.json +6 -4
package/dist/govern.js CHANGED
@@ -30,12 +30,12 @@ import { createHash, randomUUID } from "node:crypto";
30
30
  import { existsSync, mkdirSync } from "node:fs";
31
31
  import { readFile, rename, unlink, writeFile } from "node:fs/promises";
32
32
  import { join } from "node:path";
33
- import { CreateTransferError } from "tigerbeetle-node";
33
+ import { CreateTransferStatus } from "tigerbeetle-node";
34
34
  import { createAuditWriter } from "./audit/chain.js";
35
35
  import { writeReceipt } from "./audit/rotation.js";
36
- import { detectClientKind } from "./detect.js";
36
+ import { classifyEndpoint, detectClientKind } from "./detect.js";
37
37
  import { TBTransferError, TrustTBClient, XFER_SPEND } from "./ledger/client.js";
38
- import { estimateCost, estimateInputTokens } from "./ledger/pricing.js";
38
+ import { costFromRates, estimateInputTokens, resolveRates, warnUnknownModel, } from "./ledger/pricing.js";
39
39
  import { recordPattern } from "./memory/patterns.js";
40
40
  import { DEFAULT_RULES, mergePolicies } from "./policy/default-rules.js";
41
41
  import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
@@ -66,6 +66,86 @@ class AsyncMutex {
66
66
  return release;
67
67
  }
68
68
  }
69
+ // ── M2 unknown-model policy (A5) ──
70
+ /** USD value of one usertoken: 1 usertoken = $0.0001 (one basis point of a cent). */
71
+ const USERTOKENS_PER_DOLLAR = 10_000;
72
+ /**
73
+ * Enforce config.unknownModelPolicy at AUTHORIZE time (A5). Only cloud-scope
74
+ * resolutions can be unknown — local scope always resolves to local rates.
75
+ * "deny" throws before any PENDING hold; "warn" logs once per model string per
76
+ * process via the shared warnUnknownModel helper (receipts carry
77
+ * meter.rateSource "fallback" regardless of warn dedup); "fallback" is silent.
78
+ */
79
+ function enforceUnknownModelPolicy(model, resolution, config) {
80
+ if (!resolution.unknown)
81
+ return;
82
+ if (config.unknownModelPolicy === "deny") {
83
+ throw new PolicyDeniedError(`unknown_model: ${model} not in pricing table`);
84
+ }
85
+ if (config.unknownModelPolicy === "warn") {
86
+ warnUnknownModel(model);
87
+ }
88
+ }
89
+ /**
90
+ * A4: build forward-args for a local openai stream call with
91
+ * stream_options.include_usage merged in. The caller's other stream_options
92
+ * fields survive; an explicit include_usage (true OR false) is respected —
93
+ * only a missing/null value injects. Returns null when no injection applies.
94
+ */
95
+ function withInjectedUsageOptions(args) {
96
+ const params = (args[0] ?? {});
97
+ if (params.stream !== true)
98
+ return null;
99
+ const rawOptions = params.stream_options;
100
+ const streamOptions = rawOptions != null && typeof rawOptions === "object"
101
+ ? rawOptions
102
+ : undefined;
103
+ if (streamOptions?.include_usage != null)
104
+ return null;
105
+ return [
106
+ { ...params, stream_options: { ...streamOptions, include_usage: true } },
107
+ ...args.slice(1),
108
+ ];
109
+ }
110
+ /** Message shapes an OpenAI-compat server emits when it rejects an unknown field. */
111
+ const STREAM_OPTIONS_REJECTION_RE = /stream_options|include_usage|unrecognized|unknown.{0,20}(field|argument|parameter|option)/i;
112
+ /**
113
+ * A4 retry heuristic: does `err` plausibly indicate that the server rejected the
114
+ * injected `stream_options.include_usage`? We only retry-without-injection for
115
+ * these — a blanket retry on ANY error would double the provider call on
116
+ * transient failures (ECONNRESET, timeouts, 5xx) and mask the real root cause,
117
+ * so everything else rethrows the ORIGINAL error immediately. Matches the
118
+ * rejection message on the error, its nested `error.message`, or a cheaply
119
+ * reachable `response` body; or an HTTP-shaped 400/422 status on the error
120
+ * object. Tradeoff: a server that rejects the field with an opaque 500 and no
121
+ * telltale text is NOT retried — acceptable, since include_usage is widely
122
+ * supported and the caller still receives the original error.
123
+ */
124
+ function looksLikeStreamOptionsRejection(err) {
125
+ if (err == null || typeof err !== "object") {
126
+ return err instanceof Error && STREAM_OPTIONS_REJECTION_RE.test(err.message);
127
+ }
128
+ const e = err;
129
+ const status = e.status ?? e.statusCode;
130
+ if (status === 400 || status === 422)
131
+ return true;
132
+ const candidates = [e.message, e.body];
133
+ if (err instanceof Error)
134
+ candidates.push(err.message);
135
+ const nested = e.error;
136
+ if (nested != null && typeof nested === "object") {
137
+ candidates.push(nested.message);
138
+ }
139
+ const response = e.response;
140
+ if (typeof response === "string") {
141
+ candidates.push(response);
142
+ }
143
+ else if (response != null && typeof response === "object") {
144
+ const r = response;
145
+ candidates.push(r.data, r.body);
146
+ }
147
+ return candidates.some((c) => typeof c === "string" && STREAM_OPTIONS_REJECTION_RE.test(c));
148
+ }
69
149
  async function loadSpendLedger(vaultBase) {
70
150
  const ledgerPath = join(vaultBase, VAULT_DIR, "spend-ledger.json");
71
151
  try {
@@ -139,7 +219,6 @@ export async function trust(client, opts) {
139
219
  budget: opts?.budget ?? DEFAULT_BUDGET,
140
220
  });
141
221
  }
142
- const customRates = config.pricing === "custom" ? config.customRates : undefined;
143
222
  const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
144
223
  // AUD-470: Only accept injected _engine/_audit in test environments.
145
224
  // In production, silently ignore them to prevent governance bypass.
@@ -189,8 +268,12 @@ export async function trust(client, opts) {
189
268
  else {
190
269
  engine = null;
191
270
  }
192
- // 5. Detect client kind
271
+ // 5. Detect client kind (transport) + classify the endpoint (settlement
272
+ // regime, M2). classifyEndpoint runs BESIDE detectClientKind — the endpoint
273
+ // class, not the model string, picks local vs cloud metering (A3: this scope
274
+ // is captured once here and used verbatim at every authorize/settle below).
193
275
  const kind = detectClientKind(client);
276
+ const endpoint = classifyEndpoint(client, config, opts?.endpoint);
194
277
  // 6. Track state
195
278
  let destroyed = false;
196
279
  const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
@@ -199,9 +282,43 @@ export async function trust(client, opts) {
199
282
  let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
200
283
  // Streaming anomaly detector — shared across calls so injection-cascade
201
284
  // can track signals across the conversation. Disabled when config.anomaly.enabled=false.
202
- const anomalyDetector = createAnomalyDetector(config.anomaly, { provider: kind });
285
+ // M2 seam fix: the injected costCalculator prices spend-velocity with the
286
+ // SAME scoped rates as settlement, using the observed event's
287
+ // model/endpointClass (the detector is shared while the model varies per
288
+ // call). Denominations differ by design: cloud events → DOLLARS against
289
+ // thresholdDollarsPerMin; local events → nominal usertokens against
290
+ // localThresholdUsertokensPerMin, WITHOUT the per-call >=1 settlement floor.
291
+ // Settlement floors per call; the anomaly signal measures flow — so a
292
+ // default {0,0}-rate local stream contributes 0 and can never false-trip
293
+ // spend_velocity (rejected-merge design note, pinned by Task 3 tests).
294
+ const anomalyDetector = createAnomalyDetector(config.anomaly, {
295
+ provider: kind,
296
+ costCalculator: (calcModel, inputTokens, outputTokens, event) => {
297
+ const scope = event?.endpointClass ?? "cloud";
298
+ const resolution = resolveRates(event?.model ?? calcModel, scope, config);
299
+ if (scope === "local") {
300
+ const inTok = Number.isFinite(inputTokens) && inputTokens > 0 ? inputTokens : 0;
301
+ const outTok = Number.isFinite(outputTokens) && outputTokens > 0 ? outputTokens : 0;
302
+ return ((inTok / 1000) * resolution.rates.inputPer1k +
303
+ (outTok / 1000) * resolution.rates.outputPer1k);
304
+ }
305
+ return costFromRates(resolution.rates, inputTokens, outputTokens) / USERTOKENS_PER_DOLLAR;
306
+ },
307
+ });
203
308
  // 7. Two-phase intercept
204
- async function interceptCall(originalFn, thisArg, args) {
309
+ async function interceptCall(originalFn, thisArg, args,
310
+ // A1: which provider surface this call arrived through. "create" is the
311
+ // default request/response (and create-stream) path. "stream-helper" is the
312
+ // Anthropic messages.stream / beta.messages.stream convenience helper, whose
313
+ // forwarded call returns a self-driving MessageStream we settle via
314
+ // NON-CONSUMING emitter events instead of routing through
315
+ // createGovernedStream (A1) — consuming its single-owner async iterator would
316
+ // steal it from the caller. "openai-responses" (Task 3, A6) is the OpenAI
317
+ // Responses API (create, stream and non-stream): it shares the create request/
318
+ // response lifecycle and the generic createGovernedStream streaming path (A7),
319
+ // but MUST NOT receive the chat.completions-only stream_options.include_usage
320
+ // injection (A6) — the surface flag suppresses it below.
321
+ surfaceKind = "create") {
205
322
  if (destroyed) {
206
323
  throw new Error("TrustedClient has been destroyed");
207
324
  }
@@ -224,11 +341,22 @@ export async function trust(client, opts) {
224
341
  // a. Circuit breaker check
225
342
  const cb = breaker.get(kind);
226
343
  cb.allowRequest();
227
- // b. Estimate cost (before policy, so cost fields are available in context)
344
+ // b. Estimate cost (before policy, so cost fields are available in context).
345
+ // M2: rates resolve within the endpoint scope CAPTURED AT AUTHORIZE (A3) —
346
+ // this one resolution prices the hold, both settlement paths, and the
347
+ // receipt's meter provenance. unknownModelPolicy is enforced here, at
348
+ // authorize time, before any PENDING hold (A5).
228
349
  const transferId = trustId("tx");
229
350
  const estimatedInputTokens = estimateInputTokens(promptParts);
230
- const maxOutputTokens = params.max_tokens ?? 4096;
231
- const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
351
+ // F1: size the output estimate to the caller's ACTUAL cap. Responses callers
352
+ // set `max_output_tokens`; chat.completions set `max_tokens`. Read the
353
+ // Responses field first, then chat, then the 4096 default — each only when it
354
+ // is a finite positive number (a 0/negative/NaN cap never shrinks the hold).
355
+ const finitePositiveCap = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
356
+ const maxOutputTokens = finitePositiveCap(params.max_output_tokens) ?? finitePositiveCap(params.max_tokens) ?? 4096;
357
+ const rateResolution = resolveRates(model, endpoint.class, config);
358
+ enforceUnknownModelPolicy(model, rateResolution, config);
359
+ const estimatedCost = costFromRates(rateResolution.rates, estimatedInputTokens, maxOutputTokens);
232
360
  // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
233
361
  // This prevents concurrent calls from both passing the budget check
234
362
  // and overshooting the budget.
@@ -352,187 +480,555 @@ export async function trust(client, opts) {
352
480
  // AUD-453: Release lock after budget check + hold are complete
353
481
  releaseBudgetLock();
354
482
  }
483
+ // e0. M2 usage injection (A4): for local openai streams, opt in to the
484
+ // server's final usage chunk (Ollama emits /v1 streaming usage ONLY when
485
+ // stream_options.include_usage is set). The merge preserves the caller's
486
+ // other stream_options fields and respects an explicit include_usage.
487
+ // The resulting usage chunk is FORWARDED to the consumer unmodified
488
+ // (transparent middleware). Task 3/A6: the OpenAI Responses API is EXCLUDED
489
+ // — stream_options.include_usage is chat.completions-only; Responses carries
490
+ // usage automatically on the terminal `response.completed` event, and
491
+ // injecting the param would be an unknown field on the Responses request.
492
+ let preInjectionArgs = null;
493
+ if (kind === "openai" &&
494
+ surfaceKind !== "openai-responses" &&
495
+ endpoint.class === "local" &&
496
+ config.local.injectUsageOptions) {
497
+ const injected = withInjectedUsageOptions(forwardArgs);
498
+ if (injected != null) {
499
+ preInjectionArgs = forwardArgs;
500
+ forwardArgs = injected;
501
+ }
502
+ }
355
503
  // e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
356
504
  // redacted clone in redact mode, or the original args otherwise.
357
505
  let settled = true;
358
- try {
359
- const response = await originalFn.apply(thisArg, forwardArgs);
360
- // e2. Streaming detection: if response is an async iterable, wrap with
361
- // token accumulation. Settlement and audit happen when the stream ends.
362
- if (response != null &&
363
- typeof response === "object" &&
364
- Symbol.asyncIterator in response) {
365
- const stream = response;
366
- const governedStream = createGovernedStream(stream, kind, async (completion) => {
367
- // Determine cost: use provider usage if reported, else fall back to estimate
368
- let streamCost;
369
- let usageSource;
370
- if (completion.usageReported) {
371
- streamCost = estimateCost(model, completion.usage.inputTokens, completion.usage.outputTokens, customRates);
372
- usageSource = "provider";
373
- }
374
- else {
375
- streamCost = estimatedCost;
376
- usageSource = "estimated";
377
- }
378
- // Release in-flight hold and commit budget under mutex
379
- // AUD-468: holdActive guard prevents double-release if the
380
- // outer catch also fires (e.g. from a synchronous exception
381
- // during stream construction).
382
- if (holdActive) {
383
- holdActive = false;
384
- const releaseLock = await budgetMutex.acquire();
385
- inFlightHoldTotal -= estimatedCost;
386
- budgetSpent += streamCost;
387
- releaseLock();
388
- }
389
- else {
390
- // Hold already released — still need to record the spend.
391
- const releaseLock = await budgetMutex.acquire();
392
- budgetSpent += streamCost;
393
- releaseLock();
394
- }
395
- // AUD-457: Persist cumulative spend to disk
396
- await persistSpendLedger(vaultBase, budgetSpent);
397
- cb.recordSuccess();
398
- if (proxyConn != null && !isDryRun) {
399
- try {
400
- // AUD-460: Use the proxy's transferId for settlement
401
- await proxyConn.settle(proxyTransferId ?? transferId, streamCost);
402
- }
403
- catch (postErr) {
404
- settled = false;
405
- await audit
406
- .appendEvent({
407
- kind: "settlement_ambiguous",
408
- actor: "local",
409
- data: {
410
- model,
411
- cost: streamCost,
412
- transferId,
413
- error: postErr instanceof Error
414
- ? postErr.message.slice(0, 200)
415
- : String(postErr).slice(0, 200),
416
- },
417
- })
418
- .catch(() => {
419
- callAuditDegraded = true;
420
- });
421
- }
422
- }
423
- else if (engine != null && !isDryRun) {
424
- try {
425
- // Post the ACTUAL consumed cost (RECON #3).
426
- await engine.postPendingSpend(transferId, streamCost);
427
- }
428
- catch (postErr) {
429
- settled = false;
430
- await audit
431
- .appendEvent({
432
- kind: "settlement_ambiguous",
433
- actor: "local",
434
- data: {
435
- model,
436
- cost: streamCost,
437
- transferId,
438
- error: postErr instanceof Error
439
- ? postErr.message.slice(0, 200)
440
- : String(postErr).slice(0, 200),
441
- },
442
- })
443
- .catch(() => {
444
- callAuditDegraded = true;
445
- });
446
- }
447
- }
448
- const syntheticHash = createHash("sha256").update(transferId).digest("hex");
449
- let auditHash = syntheticHash;
450
- try {
451
- const auditEventData = {
506
+ // A2: one idempotent finalize gate per hold. The FIRST terminal signal for
507
+ // this authorization wins and claims the single ledger outcome (settle XOR
508
+ // void); every later signal returns false and MUST NOT mutate the ledger.
509
+ // Every terminal path routes through it: the non-stream settle + its
510
+ // outer-catch void, the stream onComplete/onError closures below, and
511
+ // (Tasks 2/3) any new surface's emitter/iterator/error/abort listeners. A
512
+ // duplicate terminal signal can neither double-settle nor double-void.
513
+ let finalizeState = "pending";
514
+ function finalizeOnce(outcome) {
515
+ if (finalizeState !== "pending")
516
+ return false;
517
+ finalizeState = outcome === "settle" ? "settled" : "voided";
518
+ return true;
519
+ }
520
+ // Estimate-priced stream receipt (settled:false). Serves as the initial
521
+ // handle returned to the caller AND the idempotent fallback a duplicate
522
+ // stream terminal returns after the hold was already resolved.
523
+ const buildEstimatedStreamReceipt = () => {
524
+ const streamEstimateHash = createHash("sha256").update(transferId).digest("hex");
525
+ return {
526
+ transferId,
527
+ cost: estimatedCost,
528
+ budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
529
+ auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : streamEstimateHash,
530
+ chainPath: join(VAULT_DIR, "audit"),
531
+ receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
532
+ settled: false, // AUD-454: not settled yet — stream hasn't been consumed
533
+ model,
534
+ provider: kind,
535
+ timestamp: new Date().toISOString(),
536
+ // M2: authorize-time scope, already fixed for the eventual settle (A3).
537
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
538
+ meter: {
539
+ costBasis: rateResolution.costBasis,
540
+ rateSource: rateResolution.rateSource,
541
+ },
542
+ ...(callAuditDegraded ? { auditDegraded: true } : {}),
543
+ // AUD-456: Flag proxy stub receipts
544
+ ...(proxyConn != null ? { proxyStub: true } : {}),
545
+ };
546
+ };
547
+ // Terminal stream settlement (A2 settle branch). Idempotent via
548
+ // finalizeOnce: budget commit + ledger POST + llm_call audit happen AT MOST
549
+ // ONCE per hold. Tasks 2/3 reuse this verbatim — the
550
+ // Anthropic MessageStream 'finalMessage' listener and any new stream surface
551
+ // call it with a StreamCompletion built from final usage. Does NOT touch
552
+ // inFlightStreamCount — the caller (createGovernedStream wiring, or a new
553
+ // surface's listener) owns that counter.
554
+ let streamSettledReceipt;
555
+ const finalizeStreamSettle = async (completion) => {
556
+ if (!finalizeOnce("settle")) {
557
+ // A duplicate terminal (e.g. 'finalMessage' after 'end', or a settle
558
+ // racing an abort-void): never mutate the ledger again.
559
+ return streamSettledReceipt ?? buildEstimatedStreamReceipt();
560
+ }
561
+ // Determine cost: use provider usage if reported, else fall back to
562
+ // estimate. A3: priced with the rate resolution captured at authorize;
563
+ // A11: costFromRates floors at 1 even for 0/0 usage.
564
+ let streamCost;
565
+ let usageSource;
566
+ if (completion.usageReported) {
567
+ streamCost = costFromRates(rateResolution.rates, completion.usage.inputTokens, completion.usage.outputTokens);
568
+ usageSource = "provider";
569
+ }
570
+ else {
571
+ streamCost = estimatedCost;
572
+ usageSource = "estimated";
573
+ }
574
+ // Release in-flight hold and commit budget under mutex.
575
+ // AUD-468: holdActive guard prevents double-release.
576
+ if (holdActive) {
577
+ holdActive = false;
578
+ const releaseLock = await budgetMutex.acquire();
579
+ inFlightHoldTotal -= estimatedCost;
580
+ budgetSpent += streamCost;
581
+ releaseLock();
582
+ }
583
+ else {
584
+ // Hold already released — still need to record the spend.
585
+ const releaseLock = await budgetMutex.acquire();
586
+ budgetSpent += streamCost;
587
+ releaseLock();
588
+ }
589
+ // AUD-457: Persist cumulative spend to disk
590
+ await persistSpendLedger(vaultBase, budgetSpent);
591
+ cb.recordSuccess();
592
+ if (proxyConn != null && !isDryRun) {
593
+ try {
594
+ // AUD-460: Use the proxy's transferId for settlement
595
+ await proxyConn.settle(proxyTransferId ?? transferId, streamCost);
596
+ }
597
+ catch (postErr) {
598
+ settled = false;
599
+ await audit
600
+ .appendEvent({
601
+ kind: "settlement_ambiguous",
602
+ actor: "local",
603
+ data: {
452
604
  model,
453
605
  cost: streamCost,
454
- settled,
455
606
  transferId,
456
- usageSource,
457
- chunksDelivered: completion.chunksDelivered,
458
- };
459
- if (config.pii === "warn" || config.pii === "redact") {
460
- const piiResult = redactPII(promptParts);
461
- if (piiResult.detection.found) {
462
- auditEventData.piiDetected = piiResult.detection.types;
463
- auditEventData.piiPaths = piiResult.detection.paths;
464
- }
465
- }
466
- const auditEvent = await audit.appendEvent({
467
- kind: "llm_call",
468
- actor: "local",
469
- data: auditEventData,
470
- });
471
- auditHash = auditEvent.hash;
472
- }
473
- catch {
607
+ error: postErr instanceof Error
608
+ ? postErr.message.slice(0, 200)
609
+ : String(postErr).slice(0, 200),
610
+ },
611
+ })
612
+ .catch(() => {
474
613
  callAuditDegraded = true;
614
+ });
615
+ }
616
+ }
617
+ else if (engine != null && !isDryRun) {
618
+ try {
619
+ // Post the ACTUAL consumed cost (RECON #3).
620
+ await engine.postPendingSpend(transferId, streamCost);
621
+ }
622
+ catch (postErr) {
623
+ settled = false;
624
+ await audit
625
+ .appendEvent({
626
+ kind: "settlement_ambiguous",
627
+ actor: "local",
628
+ data: {
629
+ model,
630
+ cost: streamCost,
631
+ transferId,
632
+ error: postErr instanceof Error
633
+ ? postErr.message.slice(0, 200)
634
+ : String(postErr).slice(0, 200),
635
+ },
636
+ })
637
+ .catch(() => {
638
+ callAuditDegraded = true;
639
+ });
640
+ }
641
+ }
642
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
643
+ let auditHash = syntheticHash;
644
+ try {
645
+ const auditEventData = {
646
+ model,
647
+ cost: streamCost,
648
+ settled,
649
+ transferId,
650
+ usageSource,
651
+ chunksDelivered: completion.chunksDelivered,
652
+ // M2: metering provenance mirrors the receipt (A3 authorize-time scope).
653
+ endpointClass: endpoint.class,
654
+ costBasis: rateResolution.costBasis,
655
+ rateSource: rateResolution.rateSource,
656
+ };
657
+ if (config.pii === "warn" || config.pii === "redact") {
658
+ const piiResult = redactPII(promptParts);
659
+ if (piiResult.detection.found) {
660
+ auditEventData.piiDetected = piiResult.detection.types;
661
+ auditEventData.piiPaths = piiResult.detection.paths;
475
662
  }
476
- // P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered,
477
- // so the strongest post-delivery signal is to REJECT `.receipt`.
478
- // Decrement the stream counter first so destroy() never blocks.
479
- if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
480
- inFlightStreamCount--;
481
- throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
482
- }
483
- const streamReceipt = {
484
- transferId,
485
- cost: streamCost,
486
- budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
487
- auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
488
- chainPath: join(VAULT_DIR, "audit"),
489
- receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
490
- settled,
491
- model,
492
- provider: kind,
493
- timestamp: new Date().toISOString(),
494
- usageSource,
495
- chunksDelivered: completion.chunksDelivered,
496
- ...(callAuditDegraded ? { auditDegraded: true } : {}),
497
- // AUD-456: Flag proxy stub receipts
498
- ...(proxyConn != null ? { proxyStub: true } : {}),
499
- };
500
- inFlightStreamCount--;
501
- return streamReceipt;
502
- }, async (error, partial) => {
503
- // Release in-flight hold under mutex
504
- // AUD-468: holdActive guard prevents double-release.
505
- await releaseInFlightHold();
506
- cb.recordFailure();
507
- // Best-effort audit of partial delivery
663
+ }
664
+ const auditEvent = await audit.appendEvent({
665
+ kind: "llm_call",
666
+ actor: "local",
667
+ data: auditEventData,
668
+ });
669
+ auditHash = auditEvent.hash;
670
+ }
671
+ catch {
672
+ callAuditDegraded = true;
673
+ }
674
+ // P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered, so the
675
+ // strongest post-delivery signal is to REJECT `.receipt`. The caller's
676
+ // finally decrements inFlightStreamCount so destroy() never blocks.
677
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
678
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
679
+ }
680
+ const streamReceipt = {
681
+ transferId,
682
+ cost: streamCost,
683
+ budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
684
+ auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
685
+ chainPath: join(VAULT_DIR, "audit"),
686
+ receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
687
+ settled,
688
+ model,
689
+ provider: kind,
690
+ timestamp: new Date().toISOString(),
691
+ usageSource,
692
+ chunksDelivered: completion.chunksDelivered,
693
+ // M2 provenance (A6: computeMs omitted — no compute-time source here).
694
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
695
+ meter: {
696
+ costBasis: rateResolution.costBasis,
697
+ rateSource: rateResolution.rateSource,
698
+ },
699
+ ...(callAuditDegraded ? { auditDegraded: true } : {}),
700
+ // AUD-456: Flag proxy stub receipts
701
+ ...(proxyConn != null ? { proxyStub: true } : {}),
702
+ };
703
+ streamSettledReceipt = streamReceipt;
704
+ return streamReceipt;
705
+ };
706
+ // Terminal stream void (A2 void branch). Idempotent via finalizeOnce:
707
+ // releases the hold and voids the ledger AT MOST ONCE. Tasks 2/3 reuse this
708
+ // for the Anthropic MessageStream 'error'/'abort' listeners and any new
709
+ // stream surface's error path. Does NOT touch inFlightStreamCount.
710
+ const finalizeStreamVoid = async (error, partial) => {
711
+ if (!finalizeOnce("void"))
712
+ return;
713
+ // Release in-flight hold under mutex (AUD-468: guarded).
714
+ await releaseInFlightHold();
715
+ cb.recordFailure();
716
+ // Best-effort audit of partial delivery
717
+ audit
718
+ .appendEvent({
719
+ kind: "stream_partial_delivery",
720
+ actor: "local",
721
+ data: {
722
+ transferId,
723
+ model,
724
+ chunksDelivered: partial.chunksDelivered,
725
+ partialInputTokens: partial.usage.inputTokens,
726
+ partialOutputTokens: partial.usage.outputTokens,
727
+ usageReported: partial.usageReported,
728
+ error: (() => {
729
+ const raw = error instanceof Error ? error.message : String(error);
730
+ return config.pii === "warn" || config.pii === "redact"
731
+ ? redactPII(raw).data.slice(0, 200)
732
+ : raw.slice(0, 200);
733
+ })(),
734
+ },
735
+ })
736
+ .catch(() => { });
737
+ if (proxyConn != null && !isDryRun) {
738
+ // AUD-460: Use the proxy's transferId for void
739
+ proxyConn.void(proxyTransferId ?? transferId).catch(() => { });
740
+ }
741
+ else if (engine != null && !isDryRun) {
742
+ engine.voidPendingSpend(transferId).catch(() => { });
743
+ }
744
+ };
745
+ // A1: settle an Anthropic MessageStream (messages.stream /
746
+ // beta.messages.stream) via its MULTICAST event emitter, never by consuming
747
+ // its single-owner async iterator (that belongs to the caller). The stream
748
+ // self-drives, so finalMessage/error/abort fire even if the caller never
749
+ // consumes (A4); the TigerBeetle PENDING 300s timeout (see destroy() and
750
+ // createPendingTransfer) is the last-resort backstop for a hold whose
751
+ // terminal event never arrives. finalizeOnce makes the six-mode consumption
752
+ // matrix safe: settle XOR void, exactly one ledger mutation.
753
+ const settleViaMessageStream = (streamObj) => {
754
+ // The initial handle: estimate-priced, settled:false — identical to the
755
+ // generic stream path. The caller reads the real receipt off `.receipt`.
756
+ const estimatedReceipt = buildEstimatedStreamReceipt();
757
+ const emitter = streamObj != null && typeof streamObj === "object"
758
+ ? streamObj
759
+ : null;
760
+ // This closure OWNS inFlightStreamCount for the stream's lifetime; the
761
+ // finalize* gates never touch it. Release it EXACTLY ONCE no matter how
762
+ // many terminal events fire (finalMessage-then-abort race, error-after-
763
+ // end, …) so destroy() never blocks and the counter never drifts.
764
+ inFlightStreamCount++;
765
+ let streamCountReleased = false;
766
+ const releaseStreamCount = () => {
767
+ if (streamCountReleased)
768
+ return;
769
+ streamCountReleased = true;
770
+ inFlightStreamCount--;
771
+ };
772
+ // `.receipt`: resolves with the settled receipt, rejects on a genuine
773
+ // stream error / failClosed. Built BEFORE the feature-detect branch so BOTH
774
+ // the emitter path and the non-emitter fallback expose a live `.receipt` the
775
+ // caller can await for the settled outcome. F8: governance never SILENTLY
776
+ // swallows a stream failure — a consumer who awaits `.receipt` always sees
777
+ // the error (the pre-attached catch below is a SEPARATE branch: promises are
778
+ // multicast, so it silences the unhandled-rejection ONLY for a fire-and-forget
779
+ // consumer that never awaits `.receipt`, never registers an error handler, and
780
+ // never calls done()/finalMessage() — that narrow gap is documented).
781
+ let receiptResolve;
782
+ let receiptReject;
783
+ const receiptPromise = new Promise((res, rej) => {
784
+ receiptResolve = res;
785
+ receiptReject = rej;
786
+ });
787
+ receiptPromise.catch(() => { });
788
+ // Expose `.receipt` on the returned stream object (best-effort — a frozen or
789
+ // non-object stream simply omits it; the terminal events still settle
790
+ // governance).
791
+ const attachReceipt = () => {
792
+ if (streamObj == null || typeof streamObj !== "object")
793
+ return;
794
+ try {
795
+ Object.assign(streamObj, { receipt: receiptPromise });
796
+ }
797
+ catch {
798
+ // non-extensible target — skip
799
+ }
800
+ };
801
+ // A3: a helper that connected is a billable SUCCESS. If the forwarded
802
+ // object is not an event-emitter MessageStream (feature-detect miss / an
803
+ // SDK too old to return one), we cannot tap emitter events — settle at
804
+ // ESTIMATE now so the hold never dangles, resolve `.receipt` from that
805
+ // background settle (mirroring the finalMessage path), and hand the object
806
+ // back raw.
807
+ if (emitter == null || typeof emitter.on !== "function") {
808
+ finalizeStreamSettle({
809
+ usage: { inputTokens: 0, outputTokens: 0 },
810
+ chunksDelivered: 0,
811
+ usageReported: false,
812
+ })
813
+ .then(receiptResolve, receiptReject)
814
+ .finally(releaseStreamCount);
815
+ attachReceipt();
816
+ return { response: streamObj, receipt: estimatedReceipt };
817
+ }
818
+ // Non-consuming chunk accounting: streamEvent is multicast, so tapping it
819
+ // does not steal the caller's iterator. Mirrors the wrapStream tap so
820
+ // partial-void audits and a usage-less finalMessage both carry real
821
+ // numbers, and (best-effort, A1) trips the shared anomaly detector.
822
+ let chunksDelivered = 0;
823
+ let accInput = 0;
824
+ let accOutput = 0;
825
+ let accUsageReported = false;
826
+ // R1: set TRUE before governance calls emitter.abort() on an anomaly trip,
827
+ // so the 'abort' handler can distinguish a governance cutoff (void + breaker
828
+ // failure) from a genuine consumer abort (F9 settle-partial).
829
+ let anomalyAbort = false;
830
+ const partial = () => ({
831
+ usage: { inputTokens: accInput, outputTokens: accOutput },
832
+ chunksDelivered,
833
+ usageReported: accUsageReported,
834
+ });
835
+ emitter.on("streamEvent", (event) => {
836
+ chunksDelivered++;
837
+ const tokens = extractAnthropicStreamUsage(event);
838
+ let deltaTokens = 0;
839
+ if (tokens.inputTokens > accInput) {
840
+ deltaTokens += tokens.inputTokens - accInput;
841
+ accInput = tokens.inputTokens;
842
+ accUsageReported = true;
843
+ }
844
+ if (tokens.outputTokens > accOutput) {
845
+ deltaTokens += tokens.outputTokens - accOutput;
846
+ accOutput = tokens.outputTokens;
847
+ accUsageReported = true;
848
+ }
849
+ if (!config.anomaly.enabled)
850
+ return;
851
+ anomalyDetector.observe({
852
+ kind: "chunk",
853
+ deltaTokens,
854
+ cumulativeInputTokens: accInput,
855
+ cumulativeOutputTokens: accOutput,
856
+ // M2: stamp the per-call scope so the SHARED detector prices this
857
+ // event with the same scoped rates as settlement.
858
+ model,
859
+ endpointClass: endpoint.class,
860
+ });
861
+ const verdict = anomalyDetector.check();
862
+ if (verdict.tripped) {
508
863
  audit
509
864
  .appendEvent({
510
- kind: "stream_partial_delivery",
865
+ kind: "anomaly_detected",
511
866
  actor: "local",
512
867
  data: {
513
- transferId,
868
+ anomalyKind: verdict.kind,
869
+ message: verdict.message,
870
+ metric: verdict.metric,
871
+ threshold: verdict.threshold,
514
872
  model,
515
- chunksDelivered: partial.chunksDelivered,
516
- partialInputTokens: partial.usage.inputTokens,
517
- partialOutputTokens: partial.usage.outputTokens,
518
- usageReported: partial.usageReported,
519
- error: (() => {
520
- const raw = error instanceof Error ? error.message : String(error);
521
- return config.pii === "warn" || config.pii === "redact"
522
- ? redactPII(raw).data.slice(0, 200)
523
- : raw.slice(0, 200);
524
- })(),
873
+ transferId,
874
+ provider: kind,
525
875
  },
526
876
  })
527
877
  .catch(() => { });
528
- if (proxyConn != null && !isDryRun) {
529
- // AUD-460: Use the proxy's transferId for void
530
- proxyConn.void(proxyTransferId ?? transferId).catch(() => { });
878
+ // A1 best-effort mid-stream cutoff (full parity deferred). R1: flag
879
+ // this as a GOVERNANCE abort BEFORE aborting so the 'abort' handler
880
+ // VOIDs + records a breaker failure — same ledger + breaker outcome as
881
+ // the generic createGovernedStream anomaly path (which throws
882
+ // AnomalyError → finalizeStreamVoid). finalizeOnce keeps it safe if a
883
+ // finalMessage is already in flight.
884
+ anomalyAbort = true;
885
+ if (typeof emitter.abort === "function")
886
+ emitter.abort();
887
+ }
888
+ });
889
+ emitter.on("finalMessage", (msg) => {
890
+ // A3: usage-extraction failure after a successful stream settles at
891
+ // ESTIMATE, never voids. F3: MERGE per-field — a finalMessage that
892
+ // carries only one field (or null for the other) keeps the counter
893
+ // accumulated from streamEvent for the MISSING field, rather than
894
+ // zeroing it. Reported when EITHER source carried a real number;
895
+ // otherwise settle on the estimate.
896
+ const finalUsage = readFinalMessageUsage(msg);
897
+ const usageReported = finalUsage.reported || accUsageReported;
898
+ const completion = usageReported
899
+ ? {
900
+ usage: {
901
+ inputTokens: finalUsage.inputTokens ?? accInput,
902
+ outputTokens: finalUsage.outputTokens ?? accOutput,
903
+ },
904
+ chunksDelivered,
905
+ usageReported: true,
531
906
  }
532
- else if (engine != null && !isDryRun) {
533
- engine.voidPendingSpend(transferId).catch(() => { });
907
+ : {
908
+ usage: { inputTokens: 0, outputTokens: 0 },
909
+ chunksDelivered,
910
+ usageReported: false,
911
+ };
912
+ finalizeStreamSettle(completion)
913
+ .then(receiptResolve, receiptReject)
914
+ .finally(releaseStreamCount);
915
+ });
916
+ const voidVia = (error) => {
917
+ // finalizeStreamVoid claims finalizeOnce("void") SYNCHRONOUSLY at its
918
+ // top, so finalizeState reflects the outcome the moment it returns.
919
+ finalizeStreamVoid(error, partial()).finally(releaseStreamCount);
920
+ // Only surface the void on `.receipt` when THIS terminal actually won
921
+ // the gate. A late abort after a settle is a ledger no-op (finalizeOnce
922
+ // already granted "settle") — it must not reject a receipt the settle
923
+ // path will resolve, even though that settle's async is still in flight.
924
+ if (finalizeState === "voided")
925
+ receiptReject(error);
926
+ };
927
+ // F9: a consumer-initiated abort surfaces as the SDK's 'abort' event
928
+ // (APIUserAbortError), distinct from the provider-failure 'error' event. It
929
+ // is an early EXIT, not a failure: settle at the PARTIAL accumulated usage
930
+ // (mirroring the generic governed-stream break-out-of-for-await path) instead
931
+ // of voiding, and never record a circuit-breaker failure — a caller who
932
+ // aborts N streams must not trip the breaker. finalizeStreamSettle records
933
+ // success. The `pending` guard makes a LATE abort after a real terminal a
934
+ // clean no-op: without it the no-op settle would resolve `.receipt` with a
935
+ // stale estimate, racing (and beating) the winning terminal's async settle.
936
+ const settlePartialVia = () => {
937
+ if (finalizeState !== "pending")
938
+ return;
939
+ finalizeStreamSettle(partial())
940
+ .then(receiptResolve, receiptReject)
941
+ .finally(releaseStreamCount);
942
+ };
943
+ emitter.on("error", (err) => voidVia(err ?? new Error("stream error")));
944
+ // R1: a GOVERNANCE anomaly cutoff (anomalyAbort) is a provider-side FAILURE —
945
+ // void the hold + record a breaker failure, matching the generic path. A
946
+ // genuine consumer abort is an early exit — settle the partial usage (F9).
947
+ emitter.on("abort", (err) => {
948
+ if (anomalyAbort) {
949
+ voidVia(err ?? new Error("anomaly cutoff"));
950
+ }
951
+ else {
952
+ settlePartialVia();
953
+ }
954
+ });
955
+ // F6: the real MessageStream emits NO 'finalMessage' on a clean SSE close
956
+ // that lacks a message_stop (receivedMessages stays empty → _emitFinal emits
957
+ // nothing) — only 'end' fires. Without an 'end' catch-all the PENDING hold
958
+ // would dangle until the TigerBeetle 300s timeout. When 'end' is the SOLE
959
+ // terminal (gate still pending) settle at ESTIMATE so the hold releases; on
960
+ // every other terminal ('end' always fires last, after the winning settle/
961
+ // void) the guard makes this a no-op that must NOT touch `.receipt`.
962
+ emitter.on("end", () => {
963
+ if (finalizeState !== "pending")
964
+ return;
965
+ finalizeStreamSettle({
966
+ usage: { inputTokens: 0, outputTokens: 0 },
967
+ chunksDelivered,
968
+ usageReported: false,
969
+ })
970
+ .then(receiptResolve, receiptReject)
971
+ .finally(releaseStreamCount);
972
+ });
973
+ attachReceipt();
974
+ return { response: streamObj, receipt: estimatedReceipt };
975
+ };
976
+ try {
977
+ let response;
978
+ try {
979
+ response = await originalFn.apply(thisArg, forwardArgs);
980
+ }
981
+ catch (callErr) {
982
+ // A4: some OpenAI-compat servers reject unknown stream_options. When
983
+ // WE injected them AND the error plausibly says so (message/HTTP-status
984
+ // heuristic), retry ONCE without the injection; the retried stream
985
+ // simply settles on the estimate if no usage tail arrives (A7). Any
986
+ // other error — transient network failures, unrelated 5xx — rethrows
987
+ // the ORIGINAL immediately rather than duplicating compute and masking
988
+ // the root cause.
989
+ if (preInjectionArgs == null || !looksLikeStreamOptionsRejection(callErr)) {
990
+ throw callErr;
991
+ }
992
+ response = await originalFn.apply(thisArg, preInjectionArgs);
993
+ }
994
+ // A1: Anthropic messages.stream / beta.messages.stream helper. The
995
+ // forwarded call returned a MessageStream (event-emitter + AsyncIterable)
996
+ // owned by the CALLER. Settle via its multicast emitter — this branch
997
+ // runs BEFORE the generic Symbol.asyncIterator path because a
998
+ // MessageStream matches that check too but must NEVER be routed through
999
+ // createGovernedStream (that would steal the caller's single async
1000
+ // iterator, A1). A synchronous throw from stream() (bad params, connect
1001
+ // failure before the object is returned) never reaches here — it lands in
1002
+ // the outer catch, which voids the still-PENDING hold once.
1003
+ if (surfaceKind === "stream-helper") {
1004
+ return settleViaMessageStream(response);
1005
+ }
1006
+ // e2. Streaming detection: if response is an async iterable, wrap with
1007
+ // token accumulation. Settlement and audit happen when the stream ends.
1008
+ if (response != null &&
1009
+ typeof response === "object" &&
1010
+ Symbol.asyncIterator in response) {
1011
+ const stream = response;
1012
+ const governedStream = createGovernedStream(stream, kind, async (completion) => {
1013
+ // A2: route the createGovernedStream terminal through the shared
1014
+ // idempotent settle gate. The finally decrements the stream counter on
1015
+ // BOTH the receipt return and the failClosed throw.
1016
+ try {
1017
+ return await finalizeStreamSettle(completion);
1018
+ }
1019
+ finally {
1020
+ inFlightStreamCount--;
1021
+ }
1022
+ }, async (error, partial) => {
1023
+ // A2: route the createGovernedStream error terminal through the shared
1024
+ // idempotent void gate. The finally decrements the stream counter exactly
1025
+ // as the success path does.
1026
+ try {
1027
+ await finalizeStreamVoid(error, partial);
1028
+ }
1029
+ finally {
1030
+ inFlightStreamCount--;
534
1031
  }
535
- inFlightStreamCount--;
536
1032
  },
537
1033
  // onChunk: streaming anomaly detector hook. Observe each chunk;
538
1034
  // if a signal trips, throw AnomalyError to abort the stream.
@@ -546,6 +1042,10 @@ export async function trust(client, opts) {
546
1042
  deltaTokens: obs.deltaTokens,
547
1043
  cumulativeInputTokens: obs.cumulativeInputTokens,
548
1044
  cumulativeOutputTokens: obs.cumulativeOutputTokens,
1045
+ // M2: stamp the per-call scope so the SHARED detector prices
1046
+ // this event with the same scoped rates as settlement.
1047
+ model,
1048
+ endpointClass: endpoint.class,
549
1049
  });
550
1050
  const verdict = anomalyDetector.check();
551
1051
  if (verdict.tripped) {
@@ -570,29 +1070,17 @@ export async function trust(client, opts) {
570
1070
  }
571
1071
  });
572
1072
  // AUD-454: For streaming responses, settlement has NOT happened yet.
573
- // Set settled: false the real settlement status will be on
574
- // governedStream.receipt after the stream is fully consumed.
575
- const streamEstimateHash = createHash("sha256").update(transferId).digest("hex");
576
- const estimatedReceipt = {
577
- transferId,
578
- cost: estimatedCost,
579
- budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
580
- auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : streamEstimateHash,
581
- chainPath: join(VAULT_DIR, "audit"),
582
- receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
583
- settled: false, // AUD-454: not settled yet — stream hasn't been consumed
584
- model,
585
- provider: kind,
586
- timestamp: new Date().toISOString(),
587
- ...(callAuditDegraded ? { auditDegraded: true } : {}),
588
- // AUD-456: Flag proxy stub receipts
589
- ...(proxyConn != null ? { proxyStub: true } : {}),
590
- };
1073
+ // The initial handle carries settled:false at the estimate the real
1074
+ // settlement status lands on governedStream.receipt after consumption.
1075
+ const estimatedReceipt = buildEstimatedStreamReceipt();
591
1076
  inFlightStreamCount++;
592
1077
  return { response: governedStream, receipt: estimatedReceipt };
593
1078
  }
594
- // f. Compute actual cost from response usage
1079
+ // f. Compute actual cost from response usage. A3: priced with the rate
1080
+ // resolution captured at authorize. costFromRates clamps NaN/negative
1081
+ // counts (A7) and floors at 1 even for 0/0 provider usage (A11).
595
1082
  let actualCost = estimatedCost;
1083
+ let usageSource = "estimated";
596
1084
  if (response != null && typeof response === "object" && "usage" in response) {
597
1085
  const usage = response.usage;
598
1086
  if (usage != null) {
@@ -602,7 +1090,8 @@ export async function trust(client, opts) {
602
1090
  const outputTokens = usage.output_tokens ??
603
1091
  usage.completion_tokens ??
604
1092
  0;
605
- actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
1093
+ actualCost = costFromRates(rateResolution.rates, inputTokens, outputTokens);
1094
+ usageSource = "provider";
606
1095
  }
607
1096
  }
608
1097
  // h. Audit the llm_call FIRST (P3-AUDIT-FAILCLOSED). The settlement-
@@ -619,6 +1108,11 @@ export async function trust(client, opts) {
619
1108
  cost: actualCost,
620
1109
  settled: true,
621
1110
  transferId,
1111
+ usageSource,
1112
+ // M2: metering provenance mirrors the receipt (A3 authorize-time scope).
1113
+ endpointClass: endpoint.class,
1114
+ costBasis: rateResolution.costBasis,
1115
+ rateSource: rateResolution.rateSource,
622
1116
  };
623
1117
  if (config.pii === "warn" || config.pii === "redact") {
624
1118
  const piiResult = redactPII(promptParts);
@@ -647,6 +1141,11 @@ export async function trust(client, opts) {
647
1141
  if (config.audit.failClosed && llmAuditFailed) {
648
1142
  throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
649
1143
  }
1144
+ // A2: claim the single settle outcome for this hold BEFORE money moves.
1145
+ // Always granted here (no other terminal path runs for a non-stream call);
1146
+ // the claim exists so a post-commit throw routes to the outer catch WITHOUT
1147
+ // double-voiding an already-settled hold.
1148
+ finalizeOnce("settle");
650
1149
  // Release in-flight hold and commit budget under mutex — money moves only
651
1150
  // AFTER the spend is audited. AUD-468: mark hold released before the commit
652
1151
  // so a later throw cannot double-decrement inFlightHoldTotal via the catch.
@@ -750,6 +1249,13 @@ export async function trust(client, opts) {
750
1249
  model,
751
1250
  provider: kind,
752
1251
  timestamp: new Date().toISOString(),
1252
+ usageSource,
1253
+ // M2 provenance (A6: computeMs omitted — no compute-time source here).
1254
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
1255
+ meter: {
1256
+ costBasis: rateResolution.costBasis,
1257
+ rateSource: rateResolution.rateSource,
1258
+ },
753
1259
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
754
1260
  // AUD-456: Flag proxy stub receipts
755
1261
  ...(proxyConn != null ? { proxyStub: true } : {}),
@@ -764,23 +1270,32 @@ export async function trust(client, opts) {
764
1270
  await releaseInFlightHold();
765
1271
  // j. Circuit breaker: record failure
766
1272
  cb.recordFailure();
767
- // j2. Failure mode 15.2: LLM fails VOID the pending hold
768
- if (engine != null && !isDryRun) {
769
- try {
770
- await engine.voidPendingSpend(transferId);
771
- }
772
- catch {
773
- // Best-effort void log and continue
774
- }
775
- }
776
- // j3. Proxy void
777
- if (proxyConn != null && !isDryRun) {
778
- try {
779
- // AUD-460: Use the proxy's transferId for void
780
- await proxyConn.void(proxyTransferId ?? transferId);
1273
+ // j2/j3. VOID the pending holdbut ONLY if this hold has not already
1274
+ // claimed a settle outcome (A2). A pre-commit throw (provider/transport
1275
+ // error, PII/policy deny, failClosed-before-commit) reaches here with the
1276
+ // hold still PENDING → finalizeOnce("void") is granted and the ledger is
1277
+ // voided. A post-commit throw (failClosed belt-and-suspenders) already
1278
+ // settled the spend → the claim is refused and no spurious void is issued
1279
+ // against an already-posted transfer.
1280
+ if (finalizeOnce("void")) {
1281
+ // Failure mode 15.2: LLM fails — VOID the pending hold
1282
+ if (engine != null && !isDryRun) {
1283
+ try {
1284
+ await engine.voidPendingSpend(transferId);
1285
+ }
1286
+ catch {
1287
+ // Best-effort void — log and continue
1288
+ }
781
1289
  }
782
- catch {
783
- // Best-effort void
1290
+ // Proxy void
1291
+ if (proxyConn != null && !isDryRun) {
1292
+ try {
1293
+ // AUD-460: Use the proxy's transferId for void
1294
+ await proxyConn.void(proxyTransferId ?? transferId);
1295
+ }
1296
+ catch {
1297
+ // Best-effort void
1298
+ }
784
1299
  }
785
1300
  }
786
1301
  // k. Audit the failure
@@ -1191,13 +1706,46 @@ export async function trust(client, opts) {
1191
1706
  * Without this, a Google `generateContent({ model, contents })` call has an empty
1192
1707
  * `messages` array, so every PII/injection scan sees nothing and PII egresses.
1193
1708
  */
1194
- function extractPromptParts(params, kind) {
1709
+ /**
1710
+ * Normalize the prompt-bearing payload across providers into a flat parts array
1711
+ * that PII/injection scanning, token estimation, redaction, and pattern hashing
1712
+ * all consume. Exported for the A8 byte-exact regression test (kept internal to
1713
+ * the package — not re-exported from index.ts).
1714
+ *
1715
+ * Branch order is load-bearing (A8): the OpenAI Responses branch is shape-
1716
+ * discriminated (`kind === "openai"` AND no `messages` key) and placed so it can
1717
+ * NEVER preempt the chat.completions / Anthropic `messages` branch — every
1718
+ * chat.completions or Anthropic call carries `messages` and skips it, so existing
1719
+ * extraction stays byte-unchanged.
1720
+ */
1721
+ export function extractPromptParts(params, kind) {
1195
1722
  if (kind === "google") {
1196
1723
  const contents = params.contents;
1197
1724
  if (Array.isArray(contents))
1198
1725
  return contents;
1199
1726
  return contents != null ? [contents] : [];
1200
1727
  }
1728
+ // A8: OpenAI Responses API uses `input`/`instructions`, not `messages`. Wrap the
1729
+ // input so BOTH the token estimator (which walks a message's `content`) and the
1730
+ // recursive PII/injection scanners (which deep-walk every string) cover it,
1731
+ // whether `input` is a plain string, a message array, or a content-part array.
1732
+ if (kind === "openai" && params.messages === undefined) {
1733
+ const parts = [];
1734
+ const input = params.input;
1735
+ if (typeof input === "string") {
1736
+ parts.push({ role: "user", content: input });
1737
+ }
1738
+ else if (Array.isArray(input)) {
1739
+ parts.push({ role: "user", content: input });
1740
+ }
1741
+ else if (input != null && typeof input === "object") {
1742
+ parts.push({ role: "user", content: [input] });
1743
+ }
1744
+ if (typeof params.instructions === "string") {
1745
+ parts.push({ role: "system", content: params.instructions });
1746
+ }
1747
+ return parts;
1748
+ }
1201
1749
  const messages = params.messages;
1202
1750
  const parts = Array.isArray(messages) ? [...messages] : [];
1203
1751
  if (typeof params.system === "string") {
@@ -1205,14 +1753,68 @@ function extractPromptParts(params, kind) {
1205
1753
  }
1206
1754
  return parts;
1207
1755
  }
1756
+ // ── Anthropic MessageStream helpers (Task 2, A1/A3) ──
1757
+ /**
1758
+ * A1/A3: read provider usage off an Anthropic final Message (the `finalMessage`
1759
+ * event payload). Each field is returned PER-FIELD: a finite, non-negative count
1760
+ * when present, else `undefined` (so the caller can fall back to the counter it
1761
+ * accumulated from streamEvent for THAT field, rather than zeroing it — F3).
1762
+ * `reported` is true when at least one field carried a real number; when false the
1763
+ * caller settles at ESTIMATE rather than voiding a billable success (A3).
1764
+ */
1765
+ function readFinalMessageUsage(msg) {
1766
+ if (msg != null && typeof msg === "object") {
1767
+ const usage = msg.usage;
1768
+ if (usage != null && typeof usage === "object") {
1769
+ const u = usage;
1770
+ const inTok = typeof u.input_tokens === "number" && Number.isFinite(u.input_tokens) && u.input_tokens >= 0
1771
+ ? u.input_tokens
1772
+ : undefined;
1773
+ const outTok = typeof u.output_tokens === "number" &&
1774
+ Number.isFinite(u.output_tokens) &&
1775
+ u.output_tokens >= 0
1776
+ ? u.output_tokens
1777
+ : undefined;
1778
+ if (inTok !== undefined || outTok !== undefined) {
1779
+ return { inputTokens: inTok, outputTokens: outTok, reported: true };
1780
+ }
1781
+ }
1782
+ }
1783
+ return { inputTokens: undefined, outputTokens: undefined, reported: false };
1784
+ }
1785
+ /**
1786
+ * A1: incremental token extraction from a raw Anthropic stream event, for the
1787
+ * non-consuming messages.stream tap. Kept local to govern.ts (Task 2 does not
1788
+ * touch streaming.ts); mirrors streaming.ts extractAnthropicTokens — message_start
1789
+ * carries input_tokens, message_delta carries cumulative output_tokens.
1790
+ */
1791
+ function extractAnthropicStreamUsage(event) {
1792
+ if (event == null || typeof event !== "object")
1793
+ return { inputTokens: 0, outputTokens: 0 };
1794
+ const c = event;
1795
+ if (c.type === "message_start" && c.message != null && typeof c.message === "object") {
1796
+ const msg = c.message;
1797
+ if (msg.usage != null && typeof msg.usage === "object") {
1798
+ const usage = msg.usage;
1799
+ const inTok = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
1800
+ return { inputTokens: inTok > 0 ? inTok : 0, outputTokens: 0 };
1801
+ }
1802
+ }
1803
+ if (c.type === "message_delta" && c.usage != null && typeof c.usage === "object") {
1804
+ const usage = c.usage;
1805
+ const outTok = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
1806
+ return { inputTokens: 0, outputTokens: outTok > 0 ? outTok : 0 };
1807
+ }
1808
+ return { inputTokens: 0, outputTokens: 0 };
1809
+ }
1208
1810
  // ── TigerBeetle engine factory ──
1209
1811
  /** TigerBeetle codes that mean "this debit would exceed the account's credits". */
1210
1812
  function isTBInsufficientBalance(err) {
1211
1813
  if (!(err instanceof TBTransferError))
1212
1814
  return false;
1213
- return (err.code === CreateTransferError.exceeds_credits ||
1214
- err.code === CreateTransferError.overflows_debits ||
1215
- err.code === CreateTransferError.overflows_debits_pending);
1815
+ return (err.code === CreateTransferStatus.exceeds_credits ||
1816
+ err.code === CreateTransferStatus.overflows_debits ||
1817
+ err.code === CreateTransferStatus.overflows_debits_pending);
1216
1818
  }
1217
1819
  /**
1218
1820
  * Create a balance-enforcing TrustEngine backed by a real TigerBeetle client.
@@ -1307,24 +1909,173 @@ async function createTBEngine(config, seedBudget) {
1307
1909
  },
1308
1910
  };
1309
1911
  }
1310
- function buildAnthropicProxy(client, intercept, destroy, governAction) {
1311
- const original = client;
1312
- const messages = original.messages;
1912
+ /**
1913
+ * F4: mirror the Anthropic SDK's `parseMessage` / `parseBetaMessage` transform for a
1914
+ * governed `messages.parse()` / `beta.messages.parse()`. The SDK's `parse()` is
1915
+ * `this.create(params).then((m) => parseMessage(m, params))` — routed through our
1916
+ * proxy, `this.create` is the GOVERNED create, which returns a `{ response, receipt }`
1917
+ * wrapper the SDK parser crashes on (`wrapper.content` is undefined) AFTER the spend
1918
+ * has already settled. So the proxy runs the governed create itself and applies the
1919
+ * transform to the raw Message here.
1920
+ *
1921
+ * Faithful to the SDK for the modern `parsed_output` API: each `text` block gains a
1922
+ * NON-ENUMERABLE `parsed_output` (parsed via the caller-provided
1923
+ * `output_config.format.parse` when structured outputs are requested, else `JSON.parse`,
1924
+ * else null), and the message carries the first text block's `parsed_output` at the top
1925
+ * level. Reads the deprecated beta `output_format` too, so ONE transform covers the
1926
+ * stable and beta surfaces (stable params simply lack `output_format`). A structured
1927
+ * parse failure throws, matching the SDK. The deprecated per-block `parsed` getter is
1928
+ * intentionally not reproduced (superseded by `parsed_output`).
1929
+ */
1930
+ function applyAnthropicParseTransform(message, params) {
1931
+ if (message == null || typeof message !== "object")
1932
+ return message;
1933
+ const p = (params ?? {});
1934
+ const outputConfig = p.output_config;
1935
+ const format = (p.output_format ?? outputConfig?.format);
1936
+ const hasSchema = format != null && typeof format === "object" && format.type === "json_schema";
1937
+ const parseText = (text) => {
1938
+ if (!hasSchema)
1939
+ return null;
1940
+ try {
1941
+ return typeof format?.parse === "function"
1942
+ ? format.parse(text)
1943
+ : JSON.parse(text);
1944
+ }
1945
+ catch (err) {
1946
+ throw new Error(`Failed to parse structured output: ${err}`);
1947
+ }
1948
+ };
1949
+ const msg = message;
1950
+ let firstParsed = null;
1951
+ const content = Array.isArray(msg.content)
1952
+ ? msg.content.map((block) => {
1953
+ if (block != null &&
1954
+ typeof block === "object" &&
1955
+ block.type === "text") {
1956
+ const parsed = parseText(String(block.text ?? ""));
1957
+ if (firstParsed === null)
1958
+ firstParsed = parsed;
1959
+ const parsedBlock = { ...block };
1960
+ Object.defineProperty(parsedBlock, "parsed_output", {
1961
+ value: parsed,
1962
+ enumerable: false,
1963
+ });
1964
+ return parsedBlock;
1965
+ }
1966
+ return block;
1967
+ })
1968
+ : msg.content;
1969
+ return { ...msg, content, parsed_output: firstParsed };
1970
+ }
1971
+ /**
1972
+ * A1/A5: build a governed proxy over an Anthropic `messages` resource (stable or
1973
+ * beta — both share the create/stream shape). `create` routes through
1974
+ * interceptCall (surface "create"). `stream`, when present as a function (A5
1975
+ * feature-detect), routes through interceptCall (surface "stream-helper") bound to
1976
+ * the RAW `messages` target (A5) so the helper's internal
1977
+ * `messages.create({stream:true}).withResponse()` runs against the un-proxied
1978
+ * create and never re-enters the governed create trap. The governed `stream` is
1979
+ * async (it authorizes before forwarding), so callers `await` the handle, then use
1980
+ * `.on()` / `.finalMessage()` / `.abort()` / `.receipt`. `parse`, when present (A5
1981
+ * feature-detect), routes its create through governance and applies the SDK parse
1982
+ * transform (F4). Everything else (countTokens, batches, …) falls through to
1983
+ * Reflect.get untouched.
1984
+ */
1985
+ function buildAnthropicMessagesProxy(messages, intercept) {
1313
1986
  const originalCreate = messages.create;
1314
- // Proxy on the messages object: intercept `create`
1315
- const messagesProxy = new Proxy(messages, {
1987
+ // A5: feature-detect `stream` as a FUNCTION before wrapping it — an older SDK
1988
+ // without the helper leaves it absent and it stays a raw pass-through.
1989
+ const originalStream = typeof messages.stream === "function"
1990
+ ? messages.stream
1991
+ : undefined;
1992
+ // A5/F4: feature-detect `parse` the same way — absent on older SDKs.
1993
+ const hasParse = typeof messages.parse === "function";
1994
+ return new Proxy(messages, {
1316
1995
  get(target, prop, receiver) {
1317
1996
  if (prop === "create") {
1318
1997
  return (...args) => intercept(originalCreate, target, args);
1319
1998
  }
1999
+ if (prop === "stream" && originalStream !== undefined) {
2000
+ return async (...args) => {
2001
+ const { response } = await intercept(originalStream, target, args, "stream-helper");
2002
+ return response;
2003
+ };
2004
+ }
2005
+ if (prop === "parse" && hasParse) {
2006
+ // F4: run the governed create ONCE, then apply the SDK parse transform to
2007
+ // the settled Message and hand back the parsed Message (SDK-shaped) with
2008
+ // the settlement receipt attached.
2009
+ return async (...args) => {
2010
+ const { response, receipt } = await intercept(originalCreate, target, args);
2011
+ const parsed = applyAnthropicParseTransform(response, args[0]);
2012
+ if (parsed != null && typeof parsed === "object") {
2013
+ try {
2014
+ Object.defineProperty(parsed, "receipt", {
2015
+ value: receipt,
2016
+ enumerable: true,
2017
+ configurable: true,
2018
+ });
2019
+ }
2020
+ catch {
2021
+ // non-extensible parsed object — skip
2022
+ }
2023
+ }
2024
+ return parsed;
2025
+ };
2026
+ }
2027
+ return Reflect.get(target, prop, receiver);
2028
+ },
2029
+ });
2030
+ }
2031
+ /**
2032
+ * A5: build a governed proxy over `client.beta` when it exposes a create-shaped
2033
+ * `beta.messages`. Returns undefined on any miss (no beta, non-object beta, a beta
2034
+ * getter that throws, or a beta.messages without a create function) so the caller
2035
+ * falls back to raw Reflect.get and NOTHING throws at wrap time (peerDep-range
2036
+ * compatibility). beta.models / beta.files / beta.messages.batches stay documented
2037
+ * pass-throughs — create, stream, and parse on beta.messages are governed.
2038
+ */
2039
+ function buildAnthropicBetaProxy(original, intercept) {
2040
+ let beta;
2041
+ try {
2042
+ beta = original.beta;
2043
+ }
2044
+ catch {
2045
+ return undefined;
2046
+ }
2047
+ if (beta == null || typeof beta !== "object")
2048
+ return undefined;
2049
+ const betaObj = beta;
2050
+ const betaMessages = betaObj.messages;
2051
+ if (betaMessages == null ||
2052
+ typeof betaMessages !== "object" ||
2053
+ typeof betaMessages.create !== "function") {
2054
+ return undefined;
2055
+ }
2056
+ const betaMessagesProxy = buildAnthropicMessagesProxy(betaMessages, intercept);
2057
+ return new Proxy(betaObj, {
2058
+ get(target, prop, receiver) {
2059
+ if (prop === "messages")
2060
+ return betaMessagesProxy;
1320
2061
  return Reflect.get(target, prop, receiver);
1321
2062
  },
1322
2063
  });
1323
- // Proxy on the client: intercept `messages` to return our proxy, add `destroy`
2064
+ }
2065
+ function buildAnthropicProxy(client, intercept, destroy, governAction) {
2066
+ const original = client;
2067
+ const messages = original.messages;
2068
+ // Stable messages: governed `create` + (feature-detected) `stream`.
2069
+ const messagesProxy = buildAnthropicMessagesProxy(messages, intercept);
2070
+ // beta.messages.create/stream/parse — governed identically when present (A5); a
2071
+ // client without `beta` yields undefined and `beta` stays a raw pass-through.
2072
+ const betaProxy = buildAnthropicBetaProxy(original, intercept);
1324
2073
  const clientProxy = new Proxy(original, {
1325
2074
  get(target, prop, receiver) {
1326
2075
  if (prop === "messages")
1327
2076
  return messagesProxy;
2077
+ if (prop === "beta" && betaProxy !== undefined)
2078
+ return betaProxy;
1328
2079
  if (prop === "destroy")
1329
2080
  return destroy;
1330
2081
  if (prop === "governAction")
@@ -1334,6 +2085,42 @@ function buildAnthropicProxy(client, intercept, destroy, governAction) {
1334
2085
  });
1335
2086
  return clientProxy;
1336
2087
  }
2088
+ /**
2089
+ * Task 3 (A5): build a governed proxy over `client.responses` when the OpenAI SDK
2090
+ * exposes a create-shaped Responses API. Returns undefined on any miss (no
2091
+ * `responses`, non-object `responses`, a getter that throws, or a `responses`
2092
+ * without a `create` function) so the caller falls back to raw Reflect.get and
2093
+ * NOTHING throws at wrap time — the peer range (`openai >=4.70.0`) predates the
2094
+ * Responses API (~4.87), so older clients must keep working untouched.
2095
+ *
2096
+ * `responses.create` routes through interceptCall on the SEPARATE "openai-responses"
2097
+ * surface (A6: no stream_options.include_usage injection; A7: streaming still flows
2098
+ * through the generic createGovernedStream path). `this` binds to the RAW `responses`
2099
+ * object. Everything else (retrieve / cancel / delete / …) stays a pass-through.
2100
+ */
2101
+ function buildOpenAIResponsesProxy(original, intercept) {
2102
+ let responses;
2103
+ try {
2104
+ responses = original.responses;
2105
+ }
2106
+ catch {
2107
+ return undefined;
2108
+ }
2109
+ if (responses == null || typeof responses !== "object")
2110
+ return undefined;
2111
+ const responsesObj = responses;
2112
+ if (typeof responsesObj.create !== "function")
2113
+ return undefined;
2114
+ const originalCreate = responsesObj.create;
2115
+ return new Proxy(responsesObj, {
2116
+ get(target, prop, receiver) {
2117
+ if (prop === "create") {
2118
+ return (...args) => intercept(originalCreate, target, args, "openai-responses");
2119
+ }
2120
+ return Reflect.get(target, prop, receiver);
2121
+ },
2122
+ });
2123
+ }
1337
2124
  function buildOpenAIProxy(client, intercept, destroy, governAction) {
1338
2125
  const original = client;
1339
2126
  const chat = original.chat;
@@ -1354,10 +2141,15 @@ function buildOpenAIProxy(client, intercept, destroy, governAction) {
1354
2141
  return Reflect.get(target, prop, receiver);
1355
2142
  },
1356
2143
  });
2144
+ // responses.create — governed when present (A5); an older client without the
2145
+ // Responses API yields undefined and `responses` stays a raw pass-through.
2146
+ const responsesProxy = buildOpenAIResponsesProxy(original, intercept);
1357
2147
  const clientProxy = new Proxy(original, {
1358
2148
  get(target, prop, receiver) {
1359
2149
  if (prop === "chat")
1360
2150
  return chatProxy;
2151
+ if (prop === "responses" && responsesProxy !== undefined)
2152
+ return responsesProxy;
1361
2153
  if (prop === "destroy")
1362
2154
  return destroy;
1363
2155
  if (prop === "governAction")