@signalvaultio/node 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -19
- package/dist/__tests__/index.test.js +89 -39
- package/dist/__tests__/tools.test.d.ts +1 -0
- package/dist/__tests__/tools.test.js +243 -0
- package/dist/index.d.ts +114 -25
- package/dist/index.js +384 -210
- package/dist/tools.d.ts +69 -0
- package/dist/tools.js +207 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
# @signalvaultio/node
|
|
2
2
|
|
|
3
|
-
AI audit logs and guardrails for your OpenAI applications.
|
|
3
|
+
AI audit logs and guardrails for your OpenAI and Anthropic applications.
|
|
4
|
+
|
|
5
|
+
> **Server-side only.** This SDK uses Node APIs (`async_hooks`, `fetch`) and
|
|
6
|
+
> requires Node.js **18+**. Do **not** bundle it for the browser — your
|
|
7
|
+
> `apiKey`, `openaiApiKey`, and `anthropicApiKey` would be exposed to end
|
|
8
|
+
> users. Always call SignalVault from your backend.
|
|
4
9
|
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
7
12
|
```bash
|
|
13
|
+
# OpenAI only
|
|
8
14
|
npm install @signalvaultio/node openai
|
|
15
|
+
|
|
16
|
+
# Anthropic only
|
|
17
|
+
npm install @signalvaultio/node @anthropic-ai/sdk
|
|
18
|
+
|
|
19
|
+
# Both
|
|
20
|
+
npm install @signalvaultio/node openai @anthropic-ai/sdk
|
|
9
21
|
```
|
|
10
22
|
|
|
11
|
-
## Quick Start
|
|
23
|
+
## Quick Start — OpenAI
|
|
12
24
|
|
|
13
25
|
```typescript
|
|
14
26
|
import SignalVaultClient from '@signalvaultio/node';
|
|
15
27
|
|
|
16
28
|
const client = new SignalVaultClient({
|
|
17
|
-
apiKey: 'sk_live_your_signalvault_key',
|
|
18
|
-
openaiApiKey: process.env.OPENAI_API_KEY!,
|
|
19
|
-
baseUrl: 'https://api.signalvault.io',
|
|
29
|
+
apiKey: 'sk_live_your_signalvault_key',
|
|
30
|
+
openaiApiKey: process.env.OPENAI_API_KEY!,
|
|
31
|
+
baseUrl: 'https://api.signalvault.io',
|
|
20
32
|
environment: 'production',
|
|
21
33
|
});
|
|
22
34
|
|
|
23
|
-
// Use exactly like OpenAI SDK
|
|
35
|
+
// Use exactly like OpenAI SDK
|
|
24
36
|
const response = await client.chat.completions.create({
|
|
25
37
|
model: 'gpt-4',
|
|
26
38
|
messages: [{ role: 'user', content: 'Hello!' }],
|
|
@@ -29,11 +41,31 @@ const response = await client.chat.completions.create({
|
|
|
29
41
|
console.log(response.choices[0].message.content);
|
|
30
42
|
```
|
|
31
43
|
|
|
44
|
+
## Quick Start — Anthropic
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
const client = new SignalVaultClient({
|
|
48
|
+
apiKey: 'sk_live_your_signalvault_key',
|
|
49
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY!,
|
|
50
|
+
baseUrl: 'https://api.signalvault.io',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Use exactly like Anthropic SDK
|
|
54
|
+
const response = await client.messages.create({
|
|
55
|
+
model: 'claude-3-5-sonnet-20241022',
|
|
56
|
+
messages: [{ role: 'user', content: 'Hello!' }],
|
|
57
|
+
max_tokens: 1024,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log(response.content[0].text);
|
|
61
|
+
```
|
|
62
|
+
|
|
32
63
|
## Streaming
|
|
33
64
|
|
|
34
|
-
Streaming
|
|
65
|
+
Streaming is fully supported for both providers. SignalVault logs the complete response once the stream finishes:
|
|
35
66
|
|
|
36
67
|
```typescript
|
|
68
|
+
// OpenAI streaming
|
|
37
69
|
const stream = await client.chat.completions.create({
|
|
38
70
|
model: 'gpt-4',
|
|
39
71
|
messages: [{ role: 'user', content: 'Write a poem' }],
|
|
@@ -43,48 +75,161 @@ const stream = await client.chat.completions.create({
|
|
|
43
75
|
for await (const chunk of stream) {
|
|
44
76
|
process.stdout.write(chunk.choices[0]?.delta?.content || '');
|
|
45
77
|
}
|
|
78
|
+
|
|
79
|
+
// Anthropic streaming
|
|
80
|
+
const stream = await client.messages.create({
|
|
81
|
+
model: 'claude-3-5-sonnet-20241022',
|
|
82
|
+
messages: [{ role: 'user', content: 'Write a poem' }],
|
|
83
|
+
max_tokens: 1024,
|
|
84
|
+
stream: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for await (const event of stream) {
|
|
88
|
+
if (event.type === 'content_block_delta') {
|
|
89
|
+
process.stdout.write(event.delta.text || '');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Agent Tool-Use Capture
|
|
95
|
+
|
|
96
|
+
Log every tool invocation made by your agents — name, input, output, duration, and any error — as auditable events alongside your LLM calls.
|
|
97
|
+
|
|
98
|
+
### Wrapper API (recommended)
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const fetchWeather = client.tool('fetch_weather', async (city: string) => {
|
|
102
|
+
const res = await fetch(`https://api.example.com/weather?city=${city}`);
|
|
103
|
+
return res.json();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Call it like the original — SignalVault auto-times and audits
|
|
107
|
+
const weather = await fetchWeather('London');
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The wrapper records the call asynchronously (no impact on your tool's latency) and passes the result through unchanged. Errors are recorded and rethrown.
|
|
111
|
+
|
|
112
|
+
### Manual API
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
await client.tools.record({
|
|
116
|
+
toolName: 'fetch_weather',
|
|
117
|
+
toolInput: { city: 'London' },
|
|
118
|
+
toolOutput: { temp: 12.3 },
|
|
119
|
+
durationMs: 142,
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### What gets captured (and what to keep out)
|
|
124
|
+
|
|
125
|
+
When you wrap a tool or call `tools.record()`, SignalVault captures:
|
|
126
|
+
|
|
127
|
+
- `tool_name` (truncated to 200 bytes)
|
|
128
|
+
- `tool_input` — the function arguments, JSON-serialized (capped at 256 KB; oversize values are truncated with a marker)
|
|
129
|
+
- `tool_output` — the function return value, JSON-serialized (same 256 KB cap)
|
|
130
|
+
- `error.message` if the tool throws (truncated to 1900 bytes)
|
|
131
|
+
- `duration_ms`, `started_at`, and any `metadata` you attach
|
|
132
|
+
|
|
133
|
+
These fields are stored encrypted at rest server-side, but they go on the wire
|
|
134
|
+
to SignalVault's API. **If you pass user PII, secrets, or API keys as tool
|
|
135
|
+
arguments, those values will leave your process and be stored in SignalVault.**
|
|
136
|
+
Recommendations:
|
|
137
|
+
|
|
138
|
+
- Sanitize sensitive arguments before invoking the wrapped tool, or use the
|
|
139
|
+
manual `tools.record()` API and pass a redacted copy.
|
|
140
|
+
- Don't put secrets in error messages — they end up in `error` verbatim.
|
|
141
|
+
- Use `metadata` for non-sensitive identifiers (`user_id`, `feature`,
|
|
142
|
+
`workspace_id`); avoid putting raw user content in metadata.
|
|
143
|
+
|
|
144
|
+
### Linking tool calls to a parent LLM turn
|
|
145
|
+
|
|
146
|
+
Wrap your agent loop in `withContext` and tool calls inside auto-correlate to the given `requestId`:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
await client.withContext({ requestId: 'agent-turn-abc' }, async () => {
|
|
150
|
+
const llmResponse = await client.chat.completions.create({...});
|
|
151
|
+
await fetchWeather('London'); // auto-linked to 'agent-turn-abc'
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Without `withContext`, tool calls are recorded as orphans (no parent request).
|
|
156
|
+
|
|
157
|
+
## Metadata
|
|
158
|
+
|
|
159
|
+
Attach contextual metadata to every event — perfect for audit trails, user attribution, and analytics:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// Set defaults at client level
|
|
163
|
+
const client = new SignalVaultClient({
|
|
164
|
+
apiKey: 'sk_live_...',
|
|
165
|
+
openaiApiKey: process.env.OPENAI_API_KEY!,
|
|
166
|
+
metadata: { workspace_id: 'ws_abc', environment: 'production' },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Override per-call
|
|
170
|
+
const response = await client.chat.completions.create(
|
|
171
|
+
{ model: 'gpt-4', messages: [...] },
|
|
172
|
+
{ metadata: { user_id: 'u_123', feature: 'support-chat' } }
|
|
173
|
+
);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Timeout Configuration
|
|
177
|
+
|
|
178
|
+
The pre-flight guardrail check is in your request's critical path. SignalVaultClient uses a short timeout and **fails open** — your request always goes through even if the SignalVault API is slow or unreachable:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const client = new SignalVaultClient({
|
|
182
|
+
apiKey: 'sk_live_...',
|
|
183
|
+
openaiApiKey: process.env.OPENAI_API_KEY!,
|
|
184
|
+
preflightTimeout: 2000, // ms — pre-flight check timeout (fails open). Default: 2000
|
|
185
|
+
timeout: 10000, // ms — background/post-flight calls. Default: 10000
|
|
186
|
+
});
|
|
46
187
|
```
|
|
47
188
|
|
|
48
189
|
## Mirror Mode
|
|
49
190
|
|
|
50
|
-
In mirror mode, requests go directly to
|
|
191
|
+
In mirror mode, requests go directly to the AI provider first and SignalVault audits them asynchronously — no latency added, never blocks:
|
|
51
192
|
|
|
52
193
|
```typescript
|
|
53
194
|
const client = new SignalVaultClient({
|
|
54
195
|
apiKey: 'sk_live_...',
|
|
55
196
|
openaiApiKey: process.env.OPENAI_API_KEY!,
|
|
56
|
-
mirrorMode: true,
|
|
197
|
+
mirrorMode: true,
|
|
57
198
|
});
|
|
58
199
|
```
|
|
59
200
|
|
|
60
201
|
## Features
|
|
61
202
|
|
|
62
203
|
- **Automatic Logging** — Every request and response is recorded in your SignalVault dashboard
|
|
63
|
-
- **Pre-flight Guardrails** — Block or redact requests before they reach
|
|
204
|
+
- **Pre-flight Guardrails** — Block or redact requests before they reach the AI provider
|
|
64
205
|
- **PII Detection** — Automatically detect emails, phone numbers, SSNs in prompts
|
|
65
206
|
- **Secret Detection** — Block API keys and tokens in prompts
|
|
66
207
|
- **Token Limits** — Enforce cost controls per request
|
|
67
208
|
- **Model Allowlists** — Restrict which AI models can be used
|
|
68
|
-
- **Streaming Support** — Full support for streaming completions
|
|
209
|
+
- **Streaming Support** — Full support for streaming completions (OpenAI + Anthropic)
|
|
69
210
|
- **Mirror Mode** — Observe without blocking (zero added latency)
|
|
211
|
+
- **Metadata** — Tag every event with user_id, feature, workspace_id, etc.
|
|
212
|
+
- **Multi-provider** — OpenAI and Anthropic/Claude support
|
|
70
213
|
|
|
71
214
|
## Configuration
|
|
72
215
|
|
|
73
216
|
```typescript
|
|
74
217
|
const client = new SignalVaultClient({
|
|
75
|
-
apiKey: 'sk_live_...',
|
|
76
|
-
openaiApiKey: 'sk-...',
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
218
|
+
apiKey: 'sk_live_...', // Your SignalVault API key (required)
|
|
219
|
+
openaiApiKey: 'sk-...', // OpenAI API key (required for chat.completions)
|
|
220
|
+
anthropicApiKey: 'sk-ant-...', // Anthropic API key (required for messages)
|
|
221
|
+
baseUrl: 'https://api.signalvault.io',
|
|
222
|
+
environment: 'production', // 'development' | 'staging' | 'production'
|
|
223
|
+
debug: false,
|
|
224
|
+
mirrorMode: false,
|
|
225
|
+
preflightTimeout: 2000, // ms
|
|
226
|
+
timeout: 10000, // ms
|
|
227
|
+
metadata: {}, // Default metadata for all events
|
|
81
228
|
});
|
|
82
229
|
```
|
|
83
230
|
|
|
84
231
|
## Error Handling
|
|
85
232
|
|
|
86
|
-
If a request is blocked by a guardrail, an error will be thrown:
|
|
87
|
-
|
|
88
233
|
```typescript
|
|
89
234
|
try {
|
|
90
235
|
const response = await client.chat.completions.create({
|
|
@@ -1,49 +1,99 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const index_1 = require("../index");
|
|
4
|
-
// Minimal unit tests for the SDK (no network calls)
|
|
5
4
|
describe('SignalVaultClient', () => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
apiKey: 'sk_test_abc',
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
describe('construction', () => {
|
|
6
|
+
it('constructs with openaiApiKey', () => {
|
|
7
|
+
const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
|
|
8
|
+
expect(client).toBeDefined();
|
|
9
|
+
expect(client.chat).toBeDefined();
|
|
10
|
+
expect(client.chat.completions).toBeDefined();
|
|
11
|
+
expect(typeof client.chat.completions.create).toBe('function');
|
|
12
|
+
});
|
|
13
|
+
it('throws when neither openaiApiKey nor anthropicApiKey is provided', () => {
|
|
14
|
+
expect(() => new index_1.SignalVaultClient({ apiKey: 'sk_test_abc' })).toThrow('At least one of openaiApiKey or anthropicApiKey is required');
|
|
15
|
+
});
|
|
16
|
+
it('defaults to localhost and production', () => {
|
|
17
|
+
const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
|
|
18
|
+
expect(client.svBaseUrl).toBe('http://localhost:4000');
|
|
19
|
+
expect(client.svEnvironment).toBe('production');
|
|
20
|
+
expect(client.debugMode).toBe(false);
|
|
21
|
+
expect(client.mirrorMode).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
it('accepts custom config', () => {
|
|
24
|
+
const client = new index_1.SignalVaultClient({
|
|
25
|
+
apiKey: 'sk_test_abc',
|
|
26
|
+
openaiApiKey: 'sk-fake',
|
|
27
|
+
baseUrl: 'https://api.signalvault.io/',
|
|
28
|
+
environment: 'staging',
|
|
29
|
+
debug: true,
|
|
30
|
+
mirrorMode: true,
|
|
31
|
+
});
|
|
32
|
+
expect(client.svBaseUrl).toBe('https://api.signalvault.io');
|
|
33
|
+
expect(client.svEnvironment).toBe('staging');
|
|
34
|
+
expect(client.debugMode).toBe(true);
|
|
35
|
+
expect(client.mirrorMode).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('strips trailing slashes from baseUrl', () => {
|
|
38
|
+
const client = new index_1.SignalVaultClient({
|
|
39
|
+
apiKey: 'sk_test_abc',
|
|
40
|
+
openaiApiKey: 'sk-fake',
|
|
41
|
+
baseUrl: 'https://example.com///',
|
|
42
|
+
});
|
|
43
|
+
expect(client.svBaseUrl).toBe('https://example.com');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('timeout configuration', () => {
|
|
47
|
+
it('defaults to 2000ms preflight and 10000ms background', () => {
|
|
48
|
+
const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
|
|
49
|
+
expect(client.preflightTimeout).toBe(2000);
|
|
50
|
+
expect(client.bgTimeout).toBe(10000);
|
|
51
|
+
});
|
|
52
|
+
it('accepts custom timeouts', () => {
|
|
53
|
+
const client = new index_1.SignalVaultClient({
|
|
54
|
+
apiKey: 'sk_test_abc',
|
|
55
|
+
openaiApiKey: 'sk-fake',
|
|
56
|
+
preflightTimeout: 500,
|
|
57
|
+
timeout: 5000,
|
|
58
|
+
});
|
|
59
|
+
expect(client.preflightTimeout).toBe(500);
|
|
60
|
+
expect(client.bgTimeout).toBe(5000);
|
|
61
|
+
});
|
|
15
62
|
});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
apiKey: 'sk_test_abc',
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
describe('metadata', () => {
|
|
64
|
+
it('defaults to empty metadata', () => {
|
|
65
|
+
const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
|
|
66
|
+
expect(client.defaultMetadata).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
it('accepts metadata in config', () => {
|
|
69
|
+
const client = new index_1.SignalVaultClient({
|
|
70
|
+
apiKey: 'sk_test_abc',
|
|
71
|
+
openaiApiKey: 'sk-fake',
|
|
72
|
+
metadata: { user_id: 'u_123', feature: 'chat' },
|
|
73
|
+
});
|
|
74
|
+
expect(client.defaultMetadata).toEqual({ user_id: 'u_123', feature: 'chat' });
|
|
75
|
+
});
|
|
26
76
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
apiKey: 'sk_test_abc',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
});
|
|
36
|
-
expect(client.signalvaultBaseUrl).toBe('https://api.signalvault.io');
|
|
37
|
-
expect(client.signalvaultEnvironment).toBe('staging');
|
|
38
|
-
expect(client.debugMode).toBe(true);
|
|
39
|
-
expect(client.mirrorMode).toBe(true);
|
|
77
|
+
describe('Anthropic', () => {
|
|
78
|
+
it('throws on messages access when anthropicApiKey not provided', () => {
|
|
79
|
+
const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
|
|
80
|
+
expect(() => client.messages).toThrow('anthropicApiKey not provided');
|
|
81
|
+
});
|
|
82
|
+
it('throws when @anthropic-ai/sdk is not installed', () => {
|
|
83
|
+
expect(() => new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', anthropicApiKey: 'sk-ant-fake' })).toThrow('@anthropic-ai/sdk is not installed');
|
|
84
|
+
});
|
|
40
85
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
86
|
+
describe('fail-open on preflight timeout', () => {
|
|
87
|
+
it('sendRequest returns allow decision on fetch error', async () => {
|
|
88
|
+
const client = new index_1.SignalVaultClient({
|
|
89
|
+
apiKey: 'sk_test_abc',
|
|
90
|
+
openaiApiKey: 'sk-fake',
|
|
91
|
+
baseUrl: 'http://127.0.0.1:1', // unreachable
|
|
92
|
+
preflightTimeout: 100,
|
|
93
|
+
});
|
|
94
|
+
const decision = await client.sendRequest('req-1', 'gpt-4', [], {}, 'openai');
|
|
95
|
+
expect(decision.decision).toBe('allow');
|
|
96
|
+
expect(decision.violations).toEqual([]);
|
|
46
97
|
});
|
|
47
|
-
expect(client.signalvaultBaseUrl).toBe('https://example.com');
|
|
48
98
|
});
|
|
49
99
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("../index");
|
|
4
|
+
describe('Agent tool-use capture', () => {
|
|
5
|
+
let fetchMock;
|
|
6
|
+
let client;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ event_id: 'evt_123' }), { status: 200 }));
|
|
9
|
+
client = new index_1.SignalVaultClient({
|
|
10
|
+
apiKey: 'sk_test_abc',
|
|
11
|
+
openaiApiKey: 'sk-fake',
|
|
12
|
+
baseUrl: 'https://api.example.com',
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fetchMock.mockRestore();
|
|
17
|
+
});
|
|
18
|
+
// Helper: lets the fire-and-forget tool record promise settle.
|
|
19
|
+
const flushAsync = () => new Promise((resolve) => setImmediate(resolve));
|
|
20
|
+
describe('client.tool() wrapper', () => {
|
|
21
|
+
it('records a successful tool invocation with input/output and timing', async () => {
|
|
22
|
+
const fetchWeather = client.tool('fetch_weather', async (city) => {
|
|
23
|
+
return { temp: 12.3, conditions: 'rain', for: city };
|
|
24
|
+
});
|
|
25
|
+
const result = await fetchWeather('London');
|
|
26
|
+
expect(result).toEqual({ temp: 12.3, conditions: 'rain', for: 'London' });
|
|
27
|
+
await flushAsync();
|
|
28
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
29
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
30
|
+
expect(url).toBe('https://api.example.com/v1/events');
|
|
31
|
+
const body = JSON.parse(init.body);
|
|
32
|
+
expect(body.type).toBe('agent.tool_call');
|
|
33
|
+
expect(body.payload.tool_name).toBe('fetch_weather');
|
|
34
|
+
expect(body.payload.tool_input).toBe('London');
|
|
35
|
+
expect(body.payload.tool_output).toEqual({ temp: 12.3, conditions: 'rain', for: 'London' });
|
|
36
|
+
expect(body.payload.duration_ms).toBeGreaterThanOrEqual(0);
|
|
37
|
+
expect(body.payload.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
38
|
+
});
|
|
39
|
+
it('records an errored invocation and rethrows', async () => {
|
|
40
|
+
const failingTool = client.tool('fetch_external', async () => {
|
|
41
|
+
throw new Error('connection refused');
|
|
42
|
+
});
|
|
43
|
+
await expect(failingTool()).rejects.toThrow('connection refused');
|
|
44
|
+
await flushAsync();
|
|
45
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
46
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
47
|
+
expect(body.payload.error).toBe('connection refused');
|
|
48
|
+
expect(body.payload.tool_output).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
it('serializes a single object arg as the value, not as an array', async () => {
|
|
51
|
+
const search = client.tool('search', async (query) => ({ matches: query.k }));
|
|
52
|
+
await search({ q: 'embeddings', k: 5 });
|
|
53
|
+
await flushAsync();
|
|
54
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
55
|
+
expect(body.payload.tool_input).toEqual({ q: 'embeddings', k: 5 });
|
|
56
|
+
});
|
|
57
|
+
it('serializes multiple args as an array', async () => {
|
|
58
|
+
const add = client.tool('add', async (a, b) => a + b);
|
|
59
|
+
await add(2, 3);
|
|
60
|
+
await flushAsync();
|
|
61
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
62
|
+
expect(body.payload.tool_input).toEqual([2, 3]);
|
|
63
|
+
});
|
|
64
|
+
it('does not block on the audit network call (fire-and-forget)', async () => {
|
|
65
|
+
// Make fetch hang so we can verify the wrapper returns before the audit completes
|
|
66
|
+
fetchMock.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(new Response('{}', { status: 200 })), 5000)));
|
|
67
|
+
const start = Date.now();
|
|
68
|
+
const tool = client.tool('fast_tool', async () => 42);
|
|
69
|
+
const result = await tool();
|
|
70
|
+
const elapsed = Date.now() - start;
|
|
71
|
+
expect(result).toBe(42);
|
|
72
|
+
expect(elapsed).toBeLessThan(100); // should NOT wait the 5s audit timeout
|
|
73
|
+
});
|
|
74
|
+
it('supports synchronous functions', async () => {
|
|
75
|
+
const sum = client.tool('sum', (a, b) => a + b);
|
|
76
|
+
const result = await sum(2, 3);
|
|
77
|
+
expect(result).toBe(5);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('client.tools.record() manual API', () => {
|
|
81
|
+
it('posts a tool_call event with the given fields', async () => {
|
|
82
|
+
await client.tools.record({
|
|
83
|
+
toolName: 'manual_tool',
|
|
84
|
+
toolInput: { x: 1 },
|
|
85
|
+
toolOutput: { y: 2 },
|
|
86
|
+
durationMs: 50,
|
|
87
|
+
requestId: 'req-explicit',
|
|
88
|
+
});
|
|
89
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
91
|
+
expect(body.type).toBe('agent.tool_call');
|
|
92
|
+
expect(body.request_id).toBe('req-explicit');
|
|
93
|
+
expect(body.payload.tool_name).toBe('manual_tool');
|
|
94
|
+
expect(body.payload.duration_ms).toBe(50);
|
|
95
|
+
});
|
|
96
|
+
it('omits request_id when not provided (orphan tool call)', async () => {
|
|
97
|
+
await client.tools.record({
|
|
98
|
+
toolName: 'orphan',
|
|
99
|
+
durationMs: 10,
|
|
100
|
+
});
|
|
101
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
102
|
+
expect(body.request_id).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('input sanitization', () => {
|
|
106
|
+
it('replaces circular references with a marker instead of throwing', async () => {
|
|
107
|
+
const circular = { name: 'root' };
|
|
108
|
+
circular.self = circular;
|
|
109
|
+
const tool = client.tool('circular_tool', async (_arg) => 'ok');
|
|
110
|
+
await tool(circular);
|
|
111
|
+
await flushAsync();
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
113
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
114
|
+
expect(body.payload.tool_input).toEqual({ name: 'root', self: '[Circular]' });
|
|
115
|
+
});
|
|
116
|
+
it('serializes BigInt arguments as a string with n suffix', async () => {
|
|
117
|
+
const tool = client.tool('bigint_tool', async (_n) => 'ok');
|
|
118
|
+
await tool(BigInt(42));
|
|
119
|
+
await flushAsync();
|
|
120
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
121
|
+
expect(body.payload.tool_input).toBe('42n');
|
|
122
|
+
});
|
|
123
|
+
it('truncates oversized tool_output with a marker', async () => {
|
|
124
|
+
// ~300 KB — over the 256 KB cap.
|
|
125
|
+
const big = 'x'.repeat(300 * 1024);
|
|
126
|
+
const tool = client.tool('big_output', async () => ({ blob: big }));
|
|
127
|
+
await tool();
|
|
128
|
+
await flushAsync();
|
|
129
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
130
|
+
expect(body.payload.tool_output._signalvault_truncated).toBe(true);
|
|
131
|
+
expect(body.payload.tool_output._signalvault_original_bytes).toBeGreaterThan(256 * 1024);
|
|
132
|
+
expect(typeof body.payload.tool_output.preview).toBe('string');
|
|
133
|
+
});
|
|
134
|
+
it('truncates a long error message to ~1900 bytes', async () => {
|
|
135
|
+
const longMsg = 'a'.repeat(5000);
|
|
136
|
+
const failing = client.tool('long_error', async () => {
|
|
137
|
+
throw new Error(longMsg);
|
|
138
|
+
});
|
|
139
|
+
await expect(failing()).rejects.toThrow();
|
|
140
|
+
await flushAsync();
|
|
141
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
142
|
+
const err = body.payload.error;
|
|
143
|
+
// Allow a generous upper bound to account for the truncation marker.
|
|
144
|
+
expect(Buffer.byteLength(err, 'utf8')).toBeLessThanOrEqual(1900);
|
|
145
|
+
expect(err).toMatch(/truncated/);
|
|
146
|
+
});
|
|
147
|
+
it('truncates an oversized tool_name and still records the call', async () => {
|
|
148
|
+
const longName = 'n'.repeat(500);
|
|
149
|
+
await client.tools.record({
|
|
150
|
+
toolName: longName,
|
|
151
|
+
durationMs: 1,
|
|
152
|
+
});
|
|
153
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
154
|
+
expect(Buffer.byteLength(body.payload.tool_name, 'utf8')).toBeLessThanOrEqual(200);
|
|
155
|
+
expect(body.payload.tool_name).toMatch(/truncated/);
|
|
156
|
+
});
|
|
157
|
+
it('rejects an empty tool name from the manual API', async () => {
|
|
158
|
+
await expect(client.tools.record({ toolName: '', durationMs: 1 })).rejects.toThrow(/non-empty string/);
|
|
159
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
it('replaces undefined tool_output with null in the body', async () => {
|
|
162
|
+
const tool = client.tool('void_tool', async () => undefined);
|
|
163
|
+
await tool();
|
|
164
|
+
await flushAsync();
|
|
165
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
166
|
+
expect(body.payload.tool_output).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
it('replaces a function value with a [Function] marker (not a crash)', async () => {
|
|
169
|
+
const tool = client.tool('weird_input', async (_fn) => 'ok');
|
|
170
|
+
await tool(() => 'hello');
|
|
171
|
+
await flushAsync();
|
|
172
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
173
|
+
expect(body.payload.tool_input).toBe('[Function]');
|
|
174
|
+
});
|
|
175
|
+
it('falls back to the serialization-error envelope for top-level undefined', async () => {
|
|
176
|
+
// A bare `undefined` is JSON-serializable as nothing — the replacer leaves
|
|
177
|
+
// it alone and JSON.stringify returns undefined. We expose this as a
|
|
178
|
+
// marker so the audit isn't silently dropped.
|
|
179
|
+
await client.tools.record({
|
|
180
|
+
toolName: 'undef',
|
|
181
|
+
toolInput: undefined,
|
|
182
|
+
durationMs: 1,
|
|
183
|
+
});
|
|
184
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
185
|
+
// sanitizePayload(undefined) returns null per the function contract.
|
|
186
|
+
expect(body.payload.tool_input).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
describe('error logging', () => {
|
|
190
|
+
let warnSpy;
|
|
191
|
+
let errorSpy;
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
194
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
195
|
+
});
|
|
196
|
+
afterEach(() => {
|
|
197
|
+
warnSpy.mockRestore();
|
|
198
|
+
errorSpy.mockRestore();
|
|
199
|
+
});
|
|
200
|
+
it('surfaces 4xx responses to console.warn even with debug=false', async () => {
|
|
201
|
+
fetchMock.mockResolvedValue(new Response('bad request', { status: 422 }));
|
|
202
|
+
await client.tools.record({ toolName: 'will_fail', durationMs: 1 });
|
|
203
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/422/);
|
|
205
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/Check your apiKey/);
|
|
206
|
+
});
|
|
207
|
+
it('does NOT log 5xx responses unless debug is true', async () => {
|
|
208
|
+
fetchMock.mockResolvedValue(new Response('boom', { status: 503 }));
|
|
209
|
+
await client.tools.record({ toolName: 'svc_down', durationMs: 1 });
|
|
210
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
211
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('client.withContext() correlation', () => {
|
|
215
|
+
it('auto-links wrapped tool calls to the surrounding requestId', async () => {
|
|
216
|
+
const tool = client.tool('search', async (q) => ({ q }));
|
|
217
|
+
await client.withContext({ requestId: 'turn-abc' }, async () => {
|
|
218
|
+
await tool('embeddings');
|
|
219
|
+
});
|
|
220
|
+
await flushAsync();
|
|
221
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
222
|
+
expect(body.request_id).toBe('turn-abc');
|
|
223
|
+
});
|
|
224
|
+
it('explicit requestId on record() overrides context', async () => {
|
|
225
|
+
await client.withContext({ requestId: 'context-id' }, async () => {
|
|
226
|
+
await client.tools.record({
|
|
227
|
+
toolName: 'override',
|
|
228
|
+
durationMs: 1,
|
|
229
|
+
requestId: 'explicit-id',
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
233
|
+
expect(body.request_id).toBe('explicit-id');
|
|
234
|
+
});
|
|
235
|
+
it('tools called outside any context are orphans', async () => {
|
|
236
|
+
const tool = client.tool('orphan_tool', async () => 'ok');
|
|
237
|
+
await tool();
|
|
238
|
+
await flushAsync();
|
|
239
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
240
|
+
expect(body.request_id).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|