@signalvaultio/node 0.2.0 → 0.4.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/dist/index.js CHANGED
@@ -3,9 +3,137 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SignalVaultClient = void 0;
6
+ exports.SignalVaultClient = exports.SignalVaultUnavailableError = exports.SignalVaultBlockedError = exports.SDK_VERSION = void 0;
7
+ exports.normalizeBaseUrl = normalizeBaseUrl;
8
+ const crypto_1 = require("crypto");
7
9
  const openai_1 = __importDefault(require("openai"));
8
- const uuid_1 = require("uuid");
10
+ const tools_1 = require("./tools");
11
+ /** Kept in sync with package.json by a unit test. */
12
+ exports.SDK_VERSION = '0.4.0';
13
+ const DEFAULT_BASE_URL = 'https://api.signalvault.io';
14
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
15
+ /** Longest Retry-After the SDK will wait out before retrying a background event once. */
16
+ const MAX_RETRY_AFTER_MS = 5000;
17
+ /** A repeated warning is printed at most once per this window. */
18
+ const WARN_INTERVAL_MS = 60000;
19
+ // ---------------------------------------------------------------------------
20
+ // Environment guard
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Hard-fails if the SDK is loaded in a browser context. The SDK relies on
24
+ * `async_hooks` (Node-only) and is intended for server-side use; bundling it
25
+ * for the browser would expose `apiKey`, `openaiApiKey`, and `anthropicApiKey`
26
+ * to end users — which is exactly the kind of leak this product is meant to
27
+ * prevent.
28
+ */
29
+ function assertNodeEnvironment() {
30
+ // `process` exists on Node and not in plain browsers. `window` is the
31
+ // canonical browser global. We refuse to run if we look browser-like.
32
+ const hasProcess = typeof process !== 'undefined' && process?.versions?.node;
33
+ const hasWindow = typeof globalThis.window !== 'undefined';
34
+ if (!hasProcess || hasWindow) {
35
+ throw new Error('[SignalVault] This SDK is server-side only. Do not bundle it for browser use — ' +
36
+ 'it will leak your API keys to end users. Call SignalVault from your backend instead.');
37
+ }
38
+ }
39
+ /**
40
+ * Normalizes the base URL and refuses plaintext HTTP to anything but the local
41
+ * machine. The API key and full prompts travel in every request, so an
42
+ * `http://` URL would expose both before the server could redirect to HTTPS.
43
+ */
44
+ function normalizeBaseUrl(raw) {
45
+ let url;
46
+ try {
47
+ url = new URL(raw);
48
+ }
49
+ catch {
50
+ throw new Error(`[SignalVault] baseUrl is not a valid URL: ${raw}`);
51
+ }
52
+ if (url.protocol === 'http:' && !LOCAL_HOSTS.has(url.hostname)) {
53
+ throw new Error('[SignalVault] baseUrl must use https:// (plain http:// is only allowed for localhost). ' +
54
+ 'Your API key and prompts would otherwise be sent unencrypted.');
55
+ }
56
+ if (url.search || url.hash) {
57
+ throw new Error(`[SignalVault] baseUrl must not contain a query or fragment: ${raw}`);
58
+ }
59
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
60
+ throw new Error(`[SignalVault] baseUrl must be an https:// URL, got ${url.protocol}`);
61
+ }
62
+ return raw.replace(/\/+$/, '');
63
+ }
64
+ /** Thrown when a guardrail rule blocks the request. */
65
+ class SignalVaultBlockedError extends Error {
66
+ constructor(requestId, violations, dashboardUrl) {
67
+ const types = [...new Set(violations.map((v) => String(v?.type ?? '')).filter(Boolean))].join(', ') || 'policy';
68
+ super(`[SignalVault] Request blocked by guardrails (${types}).`);
69
+ this.name = 'SignalVaultBlockedError';
70
+ this.requestId = requestId;
71
+ this.violations = violations;
72
+ this.dashboardUrl = dashboardUrl;
73
+ }
74
+ }
75
+ exports.SignalVaultBlockedError = SignalVaultBlockedError;
76
+ /** Thrown in `failMode: 'closed'` when no guardrail decision could be obtained. */
77
+ class SignalVaultUnavailableError extends Error {
78
+ constructor(requestId, reason, status) {
79
+ super(`[SignalVault] Guardrail check unavailable: ${reason}. Request not sent (failMode: 'closed').`);
80
+ this.name = 'SignalVaultUnavailableError';
81
+ this.requestId = requestId;
82
+ this.status = status;
83
+ }
84
+ }
85
+ exports.SignalVaultUnavailableError = SignalVaultUnavailableError;
86
+ const ALLOW = { decision: 'allow', violations: [], redactions: [] };
87
+ /**
88
+ * Set on the decision returned when no pre-flight decision was obtained.
89
+ * 'record': the server may not have stored the request (network, timeout,
90
+ * 5xx, invalid response), so it is recorded in the background and the
91
+ * unchecked call still appears in the audit log. 'skip': the server refused
92
+ * it (401/402/403/429/3xx), so a response event would be refused too.
93
+ */
94
+ const PREFLIGHT_FAILED = Symbol('preflightFailed');
95
+ /** Causes where re-sending the ai.request later can succeed. */
96
+ const RECORDABLE_CAUSES = new Set(['timeout', 'network', '5xx', 'invalid-response']);
97
+ const DECISIONS = new Set(['allow', 'warn', 'block', 'redact']);
98
+ /**
99
+ * Checks the name rather than `instanceof Error`: fetch rejects with a
100
+ * DOMException, which fails `instanceof` across realms (e.g. under Jest).
101
+ */
102
+ function isTimeout(error) {
103
+ const name = error?.name;
104
+ return name === 'TimeoutError' || name === 'AbortError';
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Payload helpers
108
+ // ---------------------------------------------------------------------------
109
+ /**
110
+ * Anthropic takes the system prompt as a top-level `system` param. The
111
+ * server scans `payload.messages`, so it is included there as a system
112
+ * message — otherwise secrets or PII in the system prompt go unchecked.
113
+ */
114
+ function anthropicMessagesForAudit(params) {
115
+ const messages = Array.isArray(params?.messages) ? params.messages : [];
116
+ if (params?.system === undefined || params?.system === null || params?.system === '') {
117
+ return messages;
118
+ }
119
+ return [{ role: 'system', content: params.system }, ...messages];
120
+ }
121
+ /** Concatenates every text block; `content[0]` alone misses text after a tool_use block. */
122
+ function anthropicOutputText(response) {
123
+ const blocks = Array.isArray(response?.content) ? response.content : [];
124
+ return blocks
125
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
126
+ .map((b) => b.text)
127
+ .join('');
128
+ }
129
+ function parseRetryAfterMs(response) {
130
+ const raw = response?.headers?.get?.('retry-after');
131
+ if (!raw)
132
+ return null;
133
+ const seconds = Number(raw);
134
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
135
+ }
136
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
137
  // ---------------------------------------------------------------------------
10
138
  // SignalVaultClient
11
139
  // ---------------------------------------------------------------------------
@@ -17,10 +145,9 @@ const uuid_1 = require("uuid");
17
145
  * const client = new SignalVaultClient({
18
146
  * apiKey: 'sk_live_...',
19
147
  * openaiApiKey: process.env.OPENAI_API_KEY!,
20
- * baseUrl: 'https://api.signalvault.io',
21
148
  * metadata: { user_id: '123' },
22
149
  * });
23
- * const res = await client.chat.completions.create({ model: 'gpt-4', messages: [...] });
150
+ * const res = await client.chat.completions.create({ model: 'gpt-4o', messages: [...] });
24
151
  * ```
25
152
  *
26
153
  * @example Anthropic
@@ -28,21 +155,27 @@ const uuid_1 = require("uuid");
28
155
  * const client = new SignalVaultClient({
29
156
  * apiKey: 'sk_live_...',
30
157
  * anthropicApiKey: process.env.ANTHROPIC_API_KEY!,
31
- * baseUrl: 'https://api.signalvault.io',
32
158
  * });
33
- * const res = await client.messages.create({ model: 'claude-3-5-sonnet-20241022', messages: [...], max_tokens: 1024 });
159
+ * const res = await client.messages.create({ model: 'claude-sonnet-4-5', messages: [...], max_tokens: 1024 });
34
160
  * ```
35
161
  */
36
162
  class SignalVaultClient {
37
163
  constructor(config) {
164
+ this.pending = new Set();
165
+ this.lastWarned = new Map();
166
+ assertNodeEnvironment();
38
167
  if (!config.openaiApiKey && !config.anthropicApiKey) {
39
168
  throw new Error('[SignalVault] At least one of openaiApiKey or anthropicApiKey is required.');
40
169
  }
170
+ if (config.failMode !== undefined && config.failMode !== 'open' && config.failMode !== 'closed') {
171
+ throw new Error("[SignalVault] failMode must be 'open' or 'closed'.");
172
+ }
41
173
  this.svApiKey = config.apiKey;
42
- this.svBaseUrl = (config.baseUrl || 'http://localhost:4000').replace(/\/+$/, '');
174
+ this.svBaseUrl = normalizeBaseUrl(config.baseUrl || DEFAULT_BASE_URL);
43
175
  this.svEnvironment = config.environment || 'production';
44
176
  this.debugMode = config.debug || false;
45
177
  this.mirrorMode = config.mirrorMode || false;
178
+ this.failMode = config.failMode ?? 'open';
46
179
  this.preflightTimeout = config.preflightTimeout ?? 2000;
47
180
  this.bgTimeout = config.timeout ?? 10000;
48
181
  this.defaultMetadata = config.metadata ?? {};
@@ -65,6 +198,165 @@ class SignalVaultClient {
65
198
  }
66
199
  }
67
200
  // ---------------------------------------------------------------------------
201
+ // Lifecycle
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Waits for in-flight audit events to finish sending, up to `timeoutMs`.
205
+ * Call before a short-lived process exits (scripts, serverless handlers),
206
+ * otherwise queued events are lost.
207
+ */
208
+ async flush(timeoutMs = 5000) {
209
+ const deadline = Date.now() + timeoutMs;
210
+ while (this.pending.size > 0) {
211
+ const remaining = deadline - Date.now();
212
+ if (remaining <= 0) {
213
+ this.warn('flush-timeout', `[SignalVault] flush() timed out with ${this.pending.size} event(s) still sending.`);
214
+ return;
215
+ }
216
+ let timer;
217
+ await Promise.race([
218
+ Promise.allSettled([...this.pending]),
219
+ new Promise((resolve) => {
220
+ timer = setTimeout(resolve, remaining);
221
+ }),
222
+ ]);
223
+ clearTimeout(timer);
224
+ }
225
+ }
226
+ /** Flushes pending events. The client can still be used afterwards. */
227
+ async close(timeoutMs = 5000) {
228
+ await this.flush(timeoutMs);
229
+ }
230
+ /** Tracks a background promise so flush() can wait for it. Never rejects. */
231
+ track(promise) {
232
+ const tracked = promise
233
+ .catch((err) => {
234
+ if (this.debugMode)
235
+ console.error('[SignalVault] Background event failed:', err);
236
+ })
237
+ .finally(() => this.pending.delete(tracked));
238
+ this.pending.add(tracked);
239
+ }
240
+ /** Prints a warning, at most once per minute per key. Not gated on debug. */
241
+ warn(key, message) {
242
+ const now = Date.now();
243
+ const last = this.lastWarned.get(key);
244
+ if (last !== undefined && now - last < WARN_INTERVAL_MS)
245
+ return;
246
+ this.lastWarned.set(key, now);
247
+ console.warn(message);
248
+ }
249
+ // ---------------------------------------------------------------------------
250
+ // Agent tool-use capture
251
+ // ---------------------------------------------------------------------------
252
+ /**
253
+ * Wraps a function so every call records an `agent.tool_call` event with
254
+ * timing, input, output, and any error. The wrapper auto-times, runs the
255
+ * inner function, fires the audit event in the background, and returns the
256
+ * inner result (or rethrows the inner error).
257
+ *
258
+ * Tool calls invoked inside a `withContext({ requestId })` block auto-pick
259
+ * up the requestId for correlation with the parent ai.request.
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * const fetchWeather = signalvault.tool('fetch_weather', async (city: string) => {
264
+ * const res = await fetch(`https://api.example.com/weather?city=${city}`);
265
+ * return res.json();
266
+ * });
267
+ *
268
+ * const weather = await fetchWeather('London');
269
+ * ```
270
+ */
271
+ tool(name, fn, options) {
272
+ return (0, tools_1.wrapTool)({
273
+ recordFn: (opts) => {
274
+ const promise = this.sendToolCallEvent(opts);
275
+ this.track(promise);
276
+ return promise;
277
+ },
278
+ debug: this.debugMode,
279
+ }, name, fn, options);
280
+ }
281
+ /**
282
+ * Manual tool recording API. Use when the wrapper isn't a fit (streaming
283
+ * tools, custom timing, post-hoc capture from logs).
284
+ *
285
+ * @example
286
+ * ```typescript
287
+ * await signalvault.tools.record({
288
+ * toolName: 'fetch_weather',
289
+ * toolInput: { city: 'London' },
290
+ * toolOutput: { temp: 12.3 },
291
+ * durationMs: 142,
292
+ * });
293
+ * ```
294
+ */
295
+ get tools() {
296
+ return {
297
+ record: (opts) => this.sendToolCallEvent(opts, false),
298
+ };
299
+ }
300
+ /**
301
+ * Runs the given async function inside a context where tool calls are
302
+ * automatically correlated to the provided `requestId`. Useful for grouping
303
+ * a multi-step agent loop under one logical turn.
304
+ *
305
+ * @example
306
+ * ```typescript
307
+ * await signalvault.withContext({ requestId: 'agent-turn-abc' }, async () => {
308
+ * const llm = await signalvault.chat.completions.create({...});
309
+ * await fetchWeather('London'); // tool call auto-linked to 'agent-turn-abc'
310
+ * });
311
+ * ```
312
+ */
313
+ withContext(ctx, fn) {
314
+ return (0, tools_1.runWithContext)(ctx, fn);
315
+ }
316
+ async sendToolCallEvent(opts, retry = true) {
317
+ const metadata = { ...this.defaultMetadata, ...(opts.metadata ?? {}) };
318
+ // Validate + sanitize before the first await, so the payload reflects the
319
+ // arguments at call time even if the caller mutates them afterwards.
320
+ // validateToolName throws on missing/empty name; surfacing that to the
321
+ // caller is intentional — the manual API should refuse malformed input.
322
+ const safeName = (0, tools_1.validateToolName)(opts.toolName);
323
+ const payload = {
324
+ tool_name: safeName,
325
+ tool_input: (0, tools_1.sanitizePayload)(opts.toolInput),
326
+ tool_output: (0, tools_1.sanitizePayload)(opts.toolOutput),
327
+ duration_ms: opts.durationMs,
328
+ };
329
+ const safeError = (0, tools_1.sanitizeError)(opts.error);
330
+ if (safeError !== undefined)
331
+ payload.error = safeError;
332
+ if (opts.startedAt)
333
+ payload.started_at = opts.startedAt;
334
+ const body = {
335
+ type: 'agent.tool_call',
336
+ environment: this.svEnvironment,
337
+ metadata,
338
+ payload,
339
+ };
340
+ if (opts.requestId)
341
+ body.request_id = opts.requestId;
342
+ try {
343
+ JSON.stringify(body);
344
+ }
345
+ catch (error) {
346
+ // sanitizePayload should make this impossible, but guard the outer
347
+ // serialize too — we'd rather drop the audit than crash the app.
348
+ console.warn('[SignalVault] tool_call serialization failed, event dropped:', error);
349
+ return;
350
+ }
351
+ const result = await this.postBackground(body, { retry });
352
+ if (result && !result.ok && result.status >= 400 && result.status < 500 && result.status !== 429) {
353
+ // Surface 4xx unconditionally — those are usually misconfigurations
354
+ // (bad key, malformed payload) the user must know about.
355
+ console.warn(`[SignalVault] tool_call rejected with ${result.status}. ` +
356
+ `Check your apiKey and event payload.`);
357
+ }
358
+ }
359
+ // ---------------------------------------------------------------------------
68
360
  // OpenAI: chat.completions
69
361
  // ---------------------------------------------------------------------------
70
362
  get chat() {
@@ -75,7 +367,7 @@ class SignalVaultClient {
75
367
  return {
76
368
  completions: {
77
369
  async create(params, options) {
78
- const requestId = (0, uuid_1.v4)();
370
+ const requestId = (0, crypto_1.randomUUID)();
79
371
  const metadata = { ...self.defaultMetadata, ...(options?.metadata ?? {}) };
80
372
  if (self.debugMode) {
81
373
  console.log('[SignalVault] Processing request:', requestId, self.mirrorMode ? '(MIRROR MODE)' : '', params.stream ? '(STREAMING)' : '');
@@ -98,7 +390,7 @@ class SignalVaultClient {
98
390
  const self = this;
99
391
  return {
100
392
  async create(params, options) {
101
- const requestId = (0, uuid_1.v4)();
393
+ const requestId = (0, crypto_1.randomUUID)();
102
394
  const metadata = { ...self.defaultMetadata, ...(options?.metadata ?? {}) };
103
395
  if (self.debugMode) {
104
396
  console.log('[SignalVault] Anthropic request:', requestId, self.mirrorMode ? '(MIRROR MODE)' : '', params.stream ? '(STREAMING)' : '');
@@ -114,215 +406,334 @@ class SignalVaultClient {
114
406
  // OpenAI — mirror mode
115
407
  // ---------------------------------------------------------------------------
116
408
  async handleOpenAIMirror(requestId, params, metadata) {
117
- try {
118
- const response = await this.openai.chat.completions.create(params);
119
- if (params.stream) {
120
- return this.wrapStream(requestId, params.model, params.messages, response, async (output, promptTokens, completionTokens) => {
121
- await this.sendAuditEvents(requestId, params.model, params.messages, output, promptTokens, completionTokens, metadata, 'openai');
122
- });
123
- }
124
- const completion = response;
125
- this.sendAuditEvents(requestId, params.model, params.messages, completion.choices[0]?.message?.content || '', completion.usage?.prompt_tokens || 0, completion.usage?.completion_tokens || 0, metadata, 'openai').catch((err) => {
126
- if (this.debugMode)
127
- console.error('[SignalVault] Audit failed (non-blocking):', err);
128
- });
129
- return completion;
130
- }
131
- catch (error) {
132
- if (this.debugMode)
133
- console.error('[SignalVault] OpenAI error:', error);
134
- throw error;
409
+ const response = await this.openai.chat.completions.create(params);
410
+ const model = params.model;
411
+ const messages = params.messages;
412
+ if (params.stream) {
413
+ return this.wrapStream(response, (output, promptTokens, completionTokens) => this.sendAuditEvents(requestId, model, messages, output, promptTokens, completionTokens, metadata, 'openai'));
135
414
  }
415
+ const completion = response;
416
+ this.track(this.sendAuditEvents(requestId, model, messages, completion.choices[0]?.message?.content || '', completion.usage?.prompt_tokens || 0, completion.usage?.completion_tokens || 0, metadata, 'openai'));
417
+ return completion;
136
418
  }
137
419
  // ---------------------------------------------------------------------------
138
420
  // OpenAI — normal mode
139
421
  // ---------------------------------------------------------------------------
140
422
  async handleOpenAINormal(requestId, params, metadata) {
141
- try {
142
- const decision = await this.sendRequest(requestId, params.model, params.messages, metadata, 'openai');
143
- if (decision.decision === 'block') {
144
- throw new Error(`[SignalVault] Request blocked: ${JSON.stringify(decision.violations)}`);
145
- }
146
- if (decision.decision === 'warn' && this.debugMode) {
147
- console.warn('[SignalVault] Warnings:', decision.violations);
148
- }
149
- const response = await this.openai.chat.completions.create(params);
150
- if (params.stream) {
151
- return this.wrapStream(requestId, params.model, params.messages, response, async (output, promptTokens, completionTokens) => {
152
- await this.sendResponseEvent(requestId, params.model, output, promptTokens, completionTokens, metadata, 'openai');
153
- });
154
- }
155
- const completion = response;
156
- await this.sendResponseEvent(requestId, params.model, completion.choices[0]?.message?.content || '', completion.usage?.prompt_tokens || 0, completion.usage?.completion_tokens || 0, metadata, 'openai');
157
- return completion;
158
- }
159
- catch (error) {
160
- if (this.debugMode)
161
- console.error('[SignalVault] Error:', error);
162
- throw error;
423
+ const model = params.model;
424
+ const messages = params.messages;
425
+ const decision = await this.enforce(requestId, model, messages, metadata, 'openai');
426
+ const fallback = this.fallbackRequest(decision, requestId, model, messages, metadata, 'openai');
427
+ const response = await this.openai.chat.completions.create(params);
428
+ if (params.stream) {
429
+ return this.wrapStream(response, (output, promptTokens, completionTokens) => this.sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, 'openai', fallback));
163
430
  }
431
+ const completion = response;
432
+ // Background: the audit of the response must not delay returning it.
433
+ this.track(this.sendResponseEvent(requestId, model, completion.choices[0]?.message?.content || '', completion.usage?.prompt_tokens || 0, completion.usage?.completion_tokens || 0, metadata, 'openai', fallback));
434
+ return completion;
164
435
  }
165
436
  // ---------------------------------------------------------------------------
166
437
  // Anthropic — mirror mode
167
438
  // ---------------------------------------------------------------------------
168
439
  async handleAnthropicMirror(requestId, params, metadata) {
169
- try {
170
- const response = await this.anthropic.messages.create(params);
171
- if (params.stream) {
172
- return this.wrapAnthropicStream(requestId, params.model, params.messages, response, async (output, inputTokens, outputTokens) => {
173
- await this.sendAuditEvents(requestId, params.model, params.messages, output, inputTokens, outputTokens, metadata, 'anthropic');
174
- });
175
- }
176
- const output = response.content?.[0]?.text || '';
177
- this.sendAuditEvents(requestId, params.model, params.messages, output, response.usage?.input_tokens || 0, response.usage?.output_tokens || 0, metadata, 'anthropic').catch((err) => {
178
- if (this.debugMode)
179
- console.error('[SignalVault] Anthropic audit failed (non-blocking):', err);
180
- });
181
- return response;
182
- }
183
- catch (error) {
184
- if (this.debugMode)
185
- console.error('[SignalVault] Anthropic error:', error);
186
- throw error;
440
+ const response = await this.anthropic.messages.create(params);
441
+ const messages = anthropicMessagesForAudit(params);
442
+ if (params.stream) {
443
+ return this.wrapAnthropicStream(response, (output, inputTokens, outputTokens) => this.sendAuditEvents(requestId, params.model, messages, output, inputTokens, outputTokens, metadata, 'anthropic'));
187
444
  }
445
+ this.track(this.sendAuditEvents(requestId, params.model, messages, anthropicOutputText(response), response.usage?.input_tokens || 0, response.usage?.output_tokens || 0, metadata, 'anthropic'));
446
+ return response;
188
447
  }
189
448
  // ---------------------------------------------------------------------------
190
449
  // Anthropic — normal mode
191
450
  // ---------------------------------------------------------------------------
192
451
  async handleAnthropicNormal(requestId, params, metadata) {
193
- try {
194
- const decision = await this.sendRequest(requestId, params.model, params.messages, metadata, 'anthropic');
195
- if (decision.decision === 'block') {
196
- throw new Error(`[SignalVault] Request blocked: ${JSON.stringify(decision.violations)}`);
197
- }
198
- if (decision.decision === 'warn' && this.debugMode) {
199
- console.warn('[SignalVault] Warnings:', decision.violations);
200
- }
201
- const response = await this.anthropic.messages.create(params);
202
- if (params.stream) {
203
- return this.wrapAnthropicStream(requestId, params.model, params.messages, response, async (output, inputTokens, outputTokens) => {
204
- await this.sendResponseEvent(requestId, params.model, output, inputTokens, outputTokens, metadata, 'anthropic');
205
- });
206
- }
207
- const output = response.content?.[0]?.text || '';
208
- await this.sendResponseEvent(requestId, params.model, output, response.usage?.input_tokens || 0, response.usage?.output_tokens || 0, metadata, 'anthropic');
209
- return response;
210
- }
211
- catch (error) {
212
- if (this.debugMode)
213
- console.error('[SignalVault] Anthropic error:', error);
214
- throw error;
452
+ const messages = anthropicMessagesForAudit(params);
453
+ const decision = await this.enforce(requestId, params.model, messages, metadata, 'anthropic');
454
+ const fallback = this.fallbackRequest(decision, requestId, params.model, messages, metadata, 'anthropic');
455
+ const response = await this.anthropic.messages.create(params);
456
+ if (params.stream) {
457
+ return this.wrapAnthropicStream(response, (output, inputTokens, outputTokens) => this.sendResponseEvent(requestId, params.model, output, inputTokens, outputTokens, metadata, 'anthropic', fallback));
215
458
  }
459
+ this.track(this.sendResponseEvent(requestId, params.model, anthropicOutputText(response), response.usage?.input_tokens || 0, response.usage?.output_tokens || 0, metadata, 'anthropic', fallback));
460
+ return response;
216
461
  }
217
462
  // ---------------------------------------------------------------------------
218
- // Unified OpenAI stream wrapper
463
+ // Stream wrappers
219
464
  // ---------------------------------------------------------------------------
220
- async *wrapStream(requestId, model, messages, stream, onComplete) {
465
+ /**
466
+ * Yields chunks through unchanged and records the response when the stream
467
+ * ends — including when the consumer breaks out early or the stream throws,
468
+ * in which case the partial output is recorded.
469
+ */
470
+ async *wrapStream(stream, onComplete) {
221
471
  const chunks = [];
222
472
  let promptTokens = 0;
223
473
  let completionTokens = 0;
224
- for await (const chunk of stream) {
225
- const content = chunk.choices[0]?.delta?.content || '';
226
- if (content)
227
- chunks.push(content);
228
- if (chunk.usage) {
229
- promptTokens = chunk.usage.prompt_tokens || 0;
230
- completionTokens = chunk.usage.completion_tokens || 0;
474
+ try {
475
+ for await (const chunk of stream) {
476
+ const content = chunk.choices?.[0]?.delta?.content || '';
477
+ if (content)
478
+ chunks.push(content);
479
+ if (chunk.usage) {
480
+ promptTokens = chunk.usage.prompt_tokens || 0;
481
+ completionTokens = chunk.usage.completion_tokens || 0;
482
+ }
483
+ yield chunk;
231
484
  }
232
- yield chunk;
233
485
  }
234
- onComplete(chunks.join(''), promptTokens, completionTokens).catch((err) => {
235
- if (this.debugMode)
236
- console.error('[SignalVault] Post-stream callback failed:', err);
237
- });
486
+ finally {
487
+ this.track(onComplete(chunks.join(''), promptTokens, completionTokens));
488
+ }
238
489
  }
239
- // ---------------------------------------------------------------------------
240
- // Anthropic stream wrapper
241
- // ---------------------------------------------------------------------------
242
- async *wrapAnthropicStream(requestId, model, messages, stream, onComplete) {
490
+ async *wrapAnthropicStream(stream, onComplete) {
243
491
  const chunks = [];
244
492
  let inputTokens = 0;
245
493
  let outputTokens = 0;
246
- for await (const event of stream) {
247
- if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
248
- chunks.push(event.delta.text || '');
249
- }
250
- if (event.type === 'message_start' && event.message?.usage) {
251
- inputTokens = event.message.usage.input_tokens || 0;
252
- }
253
- if (event.type === 'message_delta' && event.usage) {
254
- outputTokens = event.usage.output_tokens || 0;
494
+ try {
495
+ for await (const event of stream) {
496
+ if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
497
+ chunks.push(event.delta.text || '');
498
+ }
499
+ if (event.type === 'message_start' && event.message?.usage) {
500
+ inputTokens = event.message.usage.input_tokens || 0;
501
+ }
502
+ if (event.type === 'message_delta' && event.usage) {
503
+ outputTokens = event.usage.output_tokens || 0;
504
+ }
505
+ yield event;
255
506
  }
256
- yield event;
257
507
  }
258
- onComplete(chunks.join(''), inputTokens, outputTokens).catch((err) => {
259
- if (this.debugMode)
260
- console.error('[SignalVault] Anthropic post-stream callback failed:', err);
261
- });
508
+ finally {
509
+ this.track(onComplete(chunks.join(''), inputTokens, outputTokens));
510
+ }
511
+ }
512
+ // ---------------------------------------------------------------------------
513
+ // Guardrail enforcement
514
+ // ---------------------------------------------------------------------------
515
+ /** Runs the pre-flight check and throws if the request must not proceed. */
516
+ async enforce(requestId, model, messages, metadata, provider) {
517
+ const decision = await this.sendRequest(requestId, model, messages, metadata, provider);
518
+ if (decision.decision === 'block') {
519
+ throw new SignalVaultBlockedError(requestId, decision.violations, decision.dashboard_url);
520
+ }
521
+ if (decision.decision === 'warn' && this.debugMode) {
522
+ console.warn('[SignalVault] Warnings:', decision.violations);
523
+ }
524
+ return decision;
525
+ }
526
+ /**
527
+ * Called when no decision could be obtained. Throws in failMode 'closed';
528
+ * otherwise warns (never silently) and allows.
529
+ */
530
+ unavailable(requestId, key, reason, status) {
531
+ if (this.failMode === 'closed') {
532
+ throw new SignalVaultUnavailableError(requestId, reason, status);
533
+ }
534
+ this.warn(`preflight:${key}`, `[SignalVault] ${reason}. Guardrails were NOT applied — requests are being sent ` +
535
+ `to the provider unchecked (failMode: 'open').`);
536
+ const decision = { ...ALLOW, violations: [], redactions: [] };
537
+ decision[PREFLIGHT_FAILED] =
538
+ RECORDABLE_CAUSES.has(key) ? 'record' : 'skip';
539
+ return decision;
540
+ }
541
+ /**
542
+ * The ai.request body to record in the background when the pre-flight got
543
+ * no decision, so an unchecked call is still in the audit log. Undefined
544
+ * when the pre-flight was recorded normally; null when the server refused
545
+ * it and no audit event should be sent.
546
+ */
547
+ fallbackRequest(decision, requestId, model, messages, metadata, provider) {
548
+ const failed = decision[PREFLIGHT_FAILED];
549
+ if (failed === undefined)
550
+ return undefined;
551
+ if (failed === 'skip')
552
+ return null;
553
+ return {
554
+ ...this.requestBody(requestId, model, messages, metadata, provider),
555
+ payload: { messages, preflight_unavailable: true },
556
+ };
557
+ }
558
+ requestBody(requestId, model, messages, metadata, provider) {
559
+ return {
560
+ type: 'ai.request',
561
+ request_id: requestId,
562
+ environment: this.svEnvironment,
563
+ provider,
564
+ model,
565
+ metadata,
566
+ payload: { messages },
567
+ };
568
+ }
569
+ async describeFailure(status, response) {
570
+ let body;
571
+ try {
572
+ body = await response?.json();
573
+ }
574
+ catch {
575
+ body = undefined;
576
+ }
577
+ const errorType = typeof body?.error === 'object' ? body.error?.type : body?.error;
578
+ switch (status) {
579
+ case 401:
580
+ return ['401', 'SignalVault rejected the API key (401: invalid or revoked)'];
581
+ case 402:
582
+ return ['402', `SignalVault account is not active (402: ${errorType || 'payment required'})`];
583
+ case 403:
584
+ return ['403', `SignalVault denied access (403: ${errorType || 'forbidden'})`];
585
+ case 429:
586
+ return errorType === 'trial_limit_exceeded'
587
+ ? ['429-trial', 'SignalVault trial limit reached (429)']
588
+ : ['429', 'SignalVault rate limit reached (429)'];
589
+ default:
590
+ if (status >= 300 && status < 400) {
591
+ return ['3xx', `SignalVault API redirected (${status}); check that baseUrl is the https:// API URL`];
592
+ }
593
+ return status >= 500
594
+ ? ['5xx', `SignalVault API error (${status})`]
595
+ : [`${status}`, `SignalVault rejected the pre-flight check (${status})`];
596
+ }
262
597
  }
263
598
  // ---------------------------------------------------------------------------
264
599
  // API communication
265
600
  // ---------------------------------------------------------------------------
266
- authHeaders() {
601
+ headers() {
267
602
  return {
268
603
  'Content-Type': 'application/json',
604
+ Accept: 'application/json',
269
605
  Authorization: `Bearer ${this.svApiKey}`,
606
+ 'User-Agent': `signalvault-node/${exports.SDK_VERSION} node/${process.versions.node}`,
270
607
  };
271
608
  }
272
- async sendRequest(requestId, model, messages, metadata, provider) {
609
+ /** Single POST. Never follows redirects, so the API key is never re-sent elsewhere. */
610
+ async post(body, timeoutMs) {
611
+ const response = await fetch(`${this.svBaseUrl}/v1/events`, {
612
+ method: 'POST',
613
+ headers: this.headers(),
614
+ redirect: 'manual',
615
+ signal: AbortSignal.timeout(timeoutMs),
616
+ body: JSON.stringify(body),
617
+ });
618
+ return { ok: response.ok, status: response.status, response };
619
+ }
620
+ /**
621
+ * POST for background events. Adds an `event_id` so a retry is never
622
+ * double-counted, and retries once on connection errors, 5xx, and 429 with a
623
+ * short Retry-After. Timeouts are not retried: the server may be slow rather
624
+ * than down, and a retry would double the time the event is held. Returns
625
+ * the last response, or null if nothing was received.
626
+ */
627
+ async postBackground(body, { eventId = true, retry = true } = {}) {
628
+ const event = eventId ? { event_id: (0, crypto_1.randomUUID)(), ...body } : body;
629
+ let result = null;
630
+ let retryDelayMs = null;
273
631
  try {
274
- const response = await fetch(`${this.svBaseUrl}/v1/events`, {
275
- method: 'POST',
276
- headers: this.authHeaders(),
277
- signal: AbortSignal.timeout(this.preflightTimeout),
278
- body: JSON.stringify({
279
- type: 'ai.request',
280
- request_id: requestId,
281
- environment: this.svEnvironment,
282
- provider,
283
- model,
284
- metadata,
285
- payload: { messages },
286
- }),
287
- });
288
- if (!response.ok) {
289
- if (this.debugMode)
290
- console.error('[SignalVault] API error:', response.status);
291
- return { decision: 'allow', violations: [], redactions: [] };
632
+ result = await this.post(event, this.bgTimeout);
633
+ if (result.ok)
634
+ return result;
635
+ if (result.status === 429) {
636
+ const retryAfter = parseRetryAfterMs(result.response);
637
+ retryDelayMs = retryAfter !== null && retryAfter <= MAX_RETRY_AFTER_MS ? retryAfter : null;
638
+ }
639
+ else if (result.status >= 500) {
640
+ retryDelayMs = 250 + Math.random() * 500;
292
641
  }
293
- return (await response.json());
294
642
  }
295
643
  catch (error) {
296
644
  if (this.debugMode)
297
- console.error('[SignalVault] Pre-flight failed (fail-open):', error);
298
- return { decision: 'allow', violations: [], redactions: [] };
645
+ console.error('[SignalVault] Event send failed:', error);
646
+ if (!isTimeout(error))
647
+ retryDelayMs = 250 + Math.random() * 500;
648
+ }
649
+ if (retry && retryDelayMs !== null) {
650
+ await sleep(retryDelayMs);
651
+ try {
652
+ result = await this.post(event, this.bgTimeout);
653
+ if (result.ok)
654
+ return result;
655
+ }
656
+ catch (error) {
657
+ if (this.debugMode)
658
+ console.error('[SignalVault] Event retry failed:', error);
659
+ }
660
+ }
661
+ this.logUndelivered(result);
662
+ return result;
663
+ }
664
+ logUndelivered(result) {
665
+ if (result?.status === 429) {
666
+ this.warn('bg-429', '[SignalVault] Rate limited (429): audit events are being dropped.');
667
+ }
668
+ else if (this.debugMode) {
669
+ console.error('[SignalVault] Event not delivered:', result ? result.status : 'no response');
299
670
  }
300
671
  }
301
- async sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, provider) {
672
+ async sendRequest(requestId, model, messages, metadata, provider) {
673
+ let result;
302
674
  try {
303
- await fetch(`${this.svBaseUrl}/v1/events`, {
304
- method: 'POST',
305
- headers: this.authHeaders(),
306
- signal: AbortSignal.timeout(this.bgTimeout),
307
- body: JSON.stringify({
308
- type: 'ai.response',
309
- request_id: requestId,
310
- environment: this.svEnvironment,
311
- provider,
312
- model,
313
- metadata,
314
- payload: {
315
- output,
316
- usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens },
317
- },
318
- }),
319
- });
675
+ result = await this.post(this.requestBody(requestId, model, messages, metadata, provider), this.preflightTimeout);
320
676
  }
321
677
  catch (error) {
678
+ const timedOut = isTimeout(error);
322
679
  if (this.debugMode)
323
- console.error('[SignalVault] Failed to send response event:', error);
680
+ console.error('[SignalVault] Pre-flight failed:', error);
681
+ return this.unavailable(requestId, timedOut ? 'timeout' : 'network', timedOut
682
+ ? `SignalVault pre-flight check timed out after ${this.preflightTimeout}ms`
683
+ : 'SignalVault API is unreachable');
684
+ }
685
+ if (!result.ok) {
686
+ const [key, reason] = await this.describeFailure(result.status, result.response);
687
+ return this.unavailable(requestId, key, reason, result.status);
688
+ }
689
+ let data;
690
+ try {
691
+ data = await result.response.json();
324
692
  }
693
+ catch (error) {
694
+ if (isTimeout(error)) {
695
+ return this.unavailable(requestId, 'timeout', `SignalVault pre-flight check timed out after ${this.preflightTimeout}ms`);
696
+ }
697
+ return this.unavailable(requestId, 'invalid-response', 'SignalVault returned an invalid response');
698
+ }
699
+ if (!data || typeof data !== 'object' || !DECISIONS.has(data.decision)) {
700
+ return this.unavailable(requestId, 'invalid-response', 'SignalVault returned an invalid decision');
701
+ }
702
+ return {
703
+ decision: data.decision,
704
+ violations: Array.isArray(data.violations)
705
+ ? data.violations.filter((v) => v !== null && typeof v === 'object')
706
+ : [],
707
+ redactions: Array.isArray(data.redactions) ? data.redactions : [],
708
+ dashboard_url: typeof data.dashboard_url === 'string' ? data.dashboard_url : undefined,
709
+ };
710
+ }
711
+ async sendResponseEvent(requestId, model, output, promptTokens, completionTokens, metadata, provider, fallbackRequest) {
712
+ if (fallbackRequest === null)
713
+ return;
714
+ if (fallbackRequest) {
715
+ // Must land before the response: the server rejects an ai.response whose
716
+ // ai.request it has not stored.
717
+ await this.postBackground(fallbackRequest, { eventId: false });
718
+ }
719
+ await this.postBackground({
720
+ type: 'ai.response',
721
+ request_id: requestId,
722
+ environment: this.svEnvironment,
723
+ provider,
724
+ model,
725
+ metadata,
726
+ payload: {
727
+ output,
728
+ usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens },
729
+ },
730
+ });
325
731
  }
732
+ /**
733
+ * Mirror mode: records the request, then the response. Sequential on
734
+ * purpose — the server rejects an ai.response whose ai.request it has not
735
+ * stored yet, so sending both at once can lose the response.
736
+ */
326
737
  async sendAuditEvents(requestId, model, messages, output, promptTokens, completionTokens, metadata, provider) {
327
738
  const base = {
328
739
  request_id: requestId,
@@ -331,32 +742,20 @@ class SignalVaultClient {
331
742
  model,
332
743
  metadata,
333
744
  };
334
- await Promise.all([
335
- fetch(`${this.svBaseUrl}/v1/events`, {
336
- method: 'POST',
337
- headers: this.authHeaders(),
338
- signal: AbortSignal.timeout(this.bgTimeout),
339
- body: JSON.stringify({
340
- ...base,
341
- type: 'ai.request',
342
- payload: { messages, monitor_mode: true },
343
- }),
344
- }),
345
- fetch(`${this.svBaseUrl}/v1/events`, {
346
- method: 'POST',
347
- headers: this.authHeaders(),
348
- signal: AbortSignal.timeout(this.bgTimeout),
349
- body: JSON.stringify({
350
- ...base,
351
- type: 'ai.response',
352
- payload: {
353
- output,
354
- usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens },
355
- monitor_mode: true,
356
- },
357
- }),
358
- }),
359
- ]);
745
+ await this.postBackground({
746
+ ...base,
747
+ type: 'ai.request',
748
+ payload: { messages, monitor_mode: true },
749
+ });
750
+ await this.postBackground({
751
+ ...base,
752
+ type: 'ai.response',
753
+ payload: {
754
+ output,
755
+ usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens },
756
+ monitor_mode: true,
757
+ },
758
+ });
360
759
  }
361
760
  }
362
761
  exports.SignalVaultClient = SignalVaultClient;