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