usertrust 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/anomaly/detector.d.ts.map +1 -1
  2. package/dist/anomaly/detector.js +36 -11
  3. package/dist/anomaly/detector.js.map +1 -1
  4. package/dist/anomaly/signals/spend-velocity.d.ts +39 -12
  5. package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -1
  6. package/dist/anomaly/signals/spend-velocity.js +66 -43
  7. package/dist/anomaly/signals/spend-velocity.js.map +1 -1
  8. package/dist/anomaly/signals/token-rate.d.ts +33 -13
  9. package/dist/anomaly/signals/token-rate.d.ts.map +1 -1
  10. package/dist/anomaly/signals/token-rate.js +124 -38
  11. package/dist/anomaly/signals/token-rate.js.map +1 -1
  12. package/dist/anomaly/types.d.ts +28 -6
  13. package/dist/anomaly/types.d.ts.map +1 -1
  14. package/dist/cli/init.d.ts.map +1 -1
  15. package/dist/cli/init.js +98 -1
  16. package/dist/cli/init.js.map +1 -1
  17. package/dist/cli/main.js +0 -0
  18. package/dist/detect.d.ts +34 -1
  19. package/dist/detect.d.ts.map +1 -1
  20. package/dist/detect.js +124 -0
  21. package/dist/detect.js.map +1 -1
  22. package/dist/govern.d.ts +8 -1
  23. package/dist/govern.d.ts.map +1 -1
  24. package/dist/govern.js +191 -12
  25. package/dist/govern.js.map +1 -1
  26. package/dist/headless.d.ts +41 -2
  27. package/dist/headless.d.ts.map +1 -1
  28. package/dist/headless.js +54 -3
  29. package/dist/headless.js.map +1 -1
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/ledger/pricing.d.ts +54 -0
  35. package/dist/ledger/pricing.d.ts.map +1 -1
  36. package/dist/ledger/pricing.js +128 -8
  37. package/dist/ledger/pricing.js.map +1 -1
  38. package/dist/shared/types.d.ts +175 -0
  39. package/dist/shared/types.d.ts.map +1 -1
  40. package/dist/shared/types.js +55 -5
  41. package/dist/shared/types.js.map +1 -1
  42. package/dist/streaming.d.ts +8 -3
  43. package/dist/streaming.d.ts.map +1 -1
  44. package/dist/streaming.js +61 -30
  45. package/dist/streaming.js.map +1 -1
  46. package/package.json +1 -1
package/dist/detect.js CHANGED
@@ -47,4 +47,128 @@ export function detectClientKind(client) {
47
47
  }
48
48
  throw new Error("Unsupported LLM client: could not detect Anthropic, OpenAI, or Google SDK");
49
49
  }
50
+ // ── Endpoint classification (M2 local-model governance) ──
51
+ // Compared against normalizeHostname(url.hostname) — the WHATWG-serialized,
52
+ // bracket-stripped form. `::ffff:7f00:1` is how Node's URL serializes the
53
+ // IPv4-mapped IPv6 loopback `::ffff:127.0.0.1` (the last 32 bits render as hex,
54
+ // never dotted-quad), so the map entry MUST be the serialized form to match.
55
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "::ffff:7f00:1"]);
56
+ /** Best-effort runtime hint by conventional default port (metadata only). */
57
+ const RUNTIME_BY_PORT = {
58
+ "11434": "ollama",
59
+ "1234": "lmstudio",
60
+ "8000": "vllm",
61
+ };
62
+ const CLOUD_DEFAULT = { class: "cloud", runtime: "unknown" };
63
+ function parseUrl(value) {
64
+ try {
65
+ return new URL(value);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * Normalize a URL hostname for comparison: lowercase, IPv6 brackets stripped
73
+ * (Node's WHATWG URL keeps brackets in `hostname` for IPv6 literals).
74
+ */
75
+ function normalizeHostname(hostname) {
76
+ const lower = hostname.toLowerCase();
77
+ if (lower.startsWith("[") && lower.endsWith("]"))
78
+ return lower.slice(1, -1);
79
+ return lower;
80
+ }
81
+ /**
82
+ * Match a parsed baseURL against one config.endpoints[].match entry (A1).
83
+ * Exactly three entry forms — never raw string prefixing:
84
+ * (i) scheme entry ("http://gpu-box:8000") → parsed; match iff origins are equal
85
+ * (path/query/trailing slash ignored; kills "http://gpu-box:8000.evil.com"
86
+ * and "http://gpu-box:8000@evil.com" bypass shapes);
87
+ * (ii) leading-star entry ("*.gpu.internal") → hostname SUFFIX match: matches
88
+ * "a.gpu.internal" and "x.y.gpu.internal", NOT "gpu.internal" itself;
89
+ * (iii) bare hostname → case-insensitive hostname equality (any port).
90
+ * Unparseable scheme entries never match (and never throw).
91
+ */
92
+ function matchesEndpointEntry(entry, url) {
93
+ if (/^https?:\/\//i.test(entry)) {
94
+ const entryUrl = parseUrl(entry);
95
+ return entryUrl !== null && entryUrl.origin === url.origin;
96
+ }
97
+ const host = normalizeHostname(url.hostname);
98
+ if (entry.startsWith("*.")) {
99
+ const suffix = entry.slice(1).toLowerCase(); // ".gpu.internal"
100
+ return host.endsWith(suffix);
101
+ }
102
+ return host === entry.toLowerCase();
103
+ }
104
+ /** Read a string `baseURL` property off an SDK client instance, if present. */
105
+ function readBaseURL(client) {
106
+ if (client != null && typeof client === "object" && "baseURL" in client) {
107
+ const value = client.baseURL;
108
+ if (typeof value === "string")
109
+ return value;
110
+ }
111
+ return undefined;
112
+ }
113
+ /**
114
+ * Classify the endpoint a client points at — local (self-hosted) or cloud —
115
+ * selecting the settlement regime for every governed call. Runs BESIDE
116
+ * `detectClientKind` (which stays the transport detector); the endpoint class,
117
+ * not the model string, picks local vs cloud metering, so model-name spoofing
118
+ * (`ollama cp llama3.2 gpt-4o`) cannot change the regime.
119
+ *
120
+ * Classification order:
121
+ * 1. `override.class` provided → override wins (runtime defaults to "unknown").
122
+ * 2. `client.baseURL` read when it is a string; absent or malformed (URL parse
123
+ * failure) → treated as absent → cloud default. Never throws.
124
+ * 3. `config.endpoints[]` first match (origin / leading-star hostname suffix /
125
+ * bare hostname — see matchesEndpointEntry).
126
+ * 4. Loopback autodetect (config.local.autoDetectLoopback): hostname
127
+ * localhost | 127.0.0.1 | ::1 | ::ffff:127.0.0.1 (case-insensitive, IPv6
128
+ * brackets stripped, IPv4-mapped form matched by its WHATWG serialization) →
129
+ * local, runtime hinted by port (11434 ollama, 1234 lmstudio, 8000 vllm,
130
+ * else "openai-compat").
131
+ * 5. Default `{ class: "cloud", runtime: "unknown" }` — fail-EXPENSIVE:
132
+ * over-charging at cloud rates is recoverable, under-charging a paid
133
+ * endpoint is not.
134
+ *
135
+ * **Security posture (trusted-operator boundary):** endpoint classification —
136
+ * config matchers, overrides, and loopback autodetect — is a TRUSTED-OPERATOR
137
+ * decision. Never wire it to end-user or request input. In server/multi-tenant
138
+ * deployments set `local.autoDetectLoopback: false` and classify via explicit
139
+ * `endpoints[]` config: loopback inside a container can be a forwarding sidecar
140
+ * to a paid API. This is the same trust boundary as `budget`/`customRates` —
141
+ * the config author already controls billing entirely. Note that a compromised
142
+ * local server can under-report usage; receipts expose `usageSource` and
143
+ * `meter.rateSource` precisely so that this is auditable.
144
+ */
145
+ export function classifyEndpoint(client, config, override) {
146
+ const baseURL = readBaseURL(client);
147
+ // (1) Explicit override wins over everything.
148
+ if (override?.class !== undefined) {
149
+ const info = { class: override.class, runtime: override.runtime ?? "unknown" };
150
+ const overrideBase = override.baseURL ?? baseURL;
151
+ if (overrideBase !== undefined)
152
+ info.baseURL = overrideBase;
153
+ return info;
154
+ }
155
+ // (2) No readable baseURL → cloud default. Malformed → treat as absent, never throw.
156
+ if (baseURL === undefined)
157
+ return { ...CLOUD_DEFAULT };
158
+ const url = parseUrl(baseURL);
159
+ if (url === null)
160
+ return { ...CLOUD_DEFAULT };
161
+ // (3) config.endpoints[] — first match wins.
162
+ for (const entry of config.endpoints) {
163
+ if (matchesEndpointEntry(entry.match, url)) {
164
+ return { class: entry.class, runtime: entry.runtime, baseURL };
165
+ }
166
+ }
167
+ // (4) Loopback autodetect.
168
+ if (config.local.autoDetectLoopback && LOOPBACK_HOSTS.has(normalizeHostname(url.hostname))) {
169
+ return { class: "local", runtime: RUNTIME_BY_PORT[url.port] ?? "openai-compat", baseURL };
170
+ }
171
+ // (5) Cloud default.
172
+ return { class: "cloud", runtime: "unknown", baseURL };
173
+ }
50
174
  //# sourceMappingURL=detect.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"detect.js","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;AAuBjC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAe;IAC/C,IACC,MAAM,IAAI,IAAI;QACd,OAAO,MAAM,KAAK,QAAQ;QAC1B,UAAU,IAAI,MAAM;QACpB,MAAM,CAAC,QAAQ,IAAI,IAAI;QACvB,OAAO,MAAM,CAAC,QAAQ,KAAK,QAAQ;QACnC,QAAQ,IAAI,MAAM,CAAC,QAAQ;QAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,KAAK,UAAU,EAC3C,CAAC;QACF,OAAO,WAAW,CAAC;IACpB,CAAC;IAED,IACC,MAAM,IAAI,IAAI;QACd,OAAO,MAAM,KAAK,QAAQ;QAC1B,MAAM,IAAI,MAAM;QAChB,MAAM,CAAC,IAAI,IAAI,IAAI;QACnB,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ;QAC/B,aAAa,IAAI,MAAM,CAAC,IAAI;QAC5B,MAAM,CAAC,IAAI,CAAC,WAAW,IAAI,IAAI;QAC/B,OAAO,MAAM,CAAC,IAAI,CAAC,WAAW,KAAK,QAAQ;QAC3C,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,WAAW;QACnC,OAAO,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,KAAK,UAAU,EACnD,CAAC;QACF,OAAO,QAAQ,CAAC;IACjB,CAAC;IAED,IACC,MAAM,IAAI,IAAI;QACd,OAAO,MAAM,KAAK,QAAQ;QAC1B,QAAQ,IAAI,MAAM;QAClB,MAAM,CAAC,MAAM,IAAI,IAAI;QACrB,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ;QACjC,iBAAiB,IAAI,MAAM,CAAC,MAAM;QAClC,OAAO,MAAM,CAAC,MAAM,CAAC,eAAe,KAAK,UAAU,EAClD,CAAC;QACF,OAAO,QAAQ,CAAC;IACjB,CAAC;IAED,MAAM,IAAI,KAAK,CAAC,2EAA2E,CAAC,CAAC;AAC9F,CAAC"}
1
+ {"version":3,"file":"detect.js","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"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"}
package/dist/govern.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type AuditWriter } from "./audit/chain.js";
2
- import type { ActionDescriptor, GovernedActionResult } from "./shared/types.js";
2
+ import type { ActionDescriptor, EndpointInfo, GovernedActionResult } from "./shared/types.js";
3
3
  export interface TrustOpts {
4
4
  /** Path to usertrust.config.json. Defaults to `.usertrust/usertrust.config.json`. */
5
5
  configPath?: string;
@@ -25,6 +25,13 @@ export interface TrustOpts {
25
25
  dryRun?: boolean;
26
26
  /** Vault directory override (default: cwd). */
27
27
  vaultBase?: string;
28
+ /**
29
+ * Explicit endpoint classification override (M2) — wins over config.endpoints
30
+ * matchers and loopback autodetect. TRUSTED-OPERATOR input, same trust
31
+ * boundary as budget/customRates: never derive it from end-user or request
32
+ * data (A10).
33
+ */
34
+ endpoint?: Partial<EndpointInfo> | undefined;
28
35
  /**
29
36
  * Inject a mock/test engine. When set, used instead of TigerBeetle.
30
37
  * Primarily for testing failure modes.
@@ -1 +1 @@
1
- {"version":3,"file":"govern.d.ts","sourceRoot":"","sources":["../src/govern.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAE,KAAK,WAAW,EAAqB,MAAM,kBAAkB,CAAC;AA0BvE,OAAO,KAAK,EACX,gBAAgB,EAChB,oBAAoB,EAKpB,MAAM,mBAAmB,CAAC;AAK3B,MAAM,WAAW,SAAS;IACzB,qFAAqF;IACrF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,8DAA8D;AAC9D,MAAM,WAAW,WAAW;IAC3B,YAAY,CAAC,MAAM,EAAE;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpC;;;OAGG;IACH,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,gEAAgE;IAChE,cAAc,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,OAAO,CAAC,IAAI,IAAI,CAAC;CACjB;AAED,sEAAsE;AACtE,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI,CAAC,GAAG;IAClC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,YAAY,CAAC,CAAC,EACb,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACvB,OAAO,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC;CACpC,CAAC;AAuFF,wBAAsB,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAorCrF"}
1
+ {"version":3,"file":"govern.d.ts","sourceRoot":"","sources":["../src/govern.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAE,KAAK,WAAW,EAAqB,MAAM,kBAAkB,CAAC;AAgCvE,OAAO,KAAK,EACX,gBAAgB,EAChB,YAAY,EACZ,oBAAoB,EAKpB,MAAM,mBAAmB,CAAC;AAK3B,MAAM,WAAW,SAAS;IACzB,qFAAqF;IACrF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,SAAS,CAAC;IAC7C;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,8DAA8D;AAC9D,MAAM,WAAW,WAAW;IAC3B,YAAY,CAAC,MAAM,EAAE;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpC;;;OAGG;IACH,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,gEAAgE;IAChE,cAAc,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,OAAO,CAAC,IAAI,IAAI,CAAC;CACjB;AAED,sEAAsE;AACtE,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI,CAAC,GAAG;IAClC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,YAAY,CAAC,CAAC,EACb,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACvB,OAAO,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC;CACpC,CAAC;AA6KF,wBAAsB,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CA2xCrF"}
package/dist/govern.js CHANGED
@@ -33,9 +33,9 @@ import { join } from "node:path";
33
33
  import { CreateTransferError } from "tigerbeetle-node";
34
34
  import { createAuditWriter } from "./audit/chain.js";
35
35
  import { writeReceipt } from "./audit/rotation.js";
36
- import { detectClientKind } from "./detect.js";
36
+ import { classifyEndpoint, detectClientKind } from "./detect.js";
37
37
  import { TBTransferError, TrustTBClient, XFER_SPEND } from "./ledger/client.js";
38
- import { estimateCost, estimateInputTokens } from "./ledger/pricing.js";
38
+ import { costFromRates, estimateInputTokens, resolveRates, warnUnknownModel, } from "./ledger/pricing.js";
39
39
  import { recordPattern } from "./memory/patterns.js";
40
40
  import { DEFAULT_RULES, mergePolicies } from "./policy/default-rules.js";
41
41
  import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
@@ -66,6 +66,86 @@ class AsyncMutex {
66
66
  return release;
67
67
  }
68
68
  }
69
+ // ── M2 unknown-model policy (A5) ──
70
+ /** USD value of one usertoken: 1 usertoken = $0.0001 (one basis point of a cent). */
71
+ const USERTOKENS_PER_DOLLAR = 10_000;
72
+ /**
73
+ * Enforce config.unknownModelPolicy at AUTHORIZE time (A5). Only cloud-scope
74
+ * resolutions can be unknown — local scope always resolves to local rates.
75
+ * "deny" throws before any PENDING hold; "warn" logs once per model string per
76
+ * process via the shared warnUnknownModel helper (receipts carry
77
+ * meter.rateSource "fallback" regardless of warn dedup); "fallback" is silent.
78
+ */
79
+ function enforceUnknownModelPolicy(model, resolution, config) {
80
+ if (!resolution.unknown)
81
+ return;
82
+ if (config.unknownModelPolicy === "deny") {
83
+ throw new PolicyDeniedError(`unknown_model: ${model} not in pricing table`);
84
+ }
85
+ if (config.unknownModelPolicy === "warn") {
86
+ warnUnknownModel(model);
87
+ }
88
+ }
89
+ /**
90
+ * A4: build forward-args for a local openai stream call with
91
+ * stream_options.include_usage merged in. The caller's other stream_options
92
+ * fields survive; an explicit include_usage (true OR false) is respected —
93
+ * only a missing/null value injects. Returns null when no injection applies.
94
+ */
95
+ function withInjectedUsageOptions(args) {
96
+ const params = (args[0] ?? {});
97
+ if (params.stream !== true)
98
+ return null;
99
+ const rawOptions = params.stream_options;
100
+ const streamOptions = rawOptions != null && typeof rawOptions === "object"
101
+ ? rawOptions
102
+ : undefined;
103
+ if (streamOptions?.include_usage != null)
104
+ return null;
105
+ return [
106
+ { ...params, stream_options: { ...streamOptions, include_usage: true } },
107
+ ...args.slice(1),
108
+ ];
109
+ }
110
+ /** Message shapes an OpenAI-compat server emits when it rejects an unknown field. */
111
+ const STREAM_OPTIONS_REJECTION_RE = /stream_options|include_usage|unrecognized|unknown.{0,20}(field|argument|parameter|option)/i;
112
+ /**
113
+ * A4 retry heuristic: does `err` plausibly indicate that the server rejected the
114
+ * injected `stream_options.include_usage`? We only retry-without-injection for
115
+ * these — a blanket retry on ANY error would double the provider call on
116
+ * transient failures (ECONNRESET, timeouts, 5xx) and mask the real root cause,
117
+ * so everything else rethrows the ORIGINAL error immediately. Matches the
118
+ * rejection message on the error, its nested `error.message`, or a cheaply
119
+ * reachable `response` body; or an HTTP-shaped 400/422 status on the error
120
+ * object. Tradeoff: a server that rejects the field with an opaque 500 and no
121
+ * telltale text is NOT retried — acceptable, since include_usage is widely
122
+ * supported and the caller still receives the original error.
123
+ */
124
+ function looksLikeStreamOptionsRejection(err) {
125
+ if (err == null || typeof err !== "object") {
126
+ return err instanceof Error && STREAM_OPTIONS_REJECTION_RE.test(err.message);
127
+ }
128
+ const e = err;
129
+ const status = e.status ?? e.statusCode;
130
+ if (status === 400 || status === 422)
131
+ return true;
132
+ const candidates = [e.message, e.body];
133
+ if (err instanceof Error)
134
+ candidates.push(err.message);
135
+ const nested = e.error;
136
+ if (nested != null && typeof nested === "object") {
137
+ candidates.push(nested.message);
138
+ }
139
+ const response = e.response;
140
+ if (typeof response === "string") {
141
+ candidates.push(response);
142
+ }
143
+ else if (response != null && typeof response === "object") {
144
+ const r = response;
145
+ candidates.push(r.data, r.body);
146
+ }
147
+ return candidates.some((c) => typeof c === "string" && STREAM_OPTIONS_REJECTION_RE.test(c));
148
+ }
69
149
  async function loadSpendLedger(vaultBase) {
70
150
  const ledgerPath = join(vaultBase, VAULT_DIR, "spend-ledger.json");
71
151
  try {
@@ -139,7 +219,6 @@ export async function trust(client, opts) {
139
219
  budget: opts?.budget ?? DEFAULT_BUDGET,
140
220
  });
141
221
  }
142
- const customRates = config.pricing === "custom" ? config.customRates : undefined;
143
222
  const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
144
223
  // AUD-470: Only accept injected _engine/_audit in test environments.
145
224
  // In production, silently ignore them to prevent governance bypass.
@@ -189,8 +268,12 @@ export async function trust(client, opts) {
189
268
  else {
190
269
  engine = null;
191
270
  }
192
- // 5. Detect client kind
271
+ // 5. Detect client kind (transport) + classify the endpoint (settlement
272
+ // regime, M2). classifyEndpoint runs BESIDE detectClientKind — the endpoint
273
+ // class, not the model string, picks local vs cloud metering (A3: this scope
274
+ // is captured once here and used verbatim at every authorize/settle below).
193
275
  const kind = detectClientKind(client);
276
+ const endpoint = classifyEndpoint(client, config, opts?.endpoint);
194
277
  // 6. Track state
195
278
  let destroyed = false;
196
279
  const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
@@ -199,7 +282,29 @@ export async function trust(client, opts) {
199
282
  let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
200
283
  // Streaming anomaly detector — shared across calls so injection-cascade
201
284
  // can track signals across the conversation. Disabled when config.anomaly.enabled=false.
202
- const anomalyDetector = createAnomalyDetector(config.anomaly, { provider: kind });
285
+ // M2 seam fix: the injected costCalculator prices spend-velocity with the
286
+ // SAME scoped rates as settlement, using the observed event's
287
+ // model/endpointClass (the detector is shared while the model varies per
288
+ // call). Denominations differ by design: cloud events → DOLLARS against
289
+ // thresholdDollarsPerMin; local events → nominal usertokens against
290
+ // localThresholdUsertokensPerMin, WITHOUT the per-call >=1 settlement floor.
291
+ // Settlement floors per call; the anomaly signal measures flow — so a
292
+ // default {0,0}-rate local stream contributes 0 and can never false-trip
293
+ // spend_velocity (rejected-merge design note, pinned by Task 3 tests).
294
+ const anomalyDetector = createAnomalyDetector(config.anomaly, {
295
+ provider: kind,
296
+ costCalculator: (calcModel, inputTokens, outputTokens, event) => {
297
+ const scope = event?.endpointClass ?? "cloud";
298
+ const resolution = resolveRates(event?.model ?? calcModel, scope, config);
299
+ if (scope === "local") {
300
+ const inTok = Number.isFinite(inputTokens) && inputTokens > 0 ? inputTokens : 0;
301
+ const outTok = Number.isFinite(outputTokens) && outputTokens > 0 ? outputTokens : 0;
302
+ return ((inTok / 1000) * resolution.rates.inputPer1k +
303
+ (outTok / 1000) * resolution.rates.outputPer1k);
304
+ }
305
+ return costFromRates(resolution.rates, inputTokens, outputTokens) / USERTOKENS_PER_DOLLAR;
306
+ },
307
+ });
203
308
  // 7. Two-phase intercept
204
309
  async function interceptCall(originalFn, thisArg, args) {
205
310
  if (destroyed) {
@@ -224,11 +329,17 @@ export async function trust(client, opts) {
224
329
  // a. Circuit breaker check
225
330
  const cb = breaker.get(kind);
226
331
  cb.allowRequest();
227
- // b. Estimate cost (before policy, so cost fields are available in context)
332
+ // b. Estimate cost (before policy, so cost fields are available in context).
333
+ // M2: rates resolve within the endpoint scope CAPTURED AT AUTHORIZE (A3) —
334
+ // this one resolution prices the hold, both settlement paths, and the
335
+ // receipt's meter provenance. unknownModelPolicy is enforced here, at
336
+ // authorize time, before any PENDING hold (A5).
228
337
  const transferId = trustId("tx");
229
338
  const estimatedInputTokens = estimateInputTokens(promptParts);
230
339
  const maxOutputTokens = params.max_tokens ?? 4096;
231
- const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
340
+ const rateResolution = resolveRates(model, endpoint.class, config);
341
+ enforceUnknownModelPolicy(model, rateResolution, config);
342
+ const estimatedCost = costFromRates(rateResolution.rates, estimatedInputTokens, maxOutputTokens);
232
343
  // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
233
344
  // This prevents concurrent calls from both passing the budget check
234
345
  // and overshooting the budget.
@@ -352,11 +463,41 @@ export async function trust(client, opts) {
352
463
  // AUD-453: Release lock after budget check + hold are complete
353
464
  releaseBudgetLock();
354
465
  }
466
+ // e0. M2 usage injection (A4): for local openai streams, opt in to the
467
+ // server's final usage chunk (Ollama emits /v1 streaming usage ONLY when
468
+ // stream_options.include_usage is set). The merge preserves the caller's
469
+ // other stream_options fields and respects an explicit include_usage.
470
+ // The resulting usage chunk is FORWARDED to the consumer unmodified
471
+ // (transparent middleware).
472
+ let preInjectionArgs = null;
473
+ if (kind === "openai" && endpoint.class === "local" && config.local.injectUsageOptions) {
474
+ const injected = withInjectedUsageOptions(forwardArgs);
475
+ if (injected != null) {
476
+ preInjectionArgs = forwardArgs;
477
+ forwardArgs = injected;
478
+ }
479
+ }
355
480
  // e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
356
481
  // redacted clone in redact mode, or the original args otherwise.
357
482
  let settled = true;
358
483
  try {
359
- const response = await originalFn.apply(thisArg, forwardArgs);
484
+ let response;
485
+ try {
486
+ response = await originalFn.apply(thisArg, forwardArgs);
487
+ }
488
+ catch (callErr) {
489
+ // A4: some OpenAI-compat servers reject unknown stream_options. When
490
+ // WE injected them AND the error plausibly says so (message/HTTP-status
491
+ // heuristic), retry ONCE without the injection; the retried stream
492
+ // simply settles on the estimate if no usage tail arrives (A7). Any
493
+ // other error — transient network failures, unrelated 5xx — rethrows
494
+ // the ORIGINAL immediately rather than duplicating compute and masking
495
+ // the root cause.
496
+ if (preInjectionArgs == null || !looksLikeStreamOptionsRejection(callErr)) {
497
+ throw callErr;
498
+ }
499
+ response = await originalFn.apply(thisArg, preInjectionArgs);
500
+ }
360
501
  // e2. Streaming detection: if response is an async iterable, wrap with
361
502
  // token accumulation. Settlement and audit happen when the stream ends.
362
503
  if (response != null &&
@@ -364,11 +505,13 @@ export async function trust(client, opts) {
364
505
  Symbol.asyncIterator in response) {
365
506
  const stream = response;
366
507
  const governedStream = createGovernedStream(stream, kind, async (completion) => {
367
- // Determine cost: use provider usage if reported, else fall back to estimate
508
+ // Determine cost: use provider usage if reported, else fall back to
509
+ // estimate. A3: priced with the rate resolution captured at
510
+ // authorize; A11: costFromRates floors at 1 even for 0/0 usage.
368
511
  let streamCost;
369
512
  let usageSource;
370
513
  if (completion.usageReported) {
371
- streamCost = estimateCost(model, completion.usage.inputTokens, completion.usage.outputTokens, customRates);
514
+ streamCost = costFromRates(rateResolution.rates, completion.usage.inputTokens, completion.usage.outputTokens);
372
515
  usageSource = "provider";
373
516
  }
374
517
  else {
@@ -455,6 +598,10 @@ export async function trust(client, opts) {
455
598
  transferId,
456
599
  usageSource,
457
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,
458
605
  };
459
606
  if (config.pii === "warn" || config.pii === "redact") {
460
607
  const piiResult = redactPII(promptParts);
@@ -493,6 +640,12 @@ export async function trust(client, opts) {
493
640
  timestamp: new Date().toISOString(),
494
641
  usageSource,
495
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
+ },
496
649
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
497
650
  // AUD-456: Flag proxy stub receipts
498
651
  ...(proxyConn != null ? { proxyStub: true } : {}),
@@ -546,6 +699,10 @@ export async function trust(client, opts) {
546
699
  deltaTokens: obs.deltaTokens,
547
700
  cumulativeInputTokens: obs.cumulativeInputTokens,
548
701
  cumulativeOutputTokens: obs.cumulativeOutputTokens,
702
+ // M2: stamp the per-call scope so the SHARED detector prices
703
+ // this event with the same scoped rates as settlement.
704
+ model,
705
+ endpointClass: endpoint.class,
549
706
  });
550
707
  const verdict = anomalyDetector.check();
551
708
  if (verdict.tripped) {
@@ -584,6 +741,12 @@ export async function trust(client, opts) {
584
741
  model,
585
742
  provider: kind,
586
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
+ },
587
750
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
588
751
  // AUD-456: Flag proxy stub receipts
589
752
  ...(proxyConn != null ? { proxyStub: true } : {}),
@@ -591,8 +754,11 @@ export async function trust(client, opts) {
591
754
  inFlightStreamCount++;
592
755
  return { response: governedStream, receipt: estimatedReceipt };
593
756
  }
594
- // f. Compute actual cost from response usage
757
+ // f. Compute actual cost from response usage. A3: priced with the rate
758
+ // resolution captured at authorize. costFromRates clamps NaN/negative
759
+ // counts (A7) and floors at 1 even for 0/0 provider usage (A11).
595
760
  let actualCost = estimatedCost;
761
+ let usageSource = "estimated";
596
762
  if (response != null && typeof response === "object" && "usage" in response) {
597
763
  const usage = response.usage;
598
764
  if (usage != null) {
@@ -602,7 +768,8 @@ export async function trust(client, opts) {
602
768
  const outputTokens = usage.output_tokens ??
603
769
  usage.completion_tokens ??
604
770
  0;
605
- actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
771
+ actualCost = costFromRates(rateResolution.rates, inputTokens, outputTokens);
772
+ usageSource = "provider";
606
773
  }
607
774
  }
608
775
  // h. Audit the llm_call FIRST (P3-AUDIT-FAILCLOSED). The settlement-
@@ -619,6 +786,11 @@ export async function trust(client, opts) {
619
786
  cost: actualCost,
620
787
  settled: true,
621
788
  transferId,
789
+ usageSource,
790
+ // M2: metering provenance mirrors the receipt (A3 authorize-time scope).
791
+ endpointClass: endpoint.class,
792
+ costBasis: rateResolution.costBasis,
793
+ rateSource: rateResolution.rateSource,
622
794
  };
623
795
  if (config.pii === "warn" || config.pii === "redact") {
624
796
  const piiResult = redactPII(promptParts);
@@ -750,6 +922,13 @@ export async function trust(client, opts) {
750
922
  model,
751
923
  provider: kind,
752
924
  timestamp: new Date().toISOString(),
925
+ usageSource,
926
+ // M2 provenance (A6: computeMs omitted — no compute-time source here).
927
+ endpoint: { class: endpoint.class, runtime: endpoint.runtime },
928
+ meter: {
929
+ costBasis: rateResolution.costBasis,
930
+ rateSource: rateResolution.rateSource,
931
+ },
753
932
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
754
933
  // AUD-456: Flag proxy stub receipts
755
934
  ...(proxyConn != null ? { proxyStub: true } : {}),