@signalvaultio/node 0.4.0 → 0.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.
package/README.md CHANGED
@@ -20,6 +20,18 @@ npm install @signalvaultio/node @anthropic-ai/sdk
20
20
  npm install @signalvaultio/node openai @anthropic-ai/sdk
21
21
  ```
22
22
 
23
+ Supported provider SDKs: `openai` 4.4 through 7.x (openai 7 needs Node.js 22+) and `@anthropic-ai/sdk` 0.20 or later. Install only the one you use; the other is optional. TypeScript projects without `openai` need `skipLibCheck: true` (the default in most setups), because the SDK's type declarations refer to openai's types.
24
+
25
+ The package works with both `import` and `require()`:
26
+
27
+ ```typescript
28
+ import SignalVaultClient from '@signalvaultio/node'; // ESM, TypeScript
29
+ import { SignalVaultClient } from '@signalvaultio/node'; // also works
30
+ const { SignalVaultClient } = require('@signalvaultio/node'); // CommonJS
31
+ ```
32
+
33
+ Server-side bundlers (webpack, esbuild) are supported. esbuild's ESM output needs its usual `createRequire` banner for CommonJS dependencies: `--banner:js="import { createRequire } from 'module'; const require = createRequire(import.meta.url);"`.
34
+
23
35
  ## Quick Start — OpenAI
24
36
 
25
37
  ```typescript
@@ -28,8 +40,6 @@ import SignalVaultClient from '@signalvaultio/node';
28
40
  const client = new SignalVaultClient({
29
41
  apiKey: 'sk_live_your_signalvault_key',
30
42
  openaiApiKey: process.env.OPENAI_API_KEY!,
31
- baseUrl: 'https://api.signalvault.io',
32
- environment: 'production',
33
43
  });
34
44
 
35
45
  // Use exactly like OpenAI SDK
@@ -91,7 +101,7 @@ for await (const event of stream) {
91
101
  }
92
102
  ```
93
103
 
94
- For token counts on OpenAI streams, pass `stream_options: { include_usage: true }`. The SDK does not add it for you, because OpenAI then sends a final chunk with an empty `choices` array.
104
+ For OpenAI streams, the SDK sets `stream_options: { include_usage: true }` so the response is logged with its token counts. OpenAI then sends one extra final chunk that carries only the usage and no choices; the SDK consumes that chunk rather than passing it to you, so the stream you receive looks the same as without usage reporting. If you set `stream_options.include_usage` yourself, your setting is kept: with `true` you receive the usage chunk as usual, with `false` the response is logged without token counts. If your OpenAI-compatible server rejects `stream_options`, pass `stream_options: null` and the SDK sends none.
95
105
 
96
106
  ## Agent Tool-Use Capture
97
107
 
@@ -251,8 +261,20 @@ const client = new SignalVaultClient({
251
261
  });
252
262
  ```
253
263
 
264
+ `environment` does not choose which rules apply: SignalVault uses the environment of the API key you pass as `apiKey`. Use a key created for the environment you want.
265
+
254
266
  ## Error Handling
255
267
 
268
+ When a guardrail rule blocks a request, `create()` throws `SignalVaultBlockedError` and the provider is never called. It carries:
269
+
270
+ - `violations` — the rules that matched, each with `rule_id`, `type` (for example `contains_secret`), `severity`, `action` and `details`
271
+ - `requestId` — the ID the SDK sent to SignalVault as `request_id` for this request
272
+ - `dashboardUrl` — a link to the blocked request, when the API provides one
273
+
274
+ The message lists only the violation types, never the matched content.
275
+
276
+ `SignalVaultUnavailableError` (with `requestId` and the HTTP `status`, if any) is thrown only with `failMode: 'closed'`, when no decision could be obtained.
277
+
256
278
  ```typescript
257
279
  import { SignalVaultBlockedError, SignalVaultUnavailableError } from '@signalvaultio/node';
258
280
 
@@ -0,0 +1,4 @@
1
+ // Types for the ESM entry point (see index.mjs).
2
+ export * from './index.js';
3
+ import { SignalVaultClient } from './index.js';
4
+ export default SignalVaultClient;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import OpenAI from 'openai';
1
+ import type OpenAI from 'openai';
2
2
  import { ToolsAPI } from './tools';
3
3
  export type { ToolRecordOptions, ToolsAPI } from './tools';
4
4
  /** Kept in sync with package.json by a unit test. */
5
- export declare const SDK_VERSION = "0.4.0";
5
+ export declare const SDK_VERSION = "0.5.0";
6
6
  /**
7
7
  * Normalizes the base URL and refuses plaintext HTTP to anything but the local
8
8
  * machine. The API key and full prompts travel in every request, so an
@@ -209,7 +209,9 @@ export declare class SignalVaultClient {
209
209
  /**
210
210
  * Yields chunks through unchanged and records the response when the stream
211
211
  * ends — including when the consumer breaks out early or the stream throws,
212
- * in which case the partial output is recorded.
212
+ * in which case the partial output is recorded. When the SDK added
213
+ * `include_usage` itself (`hideUsageChunk`), the usage-only final chunk is
214
+ * consumed here rather than passed on.
213
215
  */
214
216
  private wrapStream;
215
217
  private wrapAnthropicStream;
package/dist/index.js CHANGED
@@ -1,15 +1,11 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.SignalVaultClient = exports.SignalVaultUnavailableError = exports.SignalVaultBlockedError = exports.SDK_VERSION = void 0;
7
4
  exports.normalizeBaseUrl = normalizeBaseUrl;
8
5
  const crypto_1 = require("crypto");
9
- const openai_1 = __importDefault(require("openai"));
10
6
  const tools_1 = require("./tools");
11
7
  /** Kept in sync with package.json by a unit test. */
12
- exports.SDK_VERSION = '0.4.0';
8
+ exports.SDK_VERSION = '0.5.0';
13
9
  const DEFAULT_BASE_URL = 'https://api.signalvault.io';
14
10
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
15
11
  /** Longest Retry-After the SDK will wait out before retrying a background event once. */
@@ -134,6 +130,76 @@ function parseRetryAfterMs(response) {
134
130
  return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
135
131
  }
136
132
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
133
+ /**
134
+ * The provider SDKs are optional peers, loaded only when their API key is
135
+ * configured, so installing just the one you use is enough. The requires are
136
+ * literal strings inside try blocks on purpose: bundlers can only follow a
137
+ * literal require, and treat one inside a try as optional.
138
+ */
139
+ function loadOpenAI(apiKey) {
140
+ let mod;
141
+ try {
142
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
143
+ mod = require('openai');
144
+ }
145
+ catch (error) {
146
+ throw notInstalled(error, 'openai');
147
+ }
148
+ return construct(mod, apiKey);
149
+ }
150
+ function loadAnthropic(apiKey) {
151
+ let mod;
152
+ try {
153
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
154
+ mod = require('@anthropic-ai/sdk');
155
+ }
156
+ catch (error) {
157
+ throw notInstalled(error, '@anthropic-ai/sdk');
158
+ }
159
+ return construct(mod, apiKey);
160
+ }
161
+ function construct(mod, apiKey) {
162
+ const Client = mod.default ?? mod;
163
+ return new Client({ apiKey });
164
+ }
165
+ /**
166
+ * Only a missing package is reported as "not installed"; anything else (a
167
+ * broken install, a missing transitive dependency) is returned unchanged.
168
+ */
169
+ function notInstalled(error, pkg) {
170
+ // Read fields directly: `instanceof Error` fails across realms (e.g. under Jest).
171
+ const { code, message } = (error ?? {});
172
+ if (code === 'MODULE_NOT_FOUND' && String(message).includes(`'${pkg}'`)) {
173
+ return new Error(`[SignalVault] ${pkg} is not installed. Run: npm install ${pkg}`);
174
+ }
175
+ return error;
176
+ }
177
+ /**
178
+ * Asks OpenAI to report token usage on a stream, unless the caller already
179
+ * decided either way. OpenAI then sends one extra final chunk with an empty
180
+ * `choices` array; `injected` tells the stream wrapper to consume that chunk
181
+ * instead of passing it to a caller who never asked for it.
182
+ *
183
+ * `stream_options: null` opts out entirely: the key is removed, for
184
+ * OpenAI-compatible servers that reject `stream_options`. (The OpenAI API
185
+ * treats null and absent the same.)
186
+ */
187
+ function withStreamUsage(params) {
188
+ if (!params.stream)
189
+ return { params, injected: false };
190
+ const options = params.stream_options;
191
+ if (options === null) {
192
+ const { stream_options: _omitted, ...rest } = params;
193
+ return { params: rest, injected: false };
194
+ }
195
+ if (options && options.include_usage !== undefined) {
196
+ return { params, injected: false };
197
+ }
198
+ return {
199
+ params: { ...params, stream_options: { ...(options ?? {}), include_usage: true } },
200
+ injected: true,
201
+ };
202
+ }
137
203
  // ---------------------------------------------------------------------------
138
204
  // SignalVaultClient
139
205
  // ---------------------------------------------------------------------------
@@ -179,23 +245,8 @@ class SignalVaultClient {
179
245
  this.preflightTimeout = config.preflightTimeout ?? 2000;
180
246
  this.bgTimeout = config.timeout ?? 10000;
181
247
  this.defaultMetadata = config.metadata ?? {};
182
- this.openai = config.openaiApiKey
183
- ? new openai_1.default({ apiKey: config.openaiApiKey })
184
- : null;
185
- if (config.anthropicApiKey) {
186
- try {
187
- // eslint-disable-next-line @typescript-eslint/no-var-requires
188
- const mod = require('@anthropic-ai/sdk');
189
- const Anthropic = mod.default ?? mod;
190
- this.anthropic = new Anthropic({ apiKey: config.anthropicApiKey });
191
- }
192
- catch {
193
- throw new Error('[SignalVault] @anthropic-ai/sdk is not installed. Run: npm install @anthropic-ai/sdk');
194
- }
195
- }
196
- else {
197
- this.anthropic = null;
198
- }
248
+ this.openai = config.openaiApiKey ? loadOpenAI(config.openaiApiKey) : null;
249
+ this.anthropic = config.anthropicApiKey ? loadAnthropic(config.anthropicApiKey) : null;
199
250
  }
200
251
  // ---------------------------------------------------------------------------
201
252
  // Lifecycle
@@ -406,11 +457,12 @@ class SignalVaultClient {
406
457
  // OpenAI — mirror mode
407
458
  // ---------------------------------------------------------------------------
408
459
  async handleOpenAIMirror(requestId, params, metadata) {
409
- const response = await this.openai.chat.completions.create(params);
460
+ const usage = withStreamUsage(params);
461
+ const response = await this.openai.chat.completions.create(usage.params);
410
462
  const model = params.model;
411
463
  const messages = params.messages;
412
464
  if (params.stream) {
413
- return this.wrapStream(response, (output, promptTokens, completionTokens) => this.sendAuditEvents(requestId, model, messages, output, promptTokens, completionTokens, metadata, 'openai'));
465
+ return this.wrapStream(response, usage.injected, (output, promptTokens, completionTokens) => this.sendAuditEvents(requestId, model, messages, output, promptTokens, completionTokens, metadata, 'openai'));
414
466
  }
415
467
  const completion = response;
416
468
  this.track(this.sendAuditEvents(requestId, model, messages, completion.choices[0]?.message?.content || '', completion.usage?.prompt_tokens || 0, completion.usage?.completion_tokens || 0, metadata, 'openai'));
@@ -424,9 +476,10 @@ class SignalVaultClient {
424
476
  const messages = params.messages;
425
477
  const decision = await this.enforce(requestId, model, messages, metadata, 'openai');
426
478
  const fallback = this.fallbackRequest(decision, requestId, model, messages, metadata, 'openai');
427
- const response = await this.openai.chat.completions.create(params);
479
+ const usage = withStreamUsage(params);
480
+ const response = await this.openai.chat.completions.create(usage.params);
428
481
  if (params.stream) {
429
- return this.wrapStream(response, (output, promptTokens, completionTokens) => this.sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, 'openai', fallback));
482
+ return this.wrapStream(response, usage.injected, (output, promptTokens, completionTokens) => this.sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, 'openai', fallback));
430
483
  }
431
484
  const completion = response;
432
485
  // Background: the audit of the response must not delay returning it.
@@ -465,9 +518,11 @@ class SignalVaultClient {
465
518
  /**
466
519
  * Yields chunks through unchanged and records the response when the stream
467
520
  * ends — including when the consumer breaks out early or the stream throws,
468
- * in which case the partial output is recorded.
521
+ * in which case the partial output is recorded. When the SDK added
522
+ * `include_usage` itself (`hideUsageChunk`), the usage-only final chunk is
523
+ * consumed here rather than passed on.
469
524
  */
470
- async *wrapStream(stream, onComplete) {
525
+ async *wrapStream(stream, hideUsageChunk, onComplete) {
471
526
  const chunks = [];
472
527
  let promptTokens = 0;
473
528
  let completionTokens = 0;
@@ -479,6 +534,9 @@ class SignalVaultClient {
479
534
  if (chunk.usage) {
480
535
  promptTokens = chunk.usage.prompt_tokens || 0;
481
536
  completionTokens = chunk.usage.completion_tokens || 0;
537
+ // Missing or empty `choices`: the usage-only chunk the caller didn't ask for.
538
+ if (hideUsageChunk && !chunk.choices?.length)
539
+ continue;
482
540
  }
483
541
  yield chunk;
484
542
  }
package/dist/index.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // ESM entry point. Re-exports the CommonJS build rather than shipping a
2
+ // second copy of the SDK: two copies would each hold their own module state
3
+ // (the AsyncLocalStorage behind withContext), so context set through `import`
4
+ // would be invisible to code loaded through `require`. Named imports from
5
+ // CommonJS keep default-export interop out of the picture for Node and
6
+ // bundlers alike. Keep this list in sync with src/index.ts — the package test
7
+ // fails if they differ.
8
+ export {
9
+ SDK_VERSION,
10
+ SignalVaultBlockedError,
11
+ SignalVaultClient,
12
+ SignalVaultUnavailableError,
13
+ normalizeBaseUrl,
14
+ } from './index.js';
15
+ import { SignalVaultClient } from './index.js';
16
+ export default SignalVaultClient;
package/package.json CHANGED
@@ -1,16 +1,30 @@
1
1
  {
2
2
  "name": "@signalvaultio/node",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "SignalVault SDK - AI audit logs and guardrails for safe production AI",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
5
+ "type": "commonjs",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/index.d.mts",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
7
21
  "files": [
8
22
  "dist",
9
23
  "!dist/__tests__",
10
24
  "README.md"
11
25
  ],
12
26
  "scripts": {
13
- "build": "tsc",
27
+ "build": "node scripts/build.mjs",
14
28
  "prepublishOnly": "npm run build",
15
29
  "test": "jest --passWithNoTests"
16
30
  },
@@ -41,7 +55,7 @@
41
55
  "homepage": "https://signalvault.io/docs",
42
56
  "peerDependencies": {
43
57
  "@anthropic-ai/sdk": ">=0.20.0",
44
- "openai": "^4.0.0"
58
+ "openai": "^4.4.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
45
59
  },
46
60
  "peerDependenciesMeta": {
47
61
  "openai": {