@swfte/nexus-sdk 0.1.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/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@swfte/nexus-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Embedded agent observability for Node services \u2014 zero dependencies, bundler-proof.",
5
+ "license": "Apache-2.0",
6
+ "author": "Swfte AI",
7
+ "homepage": "https://github.com/SwfteAI/nexus-sdk-node",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/SwfteAI/nexus-sdk-node.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/SwfteAI/nexus-sdk-node/issues"
14
+ },
15
+ "keywords": [
16
+ "observability",
17
+ "agents",
18
+ "llm",
19
+ "telemetry",
20
+ "tracing",
21
+ "ai-sdk",
22
+ "vercel"
23
+ ],
24
+ "type": "module",
25
+ "types": "./index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./index.d.ts",
29
+ "import": "./src/index.js",
30
+ "require": "./src/index.cjs"
31
+ },
32
+ "./ai": {
33
+ "types": "./ai.d.ts",
34
+ "import": "./src/ai.js",
35
+ "require": "./src/ai.cjs"
36
+ },
37
+ "./package.json": "./package.json",
38
+ "./otel": {
39
+ "types": "./otel.d.ts",
40
+ "import": "./src/otel/index.js",
41
+ "require": "./src/otel/index.cjs"
42
+ },
43
+ "./policy": {
44
+ "types": "./policy.d.ts",
45
+ "require": "./src/policy/index.cjs",
46
+ "import": "./src/policy/index.cjs"
47
+ }
48
+ },
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ },
52
+ "sideEffects": false,
53
+ "files": [
54
+ "LICENSE",
55
+ "NOTICE",
56
+ "README.md",
57
+ "ai.d.ts",
58
+ "index.d.ts",
59
+ "otel.d.ts",
60
+ "policy.d.ts",
61
+ "src/ai.cjs",
62
+ "src/ai.js",
63
+ "src/core.cjs",
64
+ "src/health.cjs",
65
+ "src/index.cjs",
66
+ "src/index.js",
67
+ "src/otel/bridge.cjs",
68
+ "src/otel/classify.cjs",
69
+ "src/otel/index.cjs",
70
+ "src/otel/index.js",
71
+ "src/otel/semconv.cjs",
72
+ "src/policy/engine.cjs",
73
+ "src/policy/envelope.cjs",
74
+ "src/policy/index.cjs",
75
+ "src/policy/rules.cjs",
76
+ "src/pricing.cjs",
77
+ "src/provenance.cjs",
78
+ "src/redact.cjs"
79
+ ],
80
+ "scripts": {
81
+ "test": "node --test test/*.test.mjs",
82
+ "bundle": "node bundle/build.mjs",
83
+ "measure": "node bundle/measure.mjs",
84
+ "typecheck": "npx --yes --package typescript@5.6.3 tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 test/types-check.mts",
85
+ "guard": "node scripts/npm-guard.cjs",
86
+ "parity": "node scripts/redact-parity.mjs && node scripts/pricing-parity.mjs && node scripts/policy-parity.mjs && node conformance/run.mjs",
87
+ "probe:ai": "cd fixtures/vercel && npm install --no-audit --no-fund && node bridge-check.mjs && node inspect.mjs",
88
+ "prepublishOnly": "npm test && npm run typecheck && npm run guard",
89
+ "conformance": "node conformance/run.mjs"
90
+ },
91
+ "dependencies": {},
92
+ "devDependencies": {}
93
+ }
package/policy.d.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `@swfte/nexus-sdk/policy` — the enforcement seam.
3
+ *
4
+ * Every agent-observability tool observes. The differentiator is that this one can refuse.
5
+ *
6
+ * ── Which call sites are genuinely gated ─────────────────────────────────────────────────────
7
+ *
8
+ * **Importing this module gates `run.action(...)`.** That is the only gated call site in this SDK,
9
+ * and it is the only one that *can* be: enforcement needs a decision taken before the effect, which
10
+ * needs a call site you own.
11
+ *
12
+ * **These are NOT gated, and cannot be:**
13
+ *
14
+ * - **The Vercel AI SDK seam (`@swfte/nexus-sdk/ai`).** Its callbacks return `void` — there is no
15
+ * channel to refuse on, and a throw from `onToolExecutionStart` neither stops the tool nor
16
+ * reaches your code. There is deliberately no policy hook there and there will not be one: a hook
17
+ * that appears to work and silently does not is worse than no hook. **If your application runs
18
+ * tool calls through the AI SDK, they cannot be gated in-process** — now or in a later release.
19
+ * - **The OpenTelemetry bridge (`@swfte/nexus-sdk/otel`).** A span is read after the fact; by the
20
+ * time it exists the call has happened.
21
+ * - **`run.usage()`, `act.effect()`, `integration()`, `deployment()`.** All of these record
22
+ * something that already occurred.
23
+ *
24
+ * So enforcement in Node means the explicit API, a gateway in front of your provider, or nothing.
25
+ * Worth knowing before you design around it rather than after.
26
+ *
27
+ * ── What throws ──────────────────────────────────────────────────────────────────────────────
28
+ *
29
+ * `Denied` is the single exception this SDK will ever put in your stack trace, and you opted into
30
+ * it twice: the rule carried `enforce: true` and the call site did not decline. **With no policy
31
+ * installed — the default — nothing here can throw**, so adding the import changes no behaviour
32
+ * until a signed envelope arrives.
33
+ */
34
+
35
+ /** How a decision was reached. Read it before trusting `denied`. */
36
+ export type DecisionSource =
37
+ /** A rule matched. */
38
+ | 'policy'
39
+ /** Policy is installed and no rule matched. */
40
+ | 'no-match'
41
+ /** No verified policy is installed. Allows, unless `failClosed`. */
42
+ | 'no-policy'
43
+ /** The envelope is stale and the rule was not `enforce`-marked, so it became advice. */
44
+ | 'stale-advisory'
45
+ /** Evaluation exceeded its latency budget. **Always allows.** */
46
+ | 'budget-exceeded'
47
+ /** A `require_approval` rule with no approver channel; resolved to the rule's `on_timeout`. */
48
+ | 'approval-unavailable'
49
+ /** A bug inside policy evaluation. **Always allows**, and raises an integrity alert. */
50
+ | 'error';
51
+
52
+ export interface Decision {
53
+ action: 'allow' | 'deny';
54
+ /** True only when this decision actually stopped something. */
55
+ enforced: boolean;
56
+ /** `action === 'deny' && enforced`. The only thing that makes `check`/`gate` throw. */
57
+ readonly denied: boolean;
58
+ ruleId: string | null;
59
+ reason: string;
60
+ source: DecisionSource;
61
+ /** The envelope is past its hard-stale window. `enforce`-marked rules still enforce. */
62
+ stale: boolean;
63
+ timedOut: boolean;
64
+ latencyMs: number;
65
+ }
66
+
67
+ export interface DecideOptions {
68
+ /**
69
+ * `false` forces advise mode for this call site regardless of rule markings — the dry-run switch.
70
+ *
71
+ * A call site can **decline** to enforce; it can never **promote**. An unmarked rule stays
72
+ * advisory however this is set, because a mode that could be inferred from context is a mode
73
+ * somebody will infer wrongly at 3am.
74
+ */
75
+ enforce?: boolean;
76
+ /** Lower the latency budget for this decision. Cannot go below the operator's floor. */
77
+ budgetMs?: number;
78
+ }
79
+
80
+ export interface InstallResult {
81
+ installed: boolean;
82
+ /** Why it was refused — a specific string, never a generic failure. */
83
+ problem: string | null;
84
+ state: 'fresh' | 'soft-stale' | 'hard-stale' | 'expired' | null;
85
+ rules: number;
86
+ /** `[index, why]` per rule that would not parse. **A quarantined `enforce` rule is an
87
+ * enforcement gap**, so this is reported rather than merely counted. */
88
+ quarantined: Array<[number, string]>;
89
+ }
90
+
91
+ export interface PolicySettings {
92
+ enforcementEnabled?: boolean;
93
+ /** Deny when no verified policy is installed. **Opt-in**, and the only place in this module
94
+ * where being unable to reach something produces a denial. */
95
+ failClosed?: boolean;
96
+ decisionBudgetMs?: number;
97
+ pubkeyHex?: string | null;
98
+ }
99
+
100
+ /** The one exception this SDK raises. Carries the decision that produced it. */
101
+ export class Denied extends Error {
102
+ readonly decision: Decision;
103
+ }
104
+
105
+ /**
106
+ * Verify and install a signed policy envelope.
107
+ *
108
+ * Failure is *no policy*, which **allows** — never a denial. A control plane that pushed a bad
109
+ * envelope must not take your service down as the punishment, and a failed install leaves the
110
+ * previous good policy in place.
111
+ */
112
+ export function install(envelope: unknown, pubkeyHex?: string | null): InstallResult;
113
+
114
+ /** Adjust settings at runtime. */
115
+ export function configure(patch: PolicySettings): PolicySettings;
116
+
117
+ /** Evaluate a subject. **Never throws.** */
118
+ export function decide(kind: string, subject: Record<string, unknown>,
119
+ opts?: DecideOptions): Decision;
120
+
121
+ /** Decide, and throw {@link Denied} if an enforcing rule refuses. */
122
+ export function check(kind: string, subject: Record<string, unknown>,
123
+ opts?: DecideOptions): Decision;
124
+
125
+ /**
126
+ * Guard a function. **Decides first, then calls it.**
127
+ *
128
+ * On a denial the body never executes. A denial raised after the write already went out is not
129
+ * enforcement, it is journalism.
130
+ */
131
+ export function gate<T>(kind: string, subject: Record<string, unknown>,
132
+ fn: (decision: Decision) => T, opts?: DecideOptions): T;
133
+
134
+ /** Engine counters: evaluations, denials, budget overruns, alerts by kind. */
135
+ export function counters(): Record<string, number>;
136
+
137
+ /** Every integrity alert this process has raised, with its count and window. */
138
+ export function alerts(): Array<{ kind: string; count: number; firstSeen: number; lastSeen: number }>;
139
+
140
+ /** Forget the installed policy, the counters and the alerts. Chiefly for tests. */
141
+ export function reset(): void;
package/src/ai.cjs ADDED
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+ /**
3
+ * The Vercel AI SDK bridge — the one capture path in Node that a bundler cannot take away.
4
+ *
5
+ * ── Why this exists at all ───────────────────────────────────────────────────────────────────
6
+ *
7
+ * Module-hook auto-instrumentation intercepts an *import*. A bundler inlines the provider's source
8
+ * into the application's own file, so by the time the artifact runs there is no import left to
9
+ * intercept and no module boundary for a hook to sit on. That is not a degraded mode, it is a dead
10
+ * end: `SCOPE.md` §3 measures it, and `test/bundled.test.mjs` asserts it. Since Next.js bundles
11
+ * server code by default, the deployment shape most likely to be running an agent in Node is
12
+ * exactly the shape module hooks cannot reach.
13
+ *
14
+ * `registerTelemetry()` is a *runtime registration*. The application calls it, at startup, with an
15
+ * object of callbacks. Nothing about the module graph is involved, so nothing a bundler does can
16
+ * erase it — verified rather than argued: `fixtures/vercel/bundle-check.mjs` bundles the probe with
17
+ * esbuild and counts the same six callbacks firing.
18
+ *
19
+ * ── What this bridge deliberately cannot do ──────────────────────────────────────────────────
20
+ *
21
+ * **It cannot deny.** `onToolExecutionStart` fires before a tool's `execute`, which looks like an
22
+ * enforcement point and is not one. The callback type is `(event) => PromiseLike<void> | void`:
23
+ * there is no return channel by which an integration could refuse, and a thrown error does not
24
+ * propagate — measured, not assumed. `fixtures/vercel/enforce.mjs` throws from that callback and
25
+ * the tool still runs (`tool-executions=1`) with the throw swallowed entirely.
26
+ *
27
+ * So this module is observe-only, by the seam's type signature rather than by our choice. In Node,
28
+ * enforcement comes from the explicit API — `run.action(...)`, where the customer owns the call
29
+ * site and a decision can be taken before the effect — or it does not come at all. Nothing in this
30
+ * file accepts a policy, a gate, or a `block` callback, because offering one would be a lie that
31
+ * only shows up in production.
32
+ *
33
+ * ── Content ─────────────────────────────────────────────────────────────────────────────────
34
+ *
35
+ * This bridge used to emit **metadata only, at every tier** — never a tool input, never a tool
36
+ * output, not even at `tier: 'full'`. That was not caution for its own sake: the AI SDK hands these
37
+ * callbacks fully-formed message arrays and tool payloads, arbitrary customer data in bulk
38
+ * including whatever a RAG step just pulled out of a database, and gating it correctly needed a
39
+ * redaction pass this SDK did not have. A half-written redactor is worse than none, because its
40
+ * output *looks* scrubbed.
41
+ *
42
+ * `src/redact.cjs` is that pass now — ported from `nexus/redact.py` and checked against it over a
43
+ * shared corpus by `scripts/redact-parity.mjs`. So the restriction is lifted as far as the redactor
44
+ * honestly reaches, and no further:
45
+ *
46
+ * - **Tool inputs and outputs are emitted**, through `scrubMapping`, on the ordinary tier ladder.
47
+ * They are structured values, which is exactly the shape that pass was built for: a key called
48
+ * `api_key` is masked by name whatever its type, a number under an unrecognised key is masked
49
+ * below `full`, and the walk is bounded in width and depth so a paged tool result cannot turn
50
+ * scrubbing into the slow part of the request.
51
+ * - **Prompt and completion text are still not emitted.** Not because the redactor could not scrub
52
+ * them, but because there is nowhere honest to put them: the events carrying model text are
53
+ * `prompt`, `model_response` and `model_thinking`, and in the Python SDK those come from the OTel
54
+ * bridge, which this SDK does not have yet. Inventing a home for the text here would repeat the
55
+ * `data_expectation` mistake — a producer emitting ahead of the artifact.
56
+ *
57
+ * The honest summary: **structured tool data rides the tier ladder; model text does not appear at
58
+ * all.** `test/ai.test.mjs` asserts both halves, including a canary run that drives marked secrets
59
+ * through every callback at `tier: 'full'` and searches the whole emitted corpus.
60
+ *
61
+ * ── Usage ───────────────────────────────────────────────────────────────────────────────────
62
+ *
63
+ * import { registerTelemetry } from 'ai';
64
+ * import * as nexus from '@swfte/nexus-sdk';
65
+ * import { telemetry } from '@swfte/nexus-sdk/ai';
66
+ *
67
+ * nexus.init({ service: 'support-triage', env: 'prod' });
68
+ * registerTelemetry(telemetry());
69
+ *
70
+ * Note the direction: the customer imports `ai`, not us. This package has no dependency on `ai`,
71
+ * no peer dependency, and no version range to conflict with theirs — we hand them a plain object
72
+ * of callbacks and they pass it to a function they already had. That also means this file cannot
73
+ * break when `ai` changes its exports; it can only be wrong about a *field name*, which is what
74
+ * `FIELD_MAP` below and the recorded fixture exist to pin.
75
+ */
76
+
77
+ const core = require('./core.cjs');
78
+
79
+ /** Stamped on every event this bridge produces, so its coverage is separable in the ledger. */
80
+ const MODE = 'ai-sdk';
81
+
82
+ /**
83
+ * Where each number is read from on `ai@7`, recorded because it is the part most likely to move.
84
+ *
85
+ * These are not the names the AI SDK's own documentation suggests, and two of them are not the
86
+ * names this repository's own spike fixtures used. Measured against `ai@7.0.58` with
87
+ * `fixtures/vercel/inspect.mjs`:
88
+ *
89
+ * usage own keys: ["inputTokens","inputTokenDetails","outputTokens",
90
+ * "outputTokenDetails","totalTokens","raw"]
91
+ * usage.inputTokenDetails: { noCacheTokens, cacheReadTokens, cacheWriteTokens }
92
+ * performance: { responseTimeMs, effectiveOutputTokensPerSecond, ... }
93
+ *
94
+ * The traps, both of which produced silently-empty output before being measured:
95
+ *
96
+ * - the cache split is `usage.inputTokenDetails.cacheReadTokens`, NOT `usage.cachedInputTokens`;
97
+ * - the duration is `performance.responseTimeMs`, NOT `performance.durationMs`.
98
+ *
99
+ * `fixtures/vercel/enforce.mjs` reads both of the wrong ones. It still "worked" — printed a line,
100
+ * exited zero — with every token field silently absent, because `JSON.stringify` drops `undefined`
101
+ * keys. That is the exact failure this bridge has to not have, and it is why the reader below is
102
+ * written to try the nested shape first and fall back, and why `test/ai.test.mjs` asserts on
103
+ * numbers rather than on the call happening.
104
+ */
105
+ const FIELD_MAP = Object.freeze({
106
+ model: 'event.modelId',
107
+ provider: 'event.provider',
108
+ inputTokens: 'event.usage.inputTokens',
109
+ outputTokens: 'event.usage.outputTokens',
110
+ cacheReadTokens: 'event.usage.inputTokenDetails.cacheReadTokens',
111
+ cacheWriteTokens: 'event.usage.inputTokenDetails.cacheWriteTokens',
112
+ durationMs: 'event.performance.responseTimeMs',
113
+ toolName: 'event.toolCall.toolName',
114
+ toolDurationMs: 'event.toolExecutionMs',
115
+ });
116
+
117
+ /**
118
+ * The tool's actual return value, out of the AI SDK's wrapper around it.
119
+ *
120
+ * `onToolExecutionEnd` reports `toolOutput` as `{ type: 'tool-result', toolCallId, toolName, input,
121
+ * output, dynamic }` — the result plus a copy of the metadata already on the event. Emitting the
122
+ * wrapper would duplicate `toolName` and `input` inside `effect.output` and bury the actual value
123
+ * one level deeper than a reader expects, so the payload is unwrapped here.
124
+ *
125
+ * Defensive about the shape because it is version-dependent: an older or newer `ai` that hands back
126
+ * the bare result rather than the envelope still works.
127
+ */
128
+ function outputOf(toolOutput) {
129
+ if (!toolOutput || typeof toolOutput !== 'object') return toolOutput;
130
+ return 'output' in toolOutput ? toolOutput.output : toolOutput;
131
+ }
132
+
133
+ /**
134
+ * The `effect` payload for one tool execution, or `null` when nothing may be recorded.
135
+ *
136
+ * Keys are assigned rather than declared, so a suppressed half leaves no key behind at all. See the
137
+ * call site for why `{ input: undefined }` is not good enough.
138
+ */
139
+ function toolEffect(call, event) {
140
+ const effect = {};
141
+ if (event.recordInputs !== false && call.input !== undefined) effect.input = call.input;
142
+ if (event.recordOutputs !== false && event.toolOutput !== undefined) {
143
+ effect.output = outputOf(event.toolOutput);
144
+ }
145
+ return Object.keys(effect).length ? effect : null;
146
+ }
147
+
148
+ /** A finite non-negative number, or `undefined`. Never `NaN`, never a string that looked numeric. */
149
+ function num(v) {
150
+ if (typeof v !== 'number' || !Number.isFinite(v)) return undefined;
151
+ return v;
152
+ }
153
+
154
+ /**
155
+ * Read a token count that may be flat or nested.
156
+ *
157
+ * `ai@7.0.58` reports `usage.inputTokens` as a plain number and puts the cache split in a sibling
158
+ * `inputTokenDetails`. Earlier LanguageModelV3 drafts nested it as `{ total, cacheRead, ... }`, and
159
+ * `fixtures/vercel/inspect.mjs` still feeds a mock in that shape. Accepting both costs three lines
160
+ * and means a minor bump of `ai` that moves back or forth does not silently zero the billing
161
+ * numbers — which, given `input_tokens: o.inputTokens | 0` in the contract layer, would surface
162
+ * downstream as a real model call that cost nothing rather than as an error.
163
+ */
164
+ function tokens(v) {
165
+ if (v && typeof v === 'object') return num(v.total);
166
+ return num(v);
167
+ }
168
+
169
+ function detail(usage, flatKey, nestedParent, nestedKey) {
170
+ if (!usage) return undefined;
171
+ const flat = usage[nestedParent];
172
+ if (flat && typeof flat === 'object') {
173
+ const n = num(flat[nestedKey]);
174
+ if (n !== undefined) return n;
175
+ }
176
+ // The nested-usage shape carries the split inside the token object itself.
177
+ const t = usage[flatKey];
178
+ if (t && typeof t === 'object') return num(t[nestedKey.replace(/Tokens$/, '')]);
179
+ return undefined;
180
+ }
181
+
182
+ /**
183
+ * Build the telemetry integration object.
184
+ *
185
+ * Hand the result to the AI SDK's `registerTelemetry`. Every callback is individually guarded: a
186
+ * bug in this bridge must not surface inside the customer's `generateText` call, and the AI SDK's
187
+ * own error handling — which swallows throws from these callbacks, as `enforce.mjs` shows — would
188
+ * hide it rather than report it, so containing it here is the only way it gets counted at all.
189
+ *
190
+ * @param {{ runs?: boolean, toolActions?: boolean }} [opts]
191
+ * `runs` (default `false`) opens a nexus run per top-level AI SDK operation when the application
192
+ * is not already inside one. Off by default because double-counting is worse than under-counting:
193
+ * an application that already wraps its handler in `nexus.withAgent` would otherwise report two
194
+ * runs for one unit of work, and no downstream query can tell those apart afterwards.
195
+ * `toolActions` (default `true`) emits a `tool_action` per tool execution.
196
+ */
197
+ function telemetry(opts) {
198
+ const o = opts || {};
199
+ const wantRuns = o.runs === true;
200
+ const wantToolActions = o.toolActions !== false;
201
+
202
+ // Recorded now, not lazily: `instrumentation()` must report `ai-sdk` from the moment the
203
+ // integration is built, because the value's whole job is answering "is anything actually
204
+ // capturing?" for a customer looking at an empty dashboard. Deferring it until the first model
205
+ // call would make the honest answer arrive only after the thing it was needed to explain.
206
+ core.noteInstrumentation(MODE);
207
+
208
+ /** Runs this bridge opened itself, keyed by the AI SDK's own call id, so `onEnd` can close them. */
209
+ const ownRuns = new Map();
210
+
211
+ const guarded = (name, fn) => (event) => {
212
+ core.guard('ai.' + name, () => fn(event || {}));
213
+ };
214
+
215
+ const integration = {
216
+ onStart: guarded('onStart', (e) => {
217
+ if (!wantRuns) return;
218
+ if (core.currentRun()) return; // the application already owns a run; do not nest
219
+ const id = e.callId || e.operationId;
220
+ if (!id || ownRuns.has(id)) return;
221
+ const run = core.agent(String(e.functionId || e.operationId || 'ai'), {
222
+ goalClass: e.operationId ? String(e.operationId) : undefined,
223
+ });
224
+ ownRuns.set(id, run);
225
+ }),
226
+
227
+ onLanguageModelCallEnd: guarded('onLanguageModelCallEnd', (e) => {
228
+ const client = core.ensureClient();
229
+ if (!client) return;
230
+ const usage = e.usage || {};
231
+ const run = core.currentRun() || ownRuns.get(e.callId) || null;
232
+
233
+ client.emit(core.contract.tokenUsage(client.sessionId, client.cfg, {
234
+ model: e.modelId ? String(e.modelId) : 'unknown',
235
+ provider: e.provider ? String(e.provider) : undefined,
236
+ inputTokens: tokens(usage.inputTokens),
237
+ outputTokens: tokens(usage.outputTokens),
238
+ cacheReadTokens: detail(usage, 'inputTokens', 'inputTokenDetails', 'cacheReadTokens'),
239
+ cacheWriteTokens: detail(usage, 'inputTokens', 'inputTokenDetails', 'cacheWriteTokens'),
240
+ // NOTE: `performance.responseTimeMs` is available here and is deliberately NOT emitted.
241
+ // `token_usage` has no `duration_ms` in `contract/events.v1.json`, and adding an
242
+ // undeclared field to a type two producers already write into is the precise mechanism by
243
+ // which a shared ledger stops being trustworthy (SCOPE.md §7, D4). Model-call latency
244
+ // arrives when the contract grows a field for it, not before. Passing it into
245
+ // `contract.tokenUsage` today would not even reach the wire — the function does not read
246
+ // it — which is the worse failure: a line of code that looks like coverage and is not.
247
+ // `run_id` when the application is inside a nexus run, absent when it is not. Absent is
248
+ // correct and is not a defect: a model call made outside any run genuinely has no run to
249
+ // belong to, and inventing a synthetic parent would put a run in the ledger that no
250
+ // application ever started.
251
+ runId: run ? run.runId : undefined,
252
+ instrumentation: MODE,
253
+ }));
254
+ }),
255
+
256
+ onToolExecutionEnd: guarded('onToolExecutionEnd', (e) => {
257
+ if (!wantToolActions) return;
258
+ const client = core.ensureClient();
259
+ if (!client) return;
260
+ const call = e.toolCall || {};
261
+ const run = core.currentRun() || ownRuns.get(e.callId) || null;
262
+
263
+ client.emit(core.contract.toolAction(client.sessionId, client.cfg, {
264
+ toolName: call.toolName ? String(call.toolName) : 'unknown',
265
+ action: 'invoke',
266
+ // No `target`: the AI SDK has no notion of one, and deriving it from the tool input would
267
+ // put customer data on the wire through a field whose tier gate assumes a short identifier.
268
+ //
269
+ // `effect` is the right home for it instead, and it now carries the tool's input and
270
+ // output. `contract.toolAction` puts this through `scrubMapping`, so the tier ladder and
271
+ // the key-name rules apply exactly as they do to an `effect()` the application wrote by
272
+ // hand — a tool argument called `api_key` is masked by name, a number under an unrecognised
273
+ // key is masked below `full`, and the walk is bounded in width and depth.
274
+ //
275
+ // Wrapped in `input`/`output` rather than spread, so a tool argument called `rows` cannot
276
+ // collide with a field the contract may grow later, and so a reader can tell what the
277
+ // agent asked for from what it got back.
278
+ // The AI SDK's own per-call privacy flags are honoured on top of our tier. An application
279
+ // that told `ai` not to record inputs has already expressed an intent, and a telemetry SDK
280
+ // that overrides it because its own tier happens to be permissive has substituted its
281
+ // judgement for the caller's.
282
+ //
283
+ // Built by assignment rather than as a literal with `undefined` values: `{ input:
284
+ // undefined }` still HAS the key, so `'input' in effect` stays true and `scrubMapping`
285
+ // writes it back out as `null`. A reader then cannot tell "the application suppressed this"
286
+ // from "the tool was called with no arguments" — which is the same absent-versus-empty
287
+ // distinction the tier ladder is careful about everywhere else.
288
+ effect: toolEffect(call, e),
289
+ durationMs: num(e.toolExecutionMs),
290
+ // Always false from this path, and the constant is the honest value rather than a
291
+ // placeholder: this seam cannot deny, so an action reaching it has already executed.
292
+ // A `blocked: true` could only ever come from the explicit API.
293
+ blocked: false,
294
+ runId: run ? run.runId : undefined,
295
+ }));
296
+ }),
297
+
298
+ onEnd: guarded('onEnd', (e) => {
299
+ const id = e.callId || e.operationId;
300
+ const run = id ? ownRuns.get(id) : null;
301
+ if (!run) return;
302
+ ownRuns.delete(id);
303
+ run.end();
304
+ }),
305
+ };
306
+
307
+ return integration;
308
+ }
309
+
310
+ /**
311
+ * Convenience wrapper: hand it the AI SDK's own `registerTelemetry` and it registers for you.
312
+ *
313
+ * import { registerTelemetry } from 'ai';
314
+ * register(registerTelemetry);
315
+ *
316
+ * Exists because `registerTelemetry(telemetry())` reads like it might have an argument order to get
317
+ * wrong. Returns whether registration happened, so a startup path can log its own state; a
318
+ * non-function argument is a no-op rather than a throw, because a telemetry SDK that crashes an
319
+ * application's boot over its own optional integration has failed at its one job.
320
+ *
321
+ * @param {(integration: object) => unknown} registerTelemetry
322
+ */
323
+ function register(registerTelemetry, opts) {
324
+ return core.guard('ai.register', () => {
325
+ if (typeof registerTelemetry !== 'function') {
326
+ core.incr('ai_register_not_a_function');
327
+ return false;
328
+ }
329
+ registerTelemetry(telemetry(opts));
330
+ return true;
331
+ }, false);
332
+ }
333
+
334
+ module.exports = { telemetry, register, MODE, FIELD_MAP };
package/src/ai.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `@swfte/nexus-sdk/ai` — ESM entry for the Vercel AI SDK bridge.
3
+ *
4
+ * import { registerTelemetry } from 'ai';
5
+ * import * as nexus from '@swfte/nexus-sdk';
6
+ * import { telemetry } from '@swfte/nexus-sdk/ai';
7
+ *
8
+ * nexus.init({ service: 'support-triage', env: 'prod' });
9
+ * registerTelemetry(telemetry());
10
+ *
11
+ * A facade over `./ai.cjs`, for the same reason `src/index.js` is a facade over `src/core.cjs`: the
12
+ * state has to live in exactly one module instance or a process that reaches this package through
13
+ * both `import` and `require` gets two clients, two session ids and two queues. CJS is the format
14
+ * both loaders share, so the implementation lives there and this file re-exports it.
15
+ *
16
+ * Unlike the main entry there is no `NEXUS_ENABLED` short-circuit here, and it is not an omission:
17
+ * `telemetry()` builds a plain object of callbacks that each consult the core, and the core is
18
+ * already switched off at that point — `ensureClient()` returns `null` and every callback becomes
19
+ * a cheap no-op. Adding a second gate would buy nothing and would give a reader two places to
20
+ * check when the answer to "is it off?" has to be unambiguous.
21
+ */
22
+ import bridge from './ai.cjs';
23
+
24
+ /**
25
+ * Build the telemetry integration to hand to the AI SDK's `registerTelemetry`.
26
+ *
27
+ * Observe-only. The seam cannot deny — a throw from `onToolExecutionStart` does not stop the tool
28
+ * and does not reach the application. See the header of `src/ai.cjs` for the measurement.
29
+ */
30
+ export const telemetry = bridge.telemetry;
31
+
32
+ /** Convenience: `register(registerTelemetry)` instead of `registerTelemetry(telemetry())`. */
33
+ export const register = bridge.register;
34
+
35
+ /** The value `instrumentation()` reports once this bridge is built: `"ai-sdk"`. */
36
+ export const MODE = bridge.MODE;
37
+
38
+ /** Where each number is read from on the AI SDK's events. Exported for diagnostics and tests. */
39
+ export const FIELD_MAP = bridge.FIELD_MAP;