@relayplane/proxy 0.2.0 → 1.1.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.
@@ -1,512 +0,0 @@
1
- /**
2
- * Proxy Server Tests
3
- */
4
-
5
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
- import * as http from 'http';
7
- import * as fs from 'fs';
8
- import * as os from 'os';
9
- import * as path from 'path';
10
- import { ProxyServer, createProxyServer } from '../src/server.js';
11
- import { createLedger } from '@relayplane/ledger';
12
- import { MemoryAuthProfileStorage } from '@relayplane/auth-gate';
13
-
14
- // Helper to make HTTP requests
15
- async function request(
16
- port: number,
17
- method: string,
18
- path: string,
19
- body?: unknown,
20
- headers?: Record<string, string>
21
- ): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: unknown }> {
22
- return new Promise((resolve, reject) => {
23
- const req = http.request(
24
- {
25
- hostname: '127.0.0.1',
26
- port,
27
- path,
28
- method,
29
- headers: {
30
- 'Content-Type': 'application/json',
31
- ...headers,
32
- },
33
- },
34
- (res) => {
35
- let data = '';
36
- res.on('data', (chunk) => (data += chunk));
37
- res.on('end', () => {
38
- let parsed: unknown;
39
- try {
40
- parsed = JSON.parse(data);
41
- } catch {
42
- parsed = data;
43
- }
44
- resolve({
45
- status: res.statusCode ?? 500,
46
- headers: res.headers,
47
- body: parsed,
48
- });
49
- });
50
- }
51
- );
52
-
53
- req.on('error', reject);
54
-
55
- if (body) {
56
- req.write(JSON.stringify(body));
57
- }
58
- req.end();
59
- });
60
- }
61
-
62
- describe('ProxyServer', () => {
63
- let server: ProxyServer;
64
- let dbPath: string;
65
- const port = 3099; // Use non-standard port for testing
66
-
67
- beforeEach(async () => {
68
- // Use temp directory for ledger
69
- dbPath = path.join(os.tmpdir(), `proxy-test-${Date.now()}.db`);
70
-
71
- const ledger = createLedger({ dbPath });
72
- const authStorage = new MemoryAuthProfileStorage();
73
-
74
- // Seed test auth profiles
75
- await authStorage.seedTestData('test_workspace');
76
-
77
- server = createProxyServer({
78
- port,
79
- ledger,
80
- authStorage,
81
- verbose: false,
82
- defaultWorkspaceId: 'test_workspace',
83
- defaultAgentId: 'test_agent',
84
- });
85
-
86
- await server.start();
87
- // Wait a bit for server to be fully ready
88
- await new Promise((resolve) => setTimeout(resolve, 50));
89
- });
90
-
91
- afterEach(async () => {
92
- await server.stop();
93
- await server.getLedger().close();
94
-
95
- // Clean up test database
96
- if (fs.existsSync(dbPath)) {
97
- fs.unlinkSync(dbPath);
98
- }
99
- if (fs.existsSync(dbPath + '-wal')) {
100
- fs.unlinkSync(dbPath + '-wal');
101
- }
102
- if (fs.existsSync(dbPath + '-shm')) {
103
- fs.unlinkSync(dbPath + '-shm');
104
- }
105
- });
106
-
107
- describe('Health endpoint', () => {
108
- it('should respond to /health', async () => {
109
- const res = await request(port, 'GET', '/health');
110
-
111
- expect(res.status).toBe(200);
112
- expect(res.body).toEqual({ status: 'ok', version: '0.1.0' });
113
- });
114
-
115
- it('should respond to /', async () => {
116
- const res = await request(port, 'GET', '/');
117
-
118
- expect(res.status).toBe(200);
119
- expect(res.body).toEqual({ status: 'ok', version: '0.1.0' });
120
- });
121
- });
122
-
123
- describe('Models endpoint', () => {
124
- it('should list available models', async () => {
125
- const res = await request(port, 'GET', '/v1/models');
126
-
127
- expect(res.status).toBe(200);
128
- const body = res.body as { object: string; data: Array<{ id: string }> };
129
- expect(body.object).toBe('list');
130
- expect(body.data.length).toBeGreaterThan(0);
131
- expect(body.data.some((m) => m.id === 'claude-3-5-sonnet')).toBe(true);
132
- });
133
- });
134
-
135
- describe('Chat completions endpoint', () => {
136
- it('should return error when provider not configured', async () => {
137
- const res = await request(
138
- port,
139
- 'POST',
140
- '/v1/chat/completions',
141
- {
142
- model: 'claude-3-5-sonnet',
143
- messages: [{ role: 'user', content: 'Hello' }],
144
- },
145
- {
146
- 'X-RelayPlane-Workspace': 'test_workspace',
147
- 'X-RelayPlane-Agent': 'test_agent',
148
- }
149
- );
150
-
151
- expect(res.status).toBe(500);
152
- const body = res.body as { error: { code: string; run_id?: string } };
153
- expect(body.error.code).toBe('provider_not_configured');
154
- // Should still have a run_id for debugging
155
- expect(body.error.run_id).toBeDefined();
156
- });
157
-
158
- it('should record run in ledger even on failure', async () => {
159
- await request(
160
- port,
161
- 'POST',
162
- '/v1/chat/completions',
163
- {
164
- model: 'gpt-4o',
165
- messages: [{ role: 'user', content: 'Hello' }],
166
- },
167
- {
168
- 'X-RelayPlane-Workspace': 'test_workspace',
169
- 'X-RelayPlane-Agent': 'test_agent',
170
- }
171
- );
172
-
173
- // Check ledger has the run
174
- const runs = await server.getLedger().queryRuns({
175
- workspace_id: 'test_workspace',
176
- limit: 10,
177
- });
178
-
179
- expect(runs.items.length).toBeGreaterThan(0);
180
- expect(runs.items[0]?.status).toBe('failed');
181
- expect(runs.items[0]?.agent_id).toBe('test_agent');
182
- });
183
-
184
- it('should track auth_type from header detection', async () => {
185
- // Make request with API key style auth
186
- await request(
187
- port,
188
- 'POST',
189
- '/v1/chat/completions',
190
- {
191
- model: 'claude-3-5-sonnet',
192
- messages: [{ role: 'user', content: 'Hello' }],
193
- },
194
- {
195
- 'Authorization': 'Bearer sk-ant-api123',
196
- 'X-RelayPlane-Workspace': 'test_workspace',
197
- }
198
- );
199
-
200
- const runs = await server.getLedger().queryRuns({
201
- workspace_id: 'test_workspace',
202
- limit: 1,
203
- });
204
-
205
- expect(runs.items[0]?.auth_type).toBe('api');
206
- });
207
-
208
- it('should detect automated requests via header', async () => {
209
- await request(
210
- port,
211
- 'POST',
212
- '/v1/chat/completions',
213
- {
214
- model: 'claude-3-5-sonnet',
215
- messages: [{ role: 'user', content: 'Hello' }],
216
- },
217
- {
218
- 'X-RelayPlane-Automated': 'true',
219
- 'X-RelayPlane-Workspace': 'test_workspace',
220
- }
221
- );
222
-
223
- const runs = await server.getLedger().queryRuns({
224
- workspace_id: 'test_workspace',
225
- limit: 1,
226
- });
227
-
228
- expect(runs.items[0]?.execution_mode).toBe('background');
229
- });
230
- });
231
-
232
- describe('CORS', () => {
233
- it('should handle OPTIONS preflight', async () => {
234
- const res = await request(port, 'OPTIONS', '/v1/chat/completions');
235
-
236
- expect(res.status).toBe(204);
237
- expect(res.headers['access-control-allow-origin']).toBe('*');
238
- expect(res.headers['access-control-allow-methods']).toContain('POST');
239
- });
240
- });
241
-
242
- describe('404 handling', () => {
243
- it('should return 404 for unknown routes', async () => {
244
- const res = await request(port, 'GET', '/v1/unknown');
245
-
246
- expect(res.status).toBe(404);
247
- const body = res.body as { error: { code: string } };
248
- expect(body.error.code).toBe('not_found');
249
- });
250
- });
251
- });
252
-
253
- describe('createProxyServer', () => {
254
- it('should create server with default config', () => {
255
- const server = createProxyServer();
256
- expect(server).toBeInstanceOf(ProxyServer);
257
- });
258
-
259
- it('should create server with custom config', () => {
260
- const server = createProxyServer({
261
- port: 9999,
262
- host: '0.0.0.0',
263
- verbose: true,
264
- });
265
- expect(server).toBeInstanceOf(ProxyServer);
266
- });
267
- });
268
-
269
- describe('Policy Integration', () => {
270
- let server: ProxyServer;
271
- let dbPath: string;
272
- const port = 3098;
273
-
274
- beforeEach(async () => {
275
- dbPath = path.join(os.tmpdir(), `proxy-policy-test-${Date.now()}.db`);
276
-
277
- const ledger = createLedger({ dbPath });
278
- const authStorage = new MemoryAuthProfileStorage();
279
-
280
- await authStorage.seedTestData('test_workspace');
281
-
282
- server = createProxyServer({
283
- port,
284
- ledger,
285
- authStorage,
286
- verbose: false,
287
- defaultWorkspaceId: 'test_workspace',
288
- defaultAgentId: 'test_agent',
289
- enforcePolicies: true,
290
- });
291
-
292
- await server.start();
293
- await new Promise((resolve) => setTimeout(resolve, 50));
294
- });
295
-
296
- afterEach(async () => {
297
- await server.stop();
298
- await server.getLedger().close();
299
-
300
- if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
301
- if (fs.existsSync(dbPath + '-wal')) fs.unlinkSync(dbPath + '-wal');
302
- if (fs.existsSync(dbPath + '-shm')) fs.unlinkSync(dbPath + '-shm');
303
- });
304
-
305
- describe('Policy Management API', () => {
306
- it('should create and list policies', async () => {
307
- // Create a policy via API
308
- const createRes = await request(
309
- port,
310
- 'POST',
311
- '/v1/policies',
312
- {
313
- workspace_id: 'test_workspace',
314
- name: 'No Opus',
315
- description: 'Block expensive model',
316
- type: 'model.denylist',
317
- enabled: true,
318
- priority: 100,
319
- scope: { applies_to: 'workspace' },
320
- conditions: [],
321
- action: { type: 'deny', parameters: { models: ['claude-3-opus'] } },
322
- created_by: 'test_user',
323
- },
324
- { 'X-RelayPlane-Workspace': 'test_workspace' }
325
- );
326
-
327
- expect(createRes.status).toBe(201);
328
- const created = createRes.body as { policy: { policy_id: string; name: string } };
329
- expect(created.policy.name).toBe('No Opus');
330
- expect(created.policy.policy_id).toBeDefined();
331
-
332
- // List policies
333
- const listRes = await request(port, 'GET', '/v1/policies', undefined, {
334
- 'X-RelayPlane-Workspace': 'test_workspace',
335
- });
336
-
337
- expect(listRes.status).toBe(200);
338
- const list = listRes.body as { policies: Array<{ name: string }> };
339
- expect(list.policies.some((p) => p.name === 'No Opus')).toBe(true);
340
- });
341
-
342
- it('should get policy by ID', async () => {
343
- const createRes = await request(
344
- port,
345
- 'POST',
346
- '/v1/policies',
347
- {
348
- workspace_id: 'test_workspace',
349
- name: 'Test Policy',
350
- description: '',
351
- type: 'model.allowlist',
352
- enabled: true,
353
- priority: 100,
354
- scope: { applies_to: 'workspace' },
355
- conditions: [],
356
- action: { type: 'deny' },
357
- created_by: 'test_user',
358
- },
359
- { 'X-RelayPlane-Workspace': 'test_workspace' }
360
- );
361
-
362
- const policyId = (createRes.body as { policy: { policy_id: string } }).policy.policy_id;
363
-
364
- const getRes = await request(port, 'GET', `/v1/policies/${policyId}`);
365
-
366
- expect(getRes.status).toBe(200);
367
- const got = getRes.body as { policy: { policy_id: string } };
368
- expect(got.policy.policy_id).toBe(policyId);
369
- });
370
-
371
- it('should update policy', async () => {
372
- const createRes = await request(
373
- port,
374
- 'POST',
375
- '/v1/policies',
376
- {
377
- workspace_id: 'test_workspace',
378
- name: 'Original',
379
- description: '',
380
- type: 'model.allowlist',
381
- enabled: true,
382
- priority: 100,
383
- scope: { applies_to: 'workspace' },
384
- conditions: [],
385
- action: { type: 'deny' },
386
- created_by: 'test_user',
387
- },
388
- { 'X-RelayPlane-Workspace': 'test_workspace' }
389
- );
390
-
391
- const policyId = (createRes.body as { policy: { policy_id: string } }).policy.policy_id;
392
-
393
- const updateRes = await request(port, 'PATCH', `/v1/policies/${policyId}`, {
394
- name: 'Updated',
395
- priority: 50,
396
- });
397
-
398
- expect(updateRes.status).toBe(200);
399
- const updated = updateRes.body as { policy: { name: string; priority: number } };
400
- expect(updated.policy.name).toBe('Updated');
401
- expect(updated.policy.priority).toBe(50);
402
- });
403
-
404
- it('should delete policy', async () => {
405
- const createRes = await request(
406
- port,
407
- 'POST',
408
- '/v1/policies',
409
- {
410
- workspace_id: 'test_workspace',
411
- name: 'To Delete',
412
- description: '',
413
- type: 'model.allowlist',
414
- enabled: true,
415
- priority: 100,
416
- scope: { applies_to: 'workspace' },
417
- conditions: [],
418
- action: { type: 'deny' },
419
- created_by: 'test_user',
420
- },
421
- { 'X-RelayPlane-Workspace': 'test_workspace' }
422
- );
423
-
424
- const policyId = (createRes.body as { policy: { policy_id: string } }).policy.policy_id;
425
-
426
- const deleteRes = await request(port, 'DELETE', `/v1/policies/${policyId}`);
427
- expect(deleteRes.status).toBe(204);
428
-
429
- const getRes = await request(port, 'GET', `/v1/policies/${policyId}`);
430
- expect(getRes.status).toBe(404);
431
- });
432
- });
433
-
434
- describe('Policy Testing (Dry Run)', () => {
435
- it('should test policy without side effects', async () => {
436
- // Create a blocking policy
437
- await request(
438
- port,
439
- 'POST',
440
- '/v1/policies',
441
- {
442
- workspace_id: 'test_workspace',
443
- name: 'Block Opus',
444
- description: '',
445
- type: 'model.denylist',
446
- enabled: true,
447
- priority: 100,
448
- scope: { applies_to: 'workspace' },
449
- conditions: [],
450
- action: { type: 'deny', parameters: { models: ['claude-3-opus'] } },
451
- created_by: 'test_user',
452
- },
453
- { 'X-RelayPlane-Workspace': 'test_workspace' }
454
- );
455
-
456
- // Test the policy
457
- const testRes = await request(port, 'POST', '/v1/policies/test', {
458
- workspace_id: 'test_workspace',
459
- agent_id: 'test_agent',
460
- request: {
461
- model: 'claude-3-opus',
462
- provider: 'anthropic',
463
- },
464
- });
465
-
466
- expect(testRes.status).toBe(200);
467
- const result = testRes.body as { decision: { allow: boolean; action: string } };
468
- expect(result.decision.allow).toBe(false);
469
- expect(result.decision.action).toBe('deny');
470
- });
471
- });
472
-
473
- describe('Policy Enforcement', () => {
474
- it('should deny request when policy blocks model', async () => {
475
- // Create a blocking policy
476
- await request(
477
- port,
478
- 'POST',
479
- '/v1/policies',
480
- {
481
- workspace_id: 'test_workspace',
482
- name: 'Block GPT',
483
- description: '',
484
- type: 'model.denylist',
485
- enabled: true,
486
- priority: 100,
487
- scope: { applies_to: 'workspace' },
488
- conditions: [],
489
- action: { type: 'deny', parameters: { models: ['gpt-4o'] } },
490
- created_by: 'test_user',
491
- },
492
- { 'X-RelayPlane-Workspace': 'test_workspace' }
493
- );
494
-
495
- // Try to use blocked model
496
- const res = await request(
497
- port,
498
- 'POST',
499
- '/v1/chat/completions',
500
- {
501
- model: 'gpt-4o',
502
- messages: [{ role: 'user', content: 'Hello' }],
503
- },
504
- { 'X-RelayPlane-Workspace': 'test_workspace' }
505
- );
506
-
507
- expect(res.status).toBe(403);
508
- const body = res.body as { error: { code: string } };
509
- expect(body.error.code).toBe('policy_denied');
510
- });
511
- });
512
- });
@@ -1,126 +0,0 @@
1
- /**
2
- * Tests for Telemetry Module
3
- *
4
- * Tests: collect → audit → opt-out
5
- */
6
-
7
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
- import * as fs from 'fs';
9
- import * as path from 'path';
10
- import * as os from 'os';
11
-
12
- describe('Telemetry Module Tests', () => {
13
- describe('Task Type Inference', () => {
14
- it('should infer task types correctly', async () => {
15
- // Import the module dynamically
16
- const { inferTaskType } = await import('../src/telemetry.js');
17
-
18
- // Quick task (small input/output)
19
- expect(inferTaskType(100, 50, 'gpt-4')).toBe('quick_task');
20
-
21
- // Long context (> 10000 input)
22
- expect(inferTaskType(15000, 500, 'claude-3')).toBe('long_context');
23
-
24
- // Generation (high output ratio)
25
- expect(inferTaskType(100, 600, 'gpt-4')).toBe('generation');
26
-
27
- // Classification (low output ratio)
28
- expect(inferTaskType(500, 50, 'gpt-4')).toBe('classification');
29
-
30
- // Code review
31
- expect(inferTaskType(3000, 800, 'claude-3')).toBe('code_review');
32
-
33
- // Content generation
34
- expect(inferTaskType(500, 1500, 'gpt-4')).toBe('content_generation');
35
-
36
- // Tool use
37
- expect(inferTaskType(500, 200, 'gpt-4', true)).toBe('tool_use');
38
- });
39
- });
40
-
41
- describe('Cost Estimation', () => {
42
- it('should estimate costs correctly', async () => {
43
- const { estimateCost } = await import('../src/telemetry.js');
44
-
45
- // Claude 3.5 Haiku pricing: $0.8/M in, $4/M out
46
- const haikuCost = estimateCost('claude-3-5-haiku-20241022', 1000, 1000);
47
- expect(haikuCost).toBeCloseTo(0.0048, 4);
48
-
49
- // GPT-4o pricing: $2.5/M in, $10/M out
50
- const gpt4oCost = estimateCost('gpt-4o', 1000, 1000);
51
- expect(gpt4oCost).toBeCloseTo(0.0125, 4);
52
-
53
- // Unknown model uses default pricing
54
- const unknownCost = estimateCost('unknown-model', 1000, 1000);
55
- expect(unknownCost).toBeGreaterThan(0);
56
- });
57
- });
58
-
59
- describe('Audit Mode', () => {
60
- it('should buffer events in audit mode', async () => {
61
- const {
62
- setAuditMode,
63
- isAuditMode,
64
- getAuditBuffer,
65
- clearAuditBuffer,
66
- } = await import('../src/telemetry.js');
67
-
68
- // Clear any previous buffer
69
- clearAuditBuffer();
70
-
71
- // Check initial state
72
- expect(isAuditMode()).toBe(false);
73
-
74
- // Enable audit mode
75
- setAuditMode(true);
76
- expect(isAuditMode()).toBe(true);
77
-
78
- // Check buffer is empty
79
- expect(getAuditBuffer()).toHaveLength(0);
80
-
81
- // Clean up
82
- setAuditMode(false);
83
- clearAuditBuffer();
84
- });
85
- });
86
-
87
- describe('Offline Mode', () => {
88
- it('should track offline mode setting', async () => {
89
- const {
90
- setOfflineMode,
91
- isOfflineMode
92
- } = await import('../src/telemetry.js');
93
-
94
- // Initially false
95
- expect(isOfflineMode()).toBe(false);
96
-
97
- // Enable offline mode
98
- setOfflineMode(true);
99
- expect(isOfflineMode()).toBe(true);
100
-
101
- // Disable offline mode
102
- setOfflineMode(false);
103
- expect(isOfflineMode()).toBe(false);
104
- });
105
- });
106
-
107
- describe('Telemetry Stats', () => {
108
- it('should return stats structure', async () => {
109
- const { getTelemetryStats } = await import('../src/telemetry.js');
110
-
111
- const stats = getTelemetryStats();
112
-
113
- // Verify structure
114
- expect(stats).toHaveProperty('totalEvents');
115
- expect(stats).toHaveProperty('totalCost');
116
- expect(stats).toHaveProperty('byModel');
117
- expect(stats).toHaveProperty('byTaskType');
118
- expect(stats).toHaveProperty('successRate');
119
-
120
- // All should be valid types
121
- expect(typeof stats.totalEvents).toBe('number');
122
- expect(typeof stats.totalCost).toBe('number');
123
- expect(typeof stats.successRate).toBe('number');
124
- });
125
- });
126
- });
package/dist/cli.d.ts DELETED
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * RelayPlane Proxy CLI
4
- *
5
- * Intelligent AI model routing proxy server.
6
- *
7
- * Usage:
8
- * npx @relayplane/proxy [command] [options]
9
- * relayplane-proxy [command] [options]
10
- *
11
- * Commands:
12
- * (default) Start the proxy server
13
- * telemetry [on|off|status] Manage telemetry settings
14
- * stats Show usage statistics
15
- * config Show configuration
16
- *
17
- * Options:
18
- * --port <number> Port to listen on (default: 3001)
19
- * --host <string> Host to bind to (default: 127.0.0.1)
20
- * --offline Disable all network calls except LLM endpoints
21
- * --audit Show telemetry payloads before sending
22
- * -v, --verbose Enable verbose logging
23
- * -h, --help Show this help message
24
- * --version Show version
25
- *
26
- * Environment Variables:
27
- * ANTHROPIC_API_KEY Anthropic API key
28
- * OPENAI_API_KEY OpenAI API key
29
- * GEMINI_API_KEY Google Gemini API key
30
- * XAI_API_KEY xAI/Grok API key
31
- * MOONSHOT_API_KEY Moonshot API key
32
- *
33
- * @packageDocumentation
34
- */
35
- export {};
36
- //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG"}