@signalvaultio/node 0.4.0 → 0.5.1

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.1";
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.1';
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. */
@@ -42,25 +38,67 @@ function assertNodeEnvironment() {
42
38
  * `http://` URL would expose both before the server could redirect to HTTPS.
43
39
  */
44
40
  function normalizeBaseUrl(raw) {
41
+ // Errors never quote `raw`: it may contain credentials.
45
42
  let url;
46
43
  try {
47
44
  url = new URL(raw);
48
45
  }
49
46
  catch {
50
- throw new Error(`[SignalVault] baseUrl is not a valid URL: ${raw}`);
47
+ throw new Error('[SignalVault] baseUrl is not a valid URL.');
48
+ }
49
+ if (url.username || url.password) {
50
+ throw new Error('[SignalVault] baseUrl must not contain credentials (user:password@). Pass the key as apiKey.');
51
51
  }
52
52
  if (url.protocol === 'http:' && !LOCAL_HOSTS.has(url.hostname)) {
53
53
  throw new Error('[SignalVault] baseUrl must use https:// (plain http:// is only allowed for localhost). ' +
54
54
  'Your API key and prompts would otherwise be sent unencrypted.');
55
55
  }
56
56
  if (url.search || url.hash) {
57
- throw new Error(`[SignalVault] baseUrl must not contain a query or fragment: ${raw}`);
57
+ throw new Error('[SignalVault] baseUrl must not contain a query or fragment.');
58
58
  }
59
59
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
60
60
  throw new Error(`[SignalVault] baseUrl must be an https:// URL, got ${url.protocol}`);
61
61
  }
62
62
  return raw.replace(/\/+$/, '');
63
63
  }
64
+ /**
65
+ * Strips surrounding whitespace and refuses keys that can't go in a header.
66
+ * A key read from a file or a secret often ends in a newline. Sent as is,
67
+ * every request fails before it leaves the process, so with failMode 'open'
68
+ * every call would go through unchecked. Errors never include the key.
69
+ */
70
+ function normalizeApiKey(raw) {
71
+ if (typeof raw !== 'string') {
72
+ throw new TypeError(`[SignalVault] apiKey must be a string, got ${raw === null ? 'null' : typeof raw}.`);
73
+ }
74
+ const key = raw.trim();
75
+ if (!key) {
76
+ throw new Error('[SignalVault] apiKey is empty.');
77
+ }
78
+ if (/[^\x21-\x7e]/.test(key)) {
79
+ throw new Error('[SignalVault] apiKey contains whitespace, control or non-ASCII characters. Check how the key is loaded.');
80
+ }
81
+ return key;
82
+ }
83
+ /**
84
+ * One line describing an error and its causes, for debug output. Never
85
+ * includes `secret`, and never throws.
86
+ */
87
+ function describeError(error, secret) {
88
+ const parts = [];
89
+ let current = error;
90
+ for (let depth = 0; current !== undefined && current !== null && depth < 5; depth++) {
91
+ try {
92
+ parts.push(current instanceof Error ? `${current.name}: ${current.message}` : String(current));
93
+ current = current instanceof Error ? current.cause : undefined;
94
+ }
95
+ catch {
96
+ parts.push('<unprintable error>');
97
+ break;
98
+ }
99
+ }
100
+ return parts.join(' <- ').split(secret).join('[redacted]');
101
+ }
64
102
  /** Thrown when a guardrail rule blocks the request. */
65
103
  class SignalVaultBlockedError extends Error {
66
104
  constructor(requestId, violations, dashboardUrl) {
@@ -134,6 +172,76 @@ function parseRetryAfterMs(response) {
134
172
  return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
135
173
  }
136
174
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
175
+ /**
176
+ * The provider SDKs are optional peers, loaded only when their API key is
177
+ * configured, so installing just the one you use is enough. The requires are
178
+ * literal strings inside try blocks on purpose: bundlers can only follow a
179
+ * literal require, and treat one inside a try as optional.
180
+ */
181
+ function loadOpenAI(apiKey) {
182
+ let mod;
183
+ try {
184
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
185
+ mod = require('openai');
186
+ }
187
+ catch (error) {
188
+ throw notInstalled(error, 'openai');
189
+ }
190
+ return construct(mod, apiKey);
191
+ }
192
+ function loadAnthropic(apiKey) {
193
+ let mod;
194
+ try {
195
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
196
+ mod = require('@anthropic-ai/sdk');
197
+ }
198
+ catch (error) {
199
+ throw notInstalled(error, '@anthropic-ai/sdk');
200
+ }
201
+ return construct(mod, apiKey);
202
+ }
203
+ function construct(mod, apiKey) {
204
+ const Client = mod.default ?? mod;
205
+ return new Client({ apiKey });
206
+ }
207
+ /**
208
+ * Only a missing package is reported as "not installed"; anything else (a
209
+ * broken install, a missing transitive dependency) is returned unchanged.
210
+ */
211
+ function notInstalled(error, pkg) {
212
+ // Read fields directly: `instanceof Error` fails across realms (e.g. under Jest).
213
+ const { code, message } = (error ?? {});
214
+ if (code === 'MODULE_NOT_FOUND' && String(message).includes(`'${pkg}'`)) {
215
+ return new Error(`[SignalVault] ${pkg} is not installed. Run: npm install ${pkg}`);
216
+ }
217
+ return error;
218
+ }
219
+ /**
220
+ * Asks OpenAI to report token usage on a stream, unless the caller already
221
+ * decided either way. OpenAI then sends one extra final chunk with an empty
222
+ * `choices` array; `injected` tells the stream wrapper to consume that chunk
223
+ * instead of passing it to a caller who never asked for it.
224
+ *
225
+ * `stream_options: null` opts out entirely: the key is removed, for
226
+ * OpenAI-compatible servers that reject `stream_options`. (The OpenAI API
227
+ * treats null and absent the same.)
228
+ */
229
+ function withStreamUsage(params) {
230
+ if (!params.stream)
231
+ return { params, injected: false };
232
+ const options = params.stream_options;
233
+ if (options === null) {
234
+ const { stream_options: _omitted, ...rest } = params;
235
+ return { params: rest, injected: false };
236
+ }
237
+ if (options && options.include_usage !== undefined) {
238
+ return { params, injected: false };
239
+ }
240
+ return {
241
+ params: { ...params, stream_options: { ...(options ?? {}), include_usage: true } },
242
+ injected: true,
243
+ };
244
+ }
137
245
  // ---------------------------------------------------------------------------
138
246
  // SignalVaultClient
139
247
  // ---------------------------------------------------------------------------
@@ -170,7 +278,7 @@ class SignalVaultClient {
170
278
  if (config.failMode !== undefined && config.failMode !== 'open' && config.failMode !== 'closed') {
171
279
  throw new Error("[SignalVault] failMode must be 'open' or 'closed'.");
172
280
  }
173
- this.svApiKey = config.apiKey;
281
+ this.svApiKey = normalizeApiKey(config.apiKey);
174
282
  this.svBaseUrl = normalizeBaseUrl(config.baseUrl || DEFAULT_BASE_URL);
175
283
  this.svEnvironment = config.environment || 'production';
176
284
  this.debugMode = config.debug || false;
@@ -179,23 +287,8 @@ class SignalVaultClient {
179
287
  this.preflightTimeout = config.preflightTimeout ?? 2000;
180
288
  this.bgTimeout = config.timeout ?? 10000;
181
289
  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
- }
290
+ this.openai = config.openaiApiKey ? loadOpenAI(config.openaiApiKey) : null;
291
+ this.anthropic = config.anthropicApiKey ? loadAnthropic(config.anthropicApiKey) : null;
199
292
  }
200
293
  // ---------------------------------------------------------------------------
201
294
  // Lifecycle
@@ -231,8 +324,9 @@ class SignalVaultClient {
231
324
  track(promise) {
232
325
  const tracked = promise
233
326
  .catch((err) => {
234
- if (this.debugMode)
235
- console.error('[SignalVault] Background event failed:', err);
327
+ if (this.debugMode) {
328
+ console.error('[SignalVault] Background event failed:', describeError(err, this.svApiKey));
329
+ }
236
330
  })
237
331
  .finally(() => this.pending.delete(tracked));
238
332
  this.pending.add(tracked);
@@ -406,11 +500,12 @@ class SignalVaultClient {
406
500
  // OpenAI — mirror mode
407
501
  // ---------------------------------------------------------------------------
408
502
  async handleOpenAIMirror(requestId, params, metadata) {
409
- const response = await this.openai.chat.completions.create(params);
503
+ const usage = withStreamUsage(params);
504
+ const response = await this.openai.chat.completions.create(usage.params);
410
505
  const model = params.model;
411
506
  const messages = params.messages;
412
507
  if (params.stream) {
413
- return this.wrapStream(response, (output, promptTokens, completionTokens) => this.sendAuditEvents(requestId, model, messages, output, promptTokens, completionTokens, metadata, 'openai'));
508
+ return this.wrapStream(response, usage.injected, (output, promptTokens, completionTokens) => this.sendAuditEvents(requestId, model, messages, output, promptTokens, completionTokens, metadata, 'openai'));
414
509
  }
415
510
  const completion = response;
416
511
  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 +519,10 @@ class SignalVaultClient {
424
519
  const messages = params.messages;
425
520
  const decision = await this.enforce(requestId, model, messages, metadata, 'openai');
426
521
  const fallback = this.fallbackRequest(decision, requestId, model, messages, metadata, 'openai');
427
- const response = await this.openai.chat.completions.create(params);
522
+ const usage = withStreamUsage(params);
523
+ const response = await this.openai.chat.completions.create(usage.params);
428
524
  if (params.stream) {
429
- return this.wrapStream(response, (output, promptTokens, completionTokens) => this.sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, 'openai', fallback));
525
+ return this.wrapStream(response, usage.injected, (output, promptTokens, completionTokens) => this.sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, 'openai', fallback));
430
526
  }
431
527
  const completion = response;
432
528
  // Background: the audit of the response must not delay returning it.
@@ -465,9 +561,11 @@ class SignalVaultClient {
465
561
  /**
466
562
  * Yields chunks through unchanged and records the response when the stream
467
563
  * ends — including when the consumer breaks out early or the stream throws,
468
- * in which case the partial output is recorded.
564
+ * in which case the partial output is recorded. When the SDK added
565
+ * `include_usage` itself (`hideUsageChunk`), the usage-only final chunk is
566
+ * consumed here rather than passed on.
469
567
  */
470
- async *wrapStream(stream, onComplete) {
568
+ async *wrapStream(stream, hideUsageChunk, onComplete) {
471
569
  const chunks = [];
472
570
  let promptTokens = 0;
473
571
  let completionTokens = 0;
@@ -479,6 +577,9 @@ class SignalVaultClient {
479
577
  if (chunk.usage) {
480
578
  promptTokens = chunk.usage.prompt_tokens || 0;
481
579
  completionTokens = chunk.usage.completion_tokens || 0;
580
+ // Missing or empty `choices`: the usage-only chunk the caller didn't ask for.
581
+ if (hideUsageChunk && !chunk.choices?.length)
582
+ continue;
482
583
  }
483
584
  yield chunk;
484
585
  }
@@ -641,8 +742,9 @@ class SignalVaultClient {
641
742
  }
642
743
  }
643
744
  catch (error) {
644
- if (this.debugMode)
645
- console.error('[SignalVault] Event send failed:', error);
745
+ if (this.debugMode) {
746
+ console.error('[SignalVault] Event send failed:', describeError(error, this.svApiKey));
747
+ }
646
748
  if (!isTimeout(error))
647
749
  retryDelayMs = 250 + Math.random() * 500;
648
750
  }
@@ -654,8 +756,9 @@ class SignalVaultClient {
654
756
  return result;
655
757
  }
656
758
  catch (error) {
657
- if (this.debugMode)
658
- console.error('[SignalVault] Event retry failed:', error);
759
+ if (this.debugMode) {
760
+ console.error('[SignalVault] Event retry failed:', describeError(error, this.svApiKey));
761
+ }
659
762
  }
660
763
  }
661
764
  this.logUndelivered(result);
@@ -676,8 +779,9 @@ class SignalVaultClient {
676
779
  }
677
780
  catch (error) {
678
781
  const timedOut = isTimeout(error);
679
- if (this.debugMode)
680
- console.error('[SignalVault] Pre-flight failed:', error);
782
+ if (this.debugMode) {
783
+ console.error('[SignalVault] Pre-flight failed:', describeError(error, this.svApiKey));
784
+ }
681
785
  return this.unavailable(requestId, timedOut ? 'timeout' : 'network', timedOut
682
786
  ? `SignalVault pre-flight check timed out after ${this.preflightTimeout}ms`
683
787
  : 'SignalVault API is unreachable');
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.1",
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": {