usertrust 1.5.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.
- package/README.md +17 -1
- package/dist/anomaly/detector.js.map +1 -1
- package/dist/anomaly/index.d.ts +2 -2
- package/dist/anomaly/index.d.ts.map +1 -1
- package/dist/anomaly/index.js +1 -1
- package/dist/anomaly/index.js.map +1 -1
- package/dist/audit/anchor-doctor.d.ts +42 -0
- package/dist/audit/anchor-doctor.d.ts.map +1 -0
- package/dist/audit/anchor-doctor.js +285 -0
- package/dist/audit/anchor-doctor.js.map +1 -0
- package/dist/audit/anchor-verify.d.ts +229 -0
- package/dist/audit/anchor-verify.d.ts.map +1 -0
- package/dist/audit/anchor-verify.js +809 -0
- package/dist/audit/anchor-verify.js.map +1 -0
- package/dist/audit/anchor.d.ts +177 -0
- package/dist/audit/anchor.d.ts.map +1 -0
- package/dist/audit/anchor.js +922 -0
- package/dist/audit/anchor.js.map +1 -0
- package/dist/audit/canonical.js +1 -1
- package/dist/audit/canonical.js.map +1 -1
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +21 -7
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/read.d.ts +19 -0
- package/dist/audit/read.d.ts.map +1 -0
- package/dist/audit/read.js +94 -0
- package/dist/audit/read.js.map +1 -0
- package/dist/audit/rekor-verify.d.ts +116 -0
- package/dist/audit/rekor-verify.d.ts.map +1 -0
- package/dist/audit/rekor-verify.js +533 -0
- package/dist/audit/rekor-verify.js.map +1 -0
- package/dist/audit/rekor.d.ts +71 -0
- package/dist/audit/rekor.d.ts.map +1 -0
- package/dist/audit/rekor.js +556 -0
- package/dist/audit/rekor.js.map +1 -0
- package/dist/audit/rotation.js +1 -1
- package/dist/audit/rotation.js.map +1 -1
- package/dist/audit/sigv4.d.ts +44 -0
- package/dist/audit/sigv4.d.ts.map +1 -0
- package/dist/audit/sigv4.js +134 -0
- package/dist/audit/sigv4.js.map +1 -0
- package/dist/audit/verify.d.ts +78 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +195 -1
- package/dist/audit/verify.js.map +1 -1
- package/dist/cli/anchor.d.ts +3 -0
- package/dist/cli/anchor.d.ts.map +1 -0
- package/dist/cli/anchor.js +626 -0
- package/dist/cli/anchor.js.map +1 -0
- package/dist/cli/completions.js +1 -1
- package/dist/cli/export.d.ts +3 -0
- package/dist/cli/export.d.ts.map +1 -0
- package/dist/cli/export.js +58 -0
- package/dist/cli/export.js.map +1 -0
- package/dist/cli/health.js.map +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +4 -34
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/main.d.ts +2 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +20 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/pricing.js +1 -1
- package/dist/cli/pricing.js.map +1 -1
- package/dist/cli/ui.d.ts +3 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +35 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +342 -3
- package/dist/cli/verify.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/detect.d.ts +37 -15
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +17 -6
- package/dist/detect.js.map +1 -1
- package/dist/export/markdown.d.ts +12 -0
- package/dist/export/markdown.d.ts.map +1 -0
- package/dist/export/markdown.js +142 -0
- package/dist/export/markdown.js.map +1 -0
- package/dist/govern.d.ts +60 -3
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +842 -229
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +4 -4
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +33 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -25
- package/dist/index.js.map +1 -1
- package/dist/ledger/client.d.ts.map +1 -1
- package/dist/ledger/client.js +57 -46
- package/dist/ledger/client.js.map +1 -1
- package/dist/ledger/engine.d.ts.map +1 -1
- package/dist/ledger/engine.js +22 -13
- package/dist/ledger/engine.js.map +1 -1
- package/dist/shared/types.d.ts +114 -475
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +13 -13
- package/dist/shared/types.js.map +1 -1
- package/dist/snapshot/checkpoint.d.ts.map +1 -1
- package/dist/snapshot/checkpoint.js +20 -2
- package/dist/snapshot/checkpoint.js.map +1 -1
- package/dist/streaming.d.ts +9 -4
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +27 -0
- package/dist/streaming.js.map +1 -1
- package/dist/supply-chain/sign.d.ts.map +1 -1
- package/dist/supply-chain/sign.js +3 -0
- package/dist/supply-chain/sign.js.map +1 -1
- package/package.json +6 -4
package/dist/govern.js
CHANGED
|
@@ -30,7 +30,7 @@ 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 {
|
|
33
|
+
import { CreateTransferStatus } from "tigerbeetle-node";
|
|
34
34
|
import { createAuditWriter } from "./audit/chain.js";
|
|
35
35
|
import { writeReceipt } from "./audit/rotation.js";
|
|
36
36
|
import { classifyEndpoint, detectClientKind } from "./detect.js";
|
|
@@ -306,7 +306,19 @@ export async function trust(client, opts) {
|
|
|
306
306
|
},
|
|
307
307
|
});
|
|
308
308
|
// 7. Two-phase intercept
|
|
309
|
-
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") {
|
|
310
322
|
if (destroyed) {
|
|
311
323
|
throw new Error("TrustedClient has been destroyed");
|
|
312
324
|
}
|
|
@@ -336,7 +348,12 @@ export async function trust(client, opts) {
|
|
|
336
348
|
// authorize time, before any PENDING hold (A5).
|
|
337
349
|
const transferId = trustId("tx");
|
|
338
350
|
const estimatedInputTokens = estimateInputTokens(promptParts);
|
|
339
|
-
|
|
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;
|
|
340
357
|
const rateResolution = resolveRates(model, endpoint.class, config);
|
|
341
358
|
enforceUnknownModelPolicy(model, rateResolution, config);
|
|
342
359
|
const estimatedCost = costFromRates(rateResolution.rates, estimatedInputTokens, maxOutputTokens);
|
|
@@ -468,9 +485,15 @@ export async function trust(client, opts) {
|
|
|
468
485
|
// stream_options.include_usage is set). The merge preserves the caller's
|
|
469
486
|
// other stream_options fields and respects an explicit include_usage.
|
|
470
487
|
// The resulting usage chunk is FORWARDED to the consumer unmodified
|
|
471
|
-
// (transparent middleware).
|
|
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.
|
|
472
492
|
let preInjectionArgs = null;
|
|
473
|
-
if (kind === "openai" &&
|
|
493
|
+
if (kind === "openai" &&
|
|
494
|
+
surfaceKind !== "openai-responses" &&
|
|
495
|
+
endpoint.class === "local" &&
|
|
496
|
+
config.local.injectUsageOptions) {
|
|
474
497
|
const injected = withInjectedUsageOptions(forwardArgs);
|
|
475
498
|
if (injected != null) {
|
|
476
499
|
preInjectionArgs = forwardArgs;
|
|
@@ -480,6 +503,476 @@ export async function trust(client, opts) {
|
|
|
480
503
|
// e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
|
|
481
504
|
// redacted clone in redact mode, or the original args otherwise.
|
|
482
505
|
let settled = true;
|
|
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: {
|
|
604
|
+
model,
|
|
605
|
+
cost: streamCost,
|
|
606
|
+
transferId,
|
|
607
|
+
error: postErr instanceof Error
|
|
608
|
+
? postErr.message.slice(0, 200)
|
|
609
|
+
: String(postErr).slice(0, 200),
|
|
610
|
+
},
|
|
611
|
+
})
|
|
612
|
+
.catch(() => {
|
|
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;
|
|
662
|
+
}
|
|
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) {
|
|
863
|
+
audit
|
|
864
|
+
.appendEvent({
|
|
865
|
+
kind: "anomaly_detected",
|
|
866
|
+
actor: "local",
|
|
867
|
+
data: {
|
|
868
|
+
anomalyKind: verdict.kind,
|
|
869
|
+
message: verdict.message,
|
|
870
|
+
metric: verdict.metric,
|
|
871
|
+
threshold: verdict.threshold,
|
|
872
|
+
model,
|
|
873
|
+
transferId,
|
|
874
|
+
provider: kind,
|
|
875
|
+
},
|
|
876
|
+
})
|
|
877
|
+
.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,
|
|
906
|
+
}
|
|
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
|
+
};
|
|
483
976
|
try {
|
|
484
977
|
let response;
|
|
485
978
|
try {
|
|
@@ -498,6 +991,18 @@ export async function trust(client, opts) {
|
|
|
498
991
|
}
|
|
499
992
|
response = await originalFn.apply(thisArg, preInjectionArgs);
|
|
500
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
|
+
}
|
|
501
1006
|
// e2. Streaming detection: if response is an async iterable, wrap with
|
|
502
1007
|
// token accumulation. Settlement and audit happen when the stream ends.
|
|
503
1008
|
if (response != null &&
|
|
@@ -505,187 +1010,25 @@ export async function trust(client, opts) {
|
|
|
505
1010
|
Symbol.asyncIterator in response) {
|
|
506
1011
|
const stream = response;
|
|
507
1012
|
const governedStream = createGovernedStream(stream, kind, async (completion) => {
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
let streamCost;
|
|
512
|
-
let usageSource;
|
|
513
|
-
if (completion.usageReported) {
|
|
514
|
-
streamCost = costFromRates(rateResolution.rates, completion.usage.inputTokens, completion.usage.outputTokens);
|
|
515
|
-
usageSource = "provider";
|
|
516
|
-
}
|
|
517
|
-
else {
|
|
518
|
-
streamCost = estimatedCost;
|
|
519
|
-
usageSource = "estimated";
|
|
520
|
-
}
|
|
521
|
-
// Release in-flight hold and commit budget under mutex
|
|
522
|
-
// AUD-468: holdActive guard prevents double-release if the
|
|
523
|
-
// outer catch also fires (e.g. from a synchronous exception
|
|
524
|
-
// during stream construction).
|
|
525
|
-
if (holdActive) {
|
|
526
|
-
holdActive = false;
|
|
527
|
-
const releaseLock = await budgetMutex.acquire();
|
|
528
|
-
inFlightHoldTotal -= estimatedCost;
|
|
529
|
-
budgetSpent += streamCost;
|
|
530
|
-
releaseLock();
|
|
531
|
-
}
|
|
532
|
-
else {
|
|
533
|
-
// Hold already released — still need to record the spend.
|
|
534
|
-
const releaseLock = await budgetMutex.acquire();
|
|
535
|
-
budgetSpent += streamCost;
|
|
536
|
-
releaseLock();
|
|
537
|
-
}
|
|
538
|
-
// AUD-457: Persist cumulative spend to disk
|
|
539
|
-
await persistSpendLedger(vaultBase, budgetSpent);
|
|
540
|
-
cb.recordSuccess();
|
|
541
|
-
if (proxyConn != null && !isDryRun) {
|
|
542
|
-
try {
|
|
543
|
-
// AUD-460: Use the proxy's transferId for settlement
|
|
544
|
-
await proxyConn.settle(proxyTransferId ?? transferId, streamCost);
|
|
545
|
-
}
|
|
546
|
-
catch (postErr) {
|
|
547
|
-
settled = false;
|
|
548
|
-
await audit
|
|
549
|
-
.appendEvent({
|
|
550
|
-
kind: "settlement_ambiguous",
|
|
551
|
-
actor: "local",
|
|
552
|
-
data: {
|
|
553
|
-
model,
|
|
554
|
-
cost: streamCost,
|
|
555
|
-
transferId,
|
|
556
|
-
error: postErr instanceof Error
|
|
557
|
-
? postErr.message.slice(0, 200)
|
|
558
|
-
: String(postErr).slice(0, 200),
|
|
559
|
-
},
|
|
560
|
-
})
|
|
561
|
-
.catch(() => {
|
|
562
|
-
callAuditDegraded = true;
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
else if (engine != null && !isDryRun) {
|
|
567
|
-
try {
|
|
568
|
-
// Post the ACTUAL consumed cost (RECON #3).
|
|
569
|
-
await engine.postPendingSpend(transferId, streamCost);
|
|
570
|
-
}
|
|
571
|
-
catch (postErr) {
|
|
572
|
-
settled = false;
|
|
573
|
-
await audit
|
|
574
|
-
.appendEvent({
|
|
575
|
-
kind: "settlement_ambiguous",
|
|
576
|
-
actor: "local",
|
|
577
|
-
data: {
|
|
578
|
-
model,
|
|
579
|
-
cost: streamCost,
|
|
580
|
-
transferId,
|
|
581
|
-
error: postErr instanceof Error
|
|
582
|
-
? postErr.message.slice(0, 200)
|
|
583
|
-
: String(postErr).slice(0, 200),
|
|
584
|
-
},
|
|
585
|
-
})
|
|
586
|
-
.catch(() => {
|
|
587
|
-
callAuditDegraded = true;
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
592
|
-
let auditHash = syntheticHash;
|
|
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.
|
|
593
1016
|
try {
|
|
594
|
-
|
|
595
|
-
model,
|
|
596
|
-
cost: streamCost,
|
|
597
|
-
settled,
|
|
598
|
-
transferId,
|
|
599
|
-
usageSource,
|
|
600
|
-
chunksDelivered: completion.chunksDelivered,
|
|
601
|
-
// M2: metering provenance mirrors the receipt (A3 authorize-time scope).
|
|
602
|
-
endpointClass: endpoint.class,
|
|
603
|
-
costBasis: rateResolution.costBasis,
|
|
604
|
-
rateSource: rateResolution.rateSource,
|
|
605
|
-
};
|
|
606
|
-
if (config.pii === "warn" || config.pii === "redact") {
|
|
607
|
-
const piiResult = redactPII(promptParts);
|
|
608
|
-
if (piiResult.detection.found) {
|
|
609
|
-
auditEventData.piiDetected = piiResult.detection.types;
|
|
610
|
-
auditEventData.piiPaths = piiResult.detection.paths;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
const auditEvent = await audit.appendEvent({
|
|
614
|
-
kind: "llm_call",
|
|
615
|
-
actor: "local",
|
|
616
|
-
data: auditEventData,
|
|
617
|
-
});
|
|
618
|
-
auditHash = auditEvent.hash;
|
|
1017
|
+
return await finalizeStreamSettle(completion);
|
|
619
1018
|
}
|
|
620
|
-
|
|
621
|
-
callAuditDegraded = true;
|
|
622
|
-
}
|
|
623
|
-
// P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered,
|
|
624
|
-
// so the strongest post-delivery signal is to REJECT `.receipt`.
|
|
625
|
-
// Decrement the stream counter first so destroy() never blocks.
|
|
626
|
-
if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
|
|
1019
|
+
finally {
|
|
627
1020
|
inFlightStreamCount--;
|
|
628
|
-
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
629
1021
|
}
|
|
630
|
-
const streamReceipt = {
|
|
631
|
-
transferId,
|
|
632
|
-
cost: streamCost,
|
|
633
|
-
budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
634
|
-
auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
|
|
635
|
-
chainPath: join(VAULT_DIR, "audit"),
|
|
636
|
-
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
637
|
-
settled,
|
|
638
|
-
model,
|
|
639
|
-
provider: kind,
|
|
640
|
-
timestamp: new Date().toISOString(),
|
|
641
|
-
usageSource,
|
|
642
|
-
chunksDelivered: completion.chunksDelivered,
|
|
643
|
-
// M2 provenance (A6: computeMs omitted — no compute-time source here).
|
|
644
|
-
endpoint: { class: endpoint.class, runtime: endpoint.runtime },
|
|
645
|
-
meter: {
|
|
646
|
-
costBasis: rateResolution.costBasis,
|
|
647
|
-
rateSource: rateResolution.rateSource,
|
|
648
|
-
},
|
|
649
|
-
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
650
|
-
// AUD-456: Flag proxy stub receipts
|
|
651
|
-
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
652
|
-
};
|
|
653
|
-
inFlightStreamCount--;
|
|
654
|
-
return streamReceipt;
|
|
655
1022
|
}, async (error, partial) => {
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
audit
|
|
662
|
-
.appendEvent({
|
|
663
|
-
kind: "stream_partial_delivery",
|
|
664
|
-
actor: "local",
|
|
665
|
-
data: {
|
|
666
|
-
transferId,
|
|
667
|
-
model,
|
|
668
|
-
chunksDelivered: partial.chunksDelivered,
|
|
669
|
-
partialInputTokens: partial.usage.inputTokens,
|
|
670
|
-
partialOutputTokens: partial.usage.outputTokens,
|
|
671
|
-
usageReported: partial.usageReported,
|
|
672
|
-
error: (() => {
|
|
673
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
674
|
-
return config.pii === "warn" || config.pii === "redact"
|
|
675
|
-
? redactPII(raw).data.slice(0, 200)
|
|
676
|
-
: raw.slice(0, 200);
|
|
677
|
-
})(),
|
|
678
|
-
},
|
|
679
|
-
})
|
|
680
|
-
.catch(() => { });
|
|
681
|
-
if (proxyConn != null && !isDryRun) {
|
|
682
|
-
// AUD-460: Use the proxy's transferId for void
|
|
683
|
-
proxyConn.void(proxyTransferId ?? transferId).catch(() => { });
|
|
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);
|
|
684
1028
|
}
|
|
685
|
-
|
|
686
|
-
|
|
1029
|
+
finally {
|
|
1030
|
+
inFlightStreamCount--;
|
|
687
1031
|
}
|
|
688
|
-
inFlightStreamCount--;
|
|
689
1032
|
},
|
|
690
1033
|
// onChunk: streaming anomaly detector hook. Observe each chunk;
|
|
691
1034
|
// if a signal trips, throw AnomalyError to abort the stream.
|
|
@@ -727,30 +1070,9 @@ export async function trust(client, opts) {
|
|
|
727
1070
|
}
|
|
728
1071
|
});
|
|
729
1072
|
// AUD-454: For streaming responses, settlement has NOT happened yet.
|
|
730
|
-
//
|
|
731
|
-
// governedStream.receipt after
|
|
732
|
-
const
|
|
733
|
-
const estimatedReceipt = {
|
|
734
|
-
transferId,
|
|
735
|
-
cost: estimatedCost,
|
|
736
|
-
budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
737
|
-
auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : streamEstimateHash,
|
|
738
|
-
chainPath: join(VAULT_DIR, "audit"),
|
|
739
|
-
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
740
|
-
settled: false, // AUD-454: not settled yet — stream hasn't been consumed
|
|
741
|
-
model,
|
|
742
|
-
provider: kind,
|
|
743
|
-
timestamp: new Date().toISOString(),
|
|
744
|
-
// M2: authorize-time scope, already fixed for the eventual settle (A3).
|
|
745
|
-
endpoint: { class: endpoint.class, runtime: endpoint.runtime },
|
|
746
|
-
meter: {
|
|
747
|
-
costBasis: rateResolution.costBasis,
|
|
748
|
-
rateSource: rateResolution.rateSource,
|
|
749
|
-
},
|
|
750
|
-
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
751
|
-
// AUD-456: Flag proxy stub receipts
|
|
752
|
-
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
753
|
-
};
|
|
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();
|
|
754
1076
|
inFlightStreamCount++;
|
|
755
1077
|
return { response: governedStream, receipt: estimatedReceipt };
|
|
756
1078
|
}
|
|
@@ -819,6 +1141,11 @@ export async function trust(client, opts) {
|
|
|
819
1141
|
if (config.audit.failClosed && llmAuditFailed) {
|
|
820
1142
|
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
821
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");
|
|
822
1149
|
// Release in-flight hold and commit budget under mutex — money moves only
|
|
823
1150
|
// AFTER the spend is audited. AUD-468: mark hold released before the commit
|
|
824
1151
|
// so a later throw cannot double-decrement inFlightHoldTotal via the catch.
|
|
@@ -943,23 +1270,32 @@ export async function trust(client, opts) {
|
|
|
943
1270
|
await releaseInFlightHold();
|
|
944
1271
|
// j. Circuit breaker: record failure
|
|
945
1272
|
cb.recordFailure();
|
|
946
|
-
// j2.
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1273
|
+
// j2/j3. VOID the pending hold — but 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
|
+
}
|
|
960
1289
|
}
|
|
961
|
-
|
|
962
|
-
|
|
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
|
+
}
|
|
963
1299
|
}
|
|
964
1300
|
}
|
|
965
1301
|
// k. Audit the failure
|
|
@@ -1370,13 +1706,46 @@ export async function trust(client, opts) {
|
|
|
1370
1706
|
* Without this, a Google `generateContent({ model, contents })` call has an empty
|
|
1371
1707
|
* `messages` array, so every PII/injection scan sees nothing and PII egresses.
|
|
1372
1708
|
*/
|
|
1373
|
-
|
|
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) {
|
|
1374
1722
|
if (kind === "google") {
|
|
1375
1723
|
const contents = params.contents;
|
|
1376
1724
|
if (Array.isArray(contents))
|
|
1377
1725
|
return contents;
|
|
1378
1726
|
return contents != null ? [contents] : [];
|
|
1379
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
|
+
}
|
|
1380
1749
|
const messages = params.messages;
|
|
1381
1750
|
const parts = Array.isArray(messages) ? [...messages] : [];
|
|
1382
1751
|
if (typeof params.system === "string") {
|
|
@@ -1384,14 +1753,68 @@ function extractPromptParts(params, kind) {
|
|
|
1384
1753
|
}
|
|
1385
1754
|
return parts;
|
|
1386
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
|
+
}
|
|
1387
1810
|
// ── TigerBeetle engine factory ──
|
|
1388
1811
|
/** TigerBeetle codes that mean "this debit would exceed the account's credits". */
|
|
1389
1812
|
function isTBInsufficientBalance(err) {
|
|
1390
1813
|
if (!(err instanceof TBTransferError))
|
|
1391
1814
|
return false;
|
|
1392
|
-
return (err.code ===
|
|
1393
|
-
err.code ===
|
|
1394
|
-
err.code ===
|
|
1815
|
+
return (err.code === CreateTransferStatus.exceeds_credits ||
|
|
1816
|
+
err.code === CreateTransferStatus.overflows_debits ||
|
|
1817
|
+
err.code === CreateTransferStatus.overflows_debits_pending);
|
|
1395
1818
|
}
|
|
1396
1819
|
/**
|
|
1397
1820
|
* Create a balance-enforcing TrustEngine backed by a real TigerBeetle client.
|
|
@@ -1486,24 +1909,173 @@ async function createTBEngine(config, seedBudget) {
|
|
|
1486
1909
|
},
|
|
1487
1910
|
};
|
|
1488
1911
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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) {
|
|
1492
1986
|
const originalCreate = messages.create;
|
|
1493
|
-
//
|
|
1494
|
-
|
|
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, {
|
|
1495
1995
|
get(target, prop, receiver) {
|
|
1496
1996
|
if (prop === "create") {
|
|
1497
1997
|
return (...args) => intercept(originalCreate, target, args);
|
|
1498
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
|
+
}
|
|
1499
2027
|
return Reflect.get(target, prop, receiver);
|
|
1500
2028
|
},
|
|
1501
2029
|
});
|
|
1502
|
-
|
|
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;
|
|
2061
|
+
return Reflect.get(target, prop, receiver);
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
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);
|
|
1503
2073
|
const clientProxy = new Proxy(original, {
|
|
1504
2074
|
get(target, prop, receiver) {
|
|
1505
2075
|
if (prop === "messages")
|
|
1506
2076
|
return messagesProxy;
|
|
2077
|
+
if (prop === "beta" && betaProxy !== undefined)
|
|
2078
|
+
return betaProxy;
|
|
1507
2079
|
if (prop === "destroy")
|
|
1508
2080
|
return destroy;
|
|
1509
2081
|
if (prop === "governAction")
|
|
@@ -1513,6 +2085,42 @@ function buildAnthropicProxy(client, intercept, destroy, governAction) {
|
|
|
1513
2085
|
});
|
|
1514
2086
|
return clientProxy;
|
|
1515
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
|
+
}
|
|
1516
2124
|
function buildOpenAIProxy(client, intercept, destroy, governAction) {
|
|
1517
2125
|
const original = client;
|
|
1518
2126
|
const chat = original.chat;
|
|
@@ -1533,10 +2141,15 @@ function buildOpenAIProxy(client, intercept, destroy, governAction) {
|
|
|
1533
2141
|
return Reflect.get(target, prop, receiver);
|
|
1534
2142
|
},
|
|
1535
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);
|
|
1536
2147
|
const clientProxy = new Proxy(original, {
|
|
1537
2148
|
get(target, prop, receiver) {
|
|
1538
2149
|
if (prop === "chat")
|
|
1539
2150
|
return chatProxy;
|
|
2151
|
+
if (prop === "responses" && responsesProxy !== undefined)
|
|
2152
|
+
return responsesProxy;
|
|
1540
2153
|
if (prop === "destroy")
|
|
1541
2154
|
return destroy;
|
|
1542
2155
|
if (prop === "governAction")
|