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.
- package/README.md +17 -1
- package/dist/anomaly/detector.d.ts.map +1 -1
- package/dist/anomaly/detector.js +36 -11
- 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/anomaly/signals/spend-velocity.d.ts +39 -12
- package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -1
- package/dist/anomaly/signals/spend-velocity.js +66 -43
- package/dist/anomaly/signals/spend-velocity.js.map +1 -1
- package/dist/anomaly/signals/token-rate.d.ts +33 -13
- package/dist/anomaly/signals/token-rate.d.ts.map +1 -1
- package/dist/anomaly/signals/token-rate.js +124 -38
- package/dist/anomaly/signals/token-rate.js.map +1 -1
- package/dist/anomaly/types.d.ts +28 -6
- package/dist/anomaly/types.d.ts.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.d.ts.map +1 -1
- package/dist/cli/init.js +99 -2
- 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 +71 -16
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +141 -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 +67 -3
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +1011 -219
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts +41 -2
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +58 -7
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +33 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -23
- 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/ledger/pricing.d.ts +54 -0
- package/dist/ledger/pricing.d.ts.map +1 -1
- package/dist/ledger/pricing.js +128 -8
- package/dist/ledger/pricing.js.map +1 -1
- package/dist/shared/types.d.ts +154 -340
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +67 -17
- 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 +13 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +88 -30
- 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,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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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: "
|
|
865
|
+
kind: "anomaly_detected",
|
|
511
866
|
actor: "local",
|
|
512
867
|
data: {
|
|
513
|
-
|
|
868
|
+
anomalyKind: verdict.kind,
|
|
869
|
+
message: verdict.message,
|
|
870
|
+
metric: verdict.metric,
|
|
871
|
+
threshold: verdict.threshold,
|
|
514
872
|
model,
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
//
|
|
574
|
-
// governedStream.receipt after
|
|
575
|
-
const
|
|
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 =
|
|
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.
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
+
}
|
|
781
1289
|
}
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
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 ===
|
|
1214
|
-
err.code ===
|
|
1215
|
-
err.code ===
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
//
|
|
1315
|
-
|
|
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
|
-
|
|
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")
|