@relayplane/proxy 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,1089 @@
1
+ "use strict";
2
+ /**
3
+ * RelayPlane Agent Ops Proxy Server
4
+ *
5
+ * OpenAI-compatible proxy server with integrated observability via the Learning Ledger
6
+ * and auth enforcement via Auth Gate.
7
+ *
8
+ * Features:
9
+ * - OpenAI-compatible `/v1/chat/completions` endpoint
10
+ * - Auth Gate integration for consumer vs API auth detection
11
+ * - Learning Ledger integration for run tracking
12
+ * - Timing capture (latency_ms, ttft_ms)
13
+ * - Structured error handling
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.ProxyServer = void 0;
52
+ exports.createProxyServer = createProxyServer;
53
+ const http = __importStar(require("node:http"));
54
+ const ledger_1 = require("@relayplane/ledger");
55
+ const auth_gate_1 = require("@relayplane/auth-gate");
56
+ const policy_engine_1 = require("@relayplane/policy-engine");
57
+ const routing_engine_1 = require("@relayplane/routing-engine");
58
+ const explainability_1 = require("@relayplane/explainability");
59
+ /**
60
+ * Provider endpoint configuration
61
+ */
62
+ const PROVIDER_ENDPOINTS = {
63
+ anthropic: {
64
+ baseUrl: 'https://api.anthropic.com/v1',
65
+ authHeader: 'x-api-key',
66
+ },
67
+ openai: {
68
+ baseUrl: 'https://api.openai.com/v1',
69
+ authHeader: 'Authorization',
70
+ },
71
+ openrouter: {
72
+ baseUrl: 'https://openrouter.ai/api/v1',
73
+ authHeader: 'Authorization',
74
+ },
75
+ };
76
+ /**
77
+ * Model to provider mapping
78
+ */
79
+ function getProviderForModel(model) {
80
+ if (model.startsWith('claude') || model.startsWith('anthropic')) {
81
+ return 'anthropic';
82
+ }
83
+ if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) {
84
+ return 'openai';
85
+ }
86
+ // Default to openrouter for other models
87
+ return 'openrouter';
88
+ }
89
+ /**
90
+ * RelayPlane Agent Ops Proxy Server
91
+ */
92
+ class ProxyServer {
93
+ server = null;
94
+ ledger;
95
+ authGate;
96
+ policyEngine;
97
+ routingEngine;
98
+ capabilityRegistry;
99
+ providerManager;
100
+ explainer;
101
+ comparator;
102
+ simulator;
103
+ config;
104
+ constructor(config = {}) {
105
+ this.config = {
106
+ port: config.port ?? 3001,
107
+ host: config.host ?? '127.0.0.1',
108
+ verbose: config.verbose ?? false,
109
+ defaultWorkspaceId: config.defaultWorkspaceId ?? 'default',
110
+ defaultAgentId: config.defaultAgentId ?? 'default',
111
+ defaultAuthEnforcementMode: config.defaultAuthEnforcementMode ?? 'recommended',
112
+ enforcePolicies: config.enforcePolicies ?? true,
113
+ enableRouting: config.enableRouting ?? true,
114
+ ...config,
115
+ };
116
+ // Initialize ledger
117
+ this.ledger = config.ledger ?? (0, ledger_1.createLedger)();
118
+ // Initialize auth storage and gate
119
+ const authStorage = config.authStorage ?? new auth_gate_1.MemoryAuthProfileStorage();
120
+ this.authGate = (0, auth_gate_1.createAuthGate)({
121
+ storage: authStorage,
122
+ ledger: this.ledger,
123
+ defaultSettings: {
124
+ auth_enforcement_mode: this.config.defaultAuthEnforcementMode,
125
+ },
126
+ });
127
+ // Initialize policy engine
128
+ const policyStorage = config.policyStorage ?? new policy_engine_1.MemoryPolicyStorage();
129
+ this.policyEngine = config.policyEngine ?? (0, policy_engine_1.createPolicyEngine)({
130
+ storage: policyStorage,
131
+ ledger: this.ledger,
132
+ });
133
+ // Initialize routing engine (Phase 3)
134
+ this.capabilityRegistry = config.capabilityRegistry ?? (0, routing_engine_1.createCapabilityRegistry)({
135
+ providerOverrides: this.buildProviderOverrides(),
136
+ });
137
+ this.providerManager = config.providerManager ?? (0, routing_engine_1.createProviderManagerWithBuiltIns)();
138
+ this.routingEngine = config.routingEngine ?? (0, routing_engine_1.createRoutingEngine)({
139
+ registry: this.capabilityRegistry,
140
+ ledger: this.ledger,
141
+ });
142
+ // Initialize explainability components (Phase 4)
143
+ this.explainer = (0, explainability_1.createExplanationEngine)({
144
+ ledger: this.ledger,
145
+ policyEngine: this.policyEngine,
146
+ routingEngine: this.routingEngine,
147
+ capabilityRegistry: this.capabilityRegistry,
148
+ });
149
+ this.comparator = (0, explainability_1.createRunComparator)({
150
+ ledger: this.ledger,
151
+ explanationEngine: this.explainer,
152
+ });
153
+ this.simulator = (0, explainability_1.createSimulator)({
154
+ policyEngine: this.policyEngine,
155
+ routingEngine: this.routingEngine,
156
+ capabilityRegistry: this.capabilityRegistry,
157
+ });
158
+ // Set API keys from config
159
+ this.configureProviderApiKeys();
160
+ }
161
+ /**
162
+ * Build provider overrides from config
163
+ */
164
+ buildProviderOverrides() {
165
+ const overrides = {};
166
+ const providers = this.config.providers ?? {};
167
+ if (providers.anthropic) {
168
+ overrides.anthropic = {
169
+ enabled: true,
170
+ base_url: providers.anthropic.baseUrl,
171
+ };
172
+ }
173
+ if (providers.openai) {
174
+ overrides.openai = {
175
+ enabled: true,
176
+ base_url: providers.openai.baseUrl,
177
+ };
178
+ }
179
+ if (providers.openrouter) {
180
+ overrides.openrouter = {
181
+ enabled: true,
182
+ base_url: providers.openrouter.baseUrl,
183
+ };
184
+ }
185
+ if (providers.google) {
186
+ overrides.google = {
187
+ enabled: true,
188
+ base_url: providers.google.baseUrl,
189
+ };
190
+ }
191
+ if (providers.together) {
192
+ overrides.together = {
193
+ enabled: true,
194
+ base_url: providers.together.baseUrl,
195
+ };
196
+ }
197
+ if (providers.deepseek) {
198
+ overrides.deepseek = {
199
+ enabled: true,
200
+ base_url: providers.deepseek.baseUrl,
201
+ };
202
+ }
203
+ return overrides;
204
+ }
205
+ /**
206
+ * Configure provider API keys from config
207
+ */
208
+ configureProviderApiKeys() {
209
+ const providers = this.config.providers ?? {};
210
+ if (providers.anthropic?.apiKey) {
211
+ this.providerManager.setApiKey('anthropic', providers.anthropic.apiKey);
212
+ }
213
+ if (providers.openai?.apiKey) {
214
+ this.providerManager.setApiKey('openai', providers.openai.apiKey);
215
+ }
216
+ if (providers.openrouter?.apiKey) {
217
+ this.providerManager.setApiKey('openrouter', providers.openrouter.apiKey);
218
+ }
219
+ if (providers.google?.apiKey) {
220
+ this.providerManager.setApiKey('google', providers.google.apiKey);
221
+ }
222
+ if (providers.together?.apiKey) {
223
+ this.providerManager.setApiKey('together', providers.together.apiKey);
224
+ }
225
+ if (providers.deepseek?.apiKey) {
226
+ this.providerManager.setApiKey('deepseek', providers.deepseek.apiKey);
227
+ }
228
+ // Also try environment variables
229
+ const configs = this.capabilityRegistry.getEnabledProviders();
230
+ this.providerManager.setApiKeysFromEnv(configs);
231
+ }
232
+ /**
233
+ * Start the proxy server
234
+ */
235
+ async start() {
236
+ return new Promise((resolve) => {
237
+ this.server = http.createServer((req, res) => {
238
+ this.handleRequest(req, res).catch((err) => {
239
+ this.log('error', `Unhandled error: ${err}`);
240
+ this.sendError(res, 500, 'internal_error', 'Internal server error');
241
+ });
242
+ });
243
+ this.server.listen(this.config.port, this.config.host, () => {
244
+ this.log('info', `RelayPlane Proxy listening on http://${this.config.host}:${this.config.port}`);
245
+ resolve();
246
+ });
247
+ });
248
+ }
249
+ /**
250
+ * Stop the proxy server
251
+ */
252
+ async stop() {
253
+ return new Promise((resolve) => {
254
+ if (this.server) {
255
+ this.server.close(() => {
256
+ this.log('info', 'Proxy server stopped');
257
+ resolve();
258
+ });
259
+ }
260
+ else {
261
+ resolve();
262
+ }
263
+ });
264
+ }
265
+ /**
266
+ * Handle incoming request
267
+ */
268
+ async handleRequest(req, res) {
269
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
270
+ // CORS headers
271
+ res.setHeader('Access-Control-Allow-Origin', '*');
272
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
273
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-RelayPlane-Workspace, X-RelayPlane-Agent, X-RelayPlane-Session, X-RelayPlane-Automated');
274
+ // Handle preflight
275
+ if (req.method === 'OPTIONS') {
276
+ res.writeHead(204);
277
+ res.end();
278
+ return;
279
+ }
280
+ // Health check
281
+ if (url.pathname === '/health' || url.pathname === '/') {
282
+ res.writeHead(200, { 'Content-Type': 'application/json' });
283
+ res.end(JSON.stringify({ status: 'ok', version: '0.1.0' }));
284
+ return;
285
+ }
286
+ // OpenAI-compatible chat completions
287
+ if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
288
+ await this.handleChatCompletions(req, res);
289
+ return;
290
+ }
291
+ // Models endpoint (for client compatibility)
292
+ if (url.pathname === '/v1/models' && req.method === 'GET') {
293
+ res.writeHead(200, { 'Content-Type': 'application/json' });
294
+ res.end(JSON.stringify({
295
+ object: 'list',
296
+ data: [
297
+ { id: 'claude-3-5-sonnet', object: 'model', owned_by: 'anthropic' },
298
+ { id: 'claude-3-5-haiku', object: 'model', owned_by: 'anthropic' },
299
+ { id: 'gpt-4o', object: 'model', owned_by: 'openai' },
300
+ { id: 'gpt-4o-mini', object: 'model', owned_by: 'openai' },
301
+ ],
302
+ }));
303
+ return;
304
+ }
305
+ // Policy Management API (Phase 2)
306
+ if (url.pathname === '/v1/policies' && req.method === 'GET') {
307
+ await this.handleListPolicies(req, res);
308
+ return;
309
+ }
310
+ if (url.pathname === '/v1/policies' && req.method === 'POST') {
311
+ await this.handleCreatePolicy(req, res);
312
+ return;
313
+ }
314
+ if (url.pathname.startsWith('/v1/policies/') && req.method === 'GET') {
315
+ const policyId = url.pathname.split('/')[3];
316
+ if (policyId) {
317
+ await this.handleGetPolicy(res, policyId);
318
+ return;
319
+ }
320
+ }
321
+ if (url.pathname.startsWith('/v1/policies/') && req.method === 'PATCH') {
322
+ const policyId = url.pathname.split('/')[3];
323
+ if (policyId) {
324
+ await this.handleUpdatePolicy(req, res, policyId);
325
+ return;
326
+ }
327
+ }
328
+ if (url.pathname.startsWith('/v1/policies/') && req.method === 'DELETE') {
329
+ const policyId = url.pathname.split('/')[3];
330
+ if (policyId) {
331
+ await this.handleDeletePolicy(res, policyId);
332
+ return;
333
+ }
334
+ }
335
+ if (url.pathname === '/v1/policies/test' && req.method === 'POST') {
336
+ await this.handlePolicyTest(req, res);
337
+ return;
338
+ }
339
+ // Budget state endpoint
340
+ if (url.pathname === '/v1/budget' && req.method === 'GET') {
341
+ await this.handleGetBudget(req, res);
342
+ return;
343
+ }
344
+ // ========================================================================
345
+ // Explainability API (Phase 4)
346
+ // ========================================================================
347
+ // GET /v1/runs/{id}/explain - Full decision chain explanation
348
+ if (url.pathname.match(/^\/v1\/runs\/[^/]+\/explain$/) && req.method === 'GET') {
349
+ const runId = url.pathname.split('/')[3];
350
+ const format = url.searchParams.get('format') ?? 'full';
351
+ await this.handleExplainRun(res, runId, format);
352
+ return;
353
+ }
354
+ // GET /v1/runs/{id}/timeline - Timeline view only
355
+ if (url.pathname.match(/^\/v1\/runs\/[^/]+\/timeline$/) && req.method === 'GET') {
356
+ const runId = url.pathname.split('/')[3];
357
+ await this.handleRunTimeline(res, runId);
358
+ return;
359
+ }
360
+ // GET /v1/runs/{id}/decisions - Raw decision chain
361
+ if (url.pathname.match(/^\/v1\/runs\/[^/]+\/decisions$/) && req.method === 'GET') {
362
+ const runId = url.pathname.split('/')[3];
363
+ await this.handleRunDecisions(res, runId);
364
+ return;
365
+ }
366
+ // GET /v1/runs/{id} - Run inspector (all details)
367
+ if (url.pathname.match(/^\/v1\/runs\/[^/]+$/) && req.method === 'GET') {
368
+ const runId = url.pathname.split('/')[3];
369
+ await this.handleRunInspector(res, runId);
370
+ return;
371
+ }
372
+ // GET /v1/runs/compare?ids=run1,run2 - Run comparison
373
+ if (url.pathname === '/v1/runs/compare' && req.method === 'GET') {
374
+ const idsParam = url.searchParams.get('ids');
375
+ const includeDecisions = url.searchParams.get('include_decisions') === 'true';
376
+ await this.handleCompareRuns(res, idsParam, includeDecisions);
377
+ return;
378
+ }
379
+ // POST /v1/simulate/policy - Policy simulation
380
+ if (url.pathname === '/v1/simulate/policy' && req.method === 'POST') {
381
+ await this.handleSimulatePolicy(req, res);
382
+ return;
383
+ }
384
+ // POST /v1/simulate/routing - Routing simulation
385
+ if (url.pathname === '/v1/simulate/routing' && req.method === 'POST') {
386
+ await this.handleSimulateRouting(req, res);
387
+ return;
388
+ }
389
+ // 404 for unknown routes
390
+ this.sendError(res, 404, 'not_found', `Unknown endpoint: ${url.pathname}`);
391
+ }
392
+ // ============================================================================
393
+ // Policy Management Handlers (Phase 2)
394
+ // ============================================================================
395
+ async handleListPolicies(req, res) {
396
+ try {
397
+ const workspaceId = req.headers['x-relayplane-workspace'] ?? this.config.defaultWorkspaceId;
398
+ const policies = await this.policyEngine.listPolicies(workspaceId);
399
+ res.writeHead(200, { 'Content-Type': 'application/json' });
400
+ res.end(JSON.stringify({ policies }));
401
+ }
402
+ catch (err) {
403
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
404
+ }
405
+ }
406
+ async handleCreatePolicy(req, res) {
407
+ try {
408
+ const body = await this.readBody(req);
409
+ const policy = JSON.parse(body);
410
+ const policyId = await this.policyEngine.createPolicy(policy);
411
+ const created = await this.policyEngine.getPolicy(policyId);
412
+ res.writeHead(201, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ policy: created }));
414
+ }
415
+ catch (err) {
416
+ this.sendError(res, 400, 'invalid_request', err instanceof Error ? err.message : 'Invalid policy');
417
+ }
418
+ }
419
+ async handleGetPolicy(res, policyId) {
420
+ try {
421
+ const policy = await this.policyEngine.getPolicy(policyId);
422
+ if (!policy) {
423
+ this.sendError(res, 404, 'not_found', 'Policy not found');
424
+ return;
425
+ }
426
+ res.writeHead(200, { 'Content-Type': 'application/json' });
427
+ res.end(JSON.stringify({ policy }));
428
+ }
429
+ catch (err) {
430
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
431
+ }
432
+ }
433
+ async handleUpdatePolicy(req, res, policyId) {
434
+ try {
435
+ const body = await this.readBody(req);
436
+ const updates = JSON.parse(body);
437
+ await this.policyEngine.updatePolicy(policyId, updates);
438
+ const updated = await this.policyEngine.getPolicy(policyId);
439
+ res.writeHead(200, { 'Content-Type': 'application/json' });
440
+ res.end(JSON.stringify({ policy: updated }));
441
+ }
442
+ catch (err) {
443
+ this.sendError(res, 400, 'invalid_request', err instanceof Error ? err.message : 'Invalid update');
444
+ }
445
+ }
446
+ async handleDeletePolicy(res, policyId) {
447
+ try {
448
+ await this.policyEngine.deletePolicy(policyId);
449
+ res.writeHead(204);
450
+ res.end();
451
+ }
452
+ catch (err) {
453
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
454
+ }
455
+ }
456
+ async handlePolicyTest(req, res) {
457
+ try {
458
+ const body = await this.readBody(req);
459
+ const testRequest = JSON.parse(body);
460
+ const decision = await this.policyEngine.dryRun(testRequest);
461
+ res.writeHead(200, { 'Content-Type': 'application/json' });
462
+ res.end(JSON.stringify({ decision }));
463
+ }
464
+ catch (err) {
465
+ this.sendError(res, 400, 'invalid_request', err instanceof Error ? err.message : 'Invalid test request');
466
+ }
467
+ }
468
+ async handleGetBudget(req, res) {
469
+ try {
470
+ const workspaceId = req.headers['x-relayplane-workspace'] ?? this.config.defaultWorkspaceId;
471
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
472
+ const scopeType = (url.searchParams.get('scope_type') ?? 'workspace');
473
+ const scopeId = url.searchParams.get('scope_id') ?? workspaceId;
474
+ const period = (url.searchParams.get('period') ?? 'day');
475
+ const state = await this.policyEngine.getBudgetState(workspaceId, scopeType, scopeId, period);
476
+ if (!state) {
477
+ // Return empty state if no budget configured
478
+ res.writeHead(200, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ budget_state: null, message: 'No budget state found' }));
480
+ return;
481
+ }
482
+ res.writeHead(200, { 'Content-Type': 'application/json' });
483
+ res.end(JSON.stringify({ budget_state: state }));
484
+ }
485
+ catch (err) {
486
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
487
+ }
488
+ }
489
+ // ============================================================================
490
+ // Explainability Handlers (Phase 4)
491
+ // ============================================================================
492
+ /**
493
+ * Handle GET /v1/runs/{id}/explain - Full decision chain explanation
494
+ */
495
+ async handleExplainRun(res, runId, format) {
496
+ try {
497
+ const explanation = await this.explainer.explain(runId, format);
498
+ if (!explanation) {
499
+ this.sendError(res, 404, 'not_found', `Run not found: ${runId}`);
500
+ return;
501
+ }
502
+ res.writeHead(200, { 'Content-Type': 'application/json' });
503
+ res.end(JSON.stringify({
504
+ run_id: runId,
505
+ format,
506
+ chain: explanation.chain,
507
+ timeline: explanation.timeline,
508
+ narrative: explanation.narrative,
509
+ debug_info: explanation.debug_info,
510
+ }));
511
+ }
512
+ catch (err) {
513
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
514
+ }
515
+ }
516
+ /**
517
+ * Handle GET /v1/runs/{id}/timeline - Timeline view only
518
+ */
519
+ async handleRunTimeline(res, runId) {
520
+ try {
521
+ const timeline = await this.explainer.getTimeline(runId);
522
+ if (timeline.length === 0) {
523
+ this.sendError(res, 404, 'not_found', `Run not found: ${runId}`);
524
+ return;
525
+ }
526
+ res.writeHead(200, { 'Content-Type': 'application/json' });
527
+ res.end(JSON.stringify({ run_id: runId, timeline }));
528
+ }
529
+ catch (err) {
530
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
531
+ }
532
+ }
533
+ /**
534
+ * Handle GET /v1/runs/{id}/decisions - Raw decision chain
535
+ */
536
+ async handleRunDecisions(res, runId) {
537
+ try {
538
+ const chain = await this.explainer.getDecisionChain(runId);
539
+ if (!chain) {
540
+ this.sendError(res, 404, 'not_found', `Run not found: ${runId}`);
541
+ return;
542
+ }
543
+ res.writeHead(200, { 'Content-Type': 'application/json' });
544
+ res.end(JSON.stringify({
545
+ run_id: runId,
546
+ decisions: chain.decisions,
547
+ summary: chain.summary,
548
+ insights: chain.insights,
549
+ }));
550
+ }
551
+ catch (err) {
552
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
553
+ }
554
+ }
555
+ /**
556
+ * Handle GET /v1/runs/{id} - Run inspector (all details)
557
+ */
558
+ async handleRunInspector(res, runId) {
559
+ try {
560
+ // Get run from ledger
561
+ const run = await this.ledger.getRun(runId);
562
+ if (!run) {
563
+ this.sendError(res, 404, 'not_found', `Run not found: ${runId}`);
564
+ return;
565
+ }
566
+ // Get events
567
+ const events = await this.ledger.getRunEvents(runId);
568
+ // Get decision chain
569
+ const chain = await this.explainer.getDecisionChain(runId);
570
+ res.writeHead(200, { 'Content-Type': 'application/json' });
571
+ res.end(JSON.stringify({
572
+ run,
573
+ events,
574
+ decision_chain: chain,
575
+ }));
576
+ }
577
+ catch (err) {
578
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
579
+ }
580
+ }
581
+ /**
582
+ * Handle GET /v1/runs/compare?ids=run1,run2 - Run comparison
583
+ */
584
+ async handleCompareRuns(res, idsParam, includeDecisions) {
585
+ try {
586
+ if (!idsParam) {
587
+ this.sendError(res, 400, 'invalid_request', 'Missing required parameter: ids');
588
+ return;
589
+ }
590
+ const runIds = idsParam.split(',').map(id => id.trim()).filter(Boolean);
591
+ if (runIds.length < 2) {
592
+ this.sendError(res, 400, 'invalid_request', 'At least 2 run IDs required for comparison');
593
+ return;
594
+ }
595
+ const comparison = await this.comparator.compare(runIds, {
596
+ includeDecisionDiff: includeDecisions,
597
+ });
598
+ if (!comparison) {
599
+ this.sendError(res, 404, 'not_found', 'One or more runs not found');
600
+ return;
601
+ }
602
+ res.writeHead(200, { 'Content-Type': 'application/json' });
603
+ res.end(JSON.stringify(comparison));
604
+ }
605
+ catch (err) {
606
+ this.sendError(res, 500, 'internal_error', err instanceof Error ? err.message : 'Unknown error');
607
+ }
608
+ }
609
+ /**
610
+ * Handle POST /v1/simulate/policy - Policy simulation
611
+ */
612
+ async handleSimulatePolicy(req, res) {
613
+ try {
614
+ const body = await this.readBody(req);
615
+ const request = JSON.parse(body);
616
+ // Use workspace from header if not in body
617
+ if (!request.workspace_id) {
618
+ request.workspace_id = req.headers['x-relayplane-workspace'] ?? this.config.defaultWorkspaceId;
619
+ }
620
+ const result = await this.simulator.simulatePolicy(request);
621
+ res.writeHead(200, { 'Content-Type': 'application/json' });
622
+ res.end(JSON.stringify(result));
623
+ }
624
+ catch (err) {
625
+ this.sendError(res, 400, 'invalid_request', err instanceof Error ? err.message : 'Invalid simulation request');
626
+ }
627
+ }
628
+ /**
629
+ * Handle POST /v1/simulate/routing - Routing simulation
630
+ */
631
+ async handleSimulateRouting(req, res) {
632
+ try {
633
+ const body = await this.readBody(req);
634
+ const request = JSON.parse(body);
635
+ // Use workspace from header if not in body
636
+ if (!request.workspace_id) {
637
+ request.workspace_id = req.headers['x-relayplane-workspace'] ?? this.config.defaultWorkspaceId;
638
+ }
639
+ const result = await this.simulator.simulateRouting(request);
640
+ res.writeHead(200, { 'Content-Type': 'application/json' });
641
+ res.end(JSON.stringify(result));
642
+ }
643
+ catch (err) {
644
+ this.sendError(res, 400, 'invalid_request', err instanceof Error ? err.message : 'Invalid simulation request');
645
+ }
646
+ }
647
+ /**
648
+ * Handle /v1/chat/completions
649
+ */
650
+ async handleChatCompletions(req, res) {
651
+ const startTime = Date.now();
652
+ let runId = null;
653
+ try {
654
+ // Parse request body
655
+ const body = await this.readBody(req);
656
+ const request = JSON.parse(body);
657
+ // Extract metadata from headers
658
+ const workspaceId = req.headers['x-relayplane-workspace'] ?? this.config.defaultWorkspaceId;
659
+ const agentId = req.headers['x-relayplane-agent'] ?? this.config.defaultAgentId;
660
+ const sessionId = req.headers['x-relayplane-session'];
661
+ const isAutomated = req.headers['x-relayplane-automated'] === 'true';
662
+ // Determine provider
663
+ const provider = getProviderForModel(request.model);
664
+ // Detect auth type from Authorization header
665
+ const authHeader = req.headers['authorization'];
666
+ const authType = this.detectAuthType(authHeader);
667
+ const executionMode = isAutomated ? 'background' : 'interactive';
668
+ // Validate auth via Auth Gate
669
+ const authResult = await this.authGate.validate({
670
+ workspace_id: workspaceId,
671
+ metadata: {
672
+ session_type: isAutomated ? 'background' : 'interactive',
673
+ headers: {
674
+ 'X-RelayPlane-Automated': isAutomated ? 'true' : 'false',
675
+ },
676
+ },
677
+ });
678
+ // Start ledger run
679
+ runId = await this.ledger.startRun({
680
+ workspace_id: workspaceId,
681
+ agent_id: agentId,
682
+ session_id: sessionId,
683
+ provider,
684
+ model: request.model,
685
+ auth_type: authType,
686
+ execution_mode: executionMode,
687
+ compliance_mode: this.config.defaultAuthEnforcementMode,
688
+ auth_risk: authResult.ledger_flags.auth_risk,
689
+ policy_override: authResult.ledger_flags.policy_override,
690
+ });
691
+ // Record auth validation
692
+ await this.authGate.emitAuthEvent(runId, authResult);
693
+ // Check if auth was denied
694
+ if (!authResult.allow) {
695
+ const latencyMs = Date.now() - startTime;
696
+ await this.ledger.completeRun(runId, {
697
+ status: 'failed',
698
+ input_tokens: 0,
699
+ output_tokens: 0,
700
+ total_tokens: 0,
701
+ cost_usd: 0,
702
+ latency_ms: latencyMs,
703
+ error: {
704
+ code: 'auth_denied',
705
+ message: authResult.reason ?? 'Authentication denied',
706
+ retryable: false,
707
+ },
708
+ });
709
+ this.sendError(res, 403, 'auth_denied', authResult.reason ?? 'Authentication denied', runId, authResult.guidance_url);
710
+ return;
711
+ }
712
+ // Evaluate policies (Phase 2)
713
+ if (this.config.enforcePolicies) {
714
+ const estimatedCost = this.policyEngine.estimateCost(request.model, provider, request.messages?.reduce((sum, m) => sum + (m.content?.length ?? 0) / 4, 0) ?? 1000, // Rough token estimate
715
+ request.max_tokens ?? 1000);
716
+ const policyDecision = await this.policyEngine.evaluate({
717
+ workspace_id: workspaceId,
718
+ agent_id: agentId,
719
+ session_id: sessionId,
720
+ run_id: runId,
721
+ request: {
722
+ model: request.model,
723
+ provider,
724
+ estimated_cost_usd: estimatedCost,
725
+ estimated_tokens: request.max_tokens,
726
+ context_size: request.messages?.reduce((sum, m) => sum + (m.content?.length ?? 0), 0),
727
+ tools_requested: request.tools?.map((t) => t.function?.name).filter((n) => !!n),
728
+ },
729
+ });
730
+ // Record policy evaluation in ledger
731
+ await this.ledger.recordPolicyEvaluation(runId, policyDecision.policies_evaluated.map((p) => ({
732
+ policy_id: p.policy_id,
733
+ policy_name: p.policy_name,
734
+ matched: p.matched,
735
+ action_taken: p.action_taken,
736
+ })));
737
+ // Check if policy denied the request
738
+ if (!policyDecision.allow) {
739
+ const latencyMs = Date.now() - startTime;
740
+ await this.ledger.completeRun(runId, {
741
+ status: 'failed',
742
+ input_tokens: 0,
743
+ output_tokens: 0,
744
+ total_tokens: 0,
745
+ cost_usd: 0,
746
+ latency_ms: latencyMs,
747
+ error: {
748
+ code: policyDecision.approval_required ? 'approval_required' : 'policy_denied',
749
+ message: policyDecision.reason ?? 'Policy denied the request',
750
+ retryable: false,
751
+ },
752
+ });
753
+ if (policyDecision.approval_required) {
754
+ this.sendError(res, 403, 'approval_required', policyDecision.reason ?? 'Approval required', runId);
755
+ }
756
+ else {
757
+ this.sendError(res, 403, 'policy_denied', policyDecision.reason ?? 'Policy denied the request', runId);
758
+ }
759
+ return;
760
+ }
761
+ // Apply any modifications from policy (e.g., model downgrade, context cap)
762
+ if (policyDecision.modified_request) {
763
+ if (policyDecision.modified_request.model) {
764
+ request.model = policyDecision.modified_request.model;
765
+ }
766
+ }
767
+ // Log budget warning if present
768
+ if (policyDecision.budget_warning) {
769
+ this.log('info', `Budget warning for ${workspaceId}: ${policyDecision.budget_warning}`);
770
+ }
771
+ }
772
+ // Record routing decision
773
+ await this.ledger.recordRouting(runId, {
774
+ selected_provider: provider,
775
+ selected_model: request.model,
776
+ reason: 'Direct model selection by client',
777
+ });
778
+ // Forward to provider
779
+ const providerConfig = this.config.providers?.[provider];
780
+ if (!providerConfig?.apiKey) {
781
+ const latencyMs = Date.now() - startTime;
782
+ await this.ledger.completeRun(runId, {
783
+ status: 'failed',
784
+ input_tokens: 0,
785
+ output_tokens: 0,
786
+ total_tokens: 0,
787
+ cost_usd: 0,
788
+ latency_ms: latencyMs,
789
+ error: {
790
+ code: 'provider_not_configured',
791
+ message: `Provider ${provider} is not configured`,
792
+ retryable: false,
793
+ },
794
+ });
795
+ this.sendError(res, 500, 'provider_not_configured', `Provider ${provider} is not configured`, runId);
796
+ return;
797
+ }
798
+ // Make provider request
799
+ const providerResponse = await this.forwardToProvider(provider, request, providerConfig, runId);
800
+ const latencyMs = Date.now() - startTime;
801
+ if (providerResponse.success) {
802
+ const costUsd = this.estimateCost(provider, providerResponse.usage);
803
+ // Complete run successfully
804
+ await this.ledger.completeRun(runId, {
805
+ status: 'completed',
806
+ input_tokens: providerResponse.usage?.prompt_tokens ?? 0,
807
+ output_tokens: providerResponse.usage?.completion_tokens ?? 0,
808
+ total_tokens: providerResponse.usage?.total_tokens ?? 0,
809
+ cost_usd: costUsd,
810
+ latency_ms: latencyMs,
811
+ ttft_ms: providerResponse.ttft_ms,
812
+ });
813
+ // Record spend for budget tracking (Phase 2)
814
+ if (this.config.enforcePolicies) {
815
+ await this.policyEngine.recordSpend(workspaceId, agentId, runId, costUsd);
816
+ }
817
+ // Add run_id to response
818
+ const responseData = providerResponse.data;
819
+ const responseWithMeta = {
820
+ ...responseData,
821
+ relayplane: {
822
+ run_id: runId,
823
+ latency_ms: latencyMs,
824
+ ttft_ms: providerResponse.ttft_ms,
825
+ },
826
+ };
827
+ res.writeHead(200, { 'Content-Type': 'application/json' });
828
+ res.end(JSON.stringify(responseWithMeta));
829
+ }
830
+ else {
831
+ // Complete run with failure
832
+ await this.ledger.completeRun(runId, {
833
+ status: 'failed',
834
+ input_tokens: 0,
835
+ output_tokens: 0,
836
+ total_tokens: 0,
837
+ cost_usd: 0,
838
+ latency_ms: latencyMs,
839
+ error: {
840
+ code: providerResponse.error?.code ?? 'provider_error',
841
+ message: providerResponse.error?.message ?? 'Provider request failed',
842
+ provider_error: providerResponse.error?.raw,
843
+ retryable: providerResponse.error?.retryable ?? false,
844
+ },
845
+ });
846
+ this.sendError(res, providerResponse.error?.status ?? 500, providerResponse.error?.code ?? 'provider_error', providerResponse.error?.message ?? 'Provider request failed', runId);
847
+ }
848
+ }
849
+ catch (err) {
850
+ const latencyMs = Date.now() - startTime;
851
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
852
+ if (runId) {
853
+ await this.ledger.completeRun(runId, {
854
+ status: 'failed',
855
+ input_tokens: 0,
856
+ output_tokens: 0,
857
+ total_tokens: 0,
858
+ cost_usd: 0,
859
+ latency_ms: latencyMs,
860
+ error: {
861
+ code: 'internal_error',
862
+ message: errorMessage,
863
+ retryable: false,
864
+ },
865
+ });
866
+ }
867
+ this.sendError(res, 500, 'internal_error', errorMessage, runId ?? undefined);
868
+ }
869
+ }
870
+ /**
871
+ * Forward request to provider
872
+ */
873
+ async forwardToProvider(provider, request, config, runId) {
874
+ const endpoint = PROVIDER_ENDPOINTS[provider];
875
+ if (!endpoint) {
876
+ return {
877
+ success: false,
878
+ error: {
879
+ code: 'unknown_provider',
880
+ message: `Unknown provider: ${provider}`,
881
+ status: 500,
882
+ retryable: false,
883
+ },
884
+ };
885
+ }
886
+ const baseUrl = config.baseUrl ?? endpoint.baseUrl;
887
+ const url = `${baseUrl}/chat/completions`;
888
+ const headers = {
889
+ 'Content-Type': 'application/json',
890
+ };
891
+ // Set auth header
892
+ if (provider === 'anthropic') {
893
+ headers['x-api-key'] = config.apiKey;
894
+ headers['anthropic-version'] = '2023-06-01';
895
+ }
896
+ else {
897
+ headers['Authorization'] = `Bearer ${config.apiKey}`;
898
+ }
899
+ try {
900
+ const ttftStart = Date.now();
901
+ const response = await fetch(url, {
902
+ method: 'POST',
903
+ headers,
904
+ body: JSON.stringify(request),
905
+ });
906
+ // Record provider call
907
+ await this.ledger.recordProviderCall(runId, {
908
+ provider,
909
+ model: request.model,
910
+ attempt: 1,
911
+ ttft_ms: Date.now() - ttftStart,
912
+ });
913
+ if (!response.ok) {
914
+ const errorBody = await response.text();
915
+ let parsedError;
916
+ try {
917
+ parsedError = JSON.parse(errorBody);
918
+ }
919
+ catch {
920
+ parsedError = errorBody;
921
+ }
922
+ return {
923
+ success: false,
924
+ error: {
925
+ code: `provider_${response.status}`,
926
+ message: `Provider returned ${response.status}`,
927
+ status: response.status,
928
+ retryable: response.status >= 500 || response.status === 429,
929
+ raw: parsedError,
930
+ },
931
+ };
932
+ }
933
+ const data = await response.json();
934
+ const ttftMs = Date.now() - ttftStart;
935
+ return {
936
+ success: true,
937
+ data,
938
+ usage: data.usage,
939
+ ttft_ms: ttftMs,
940
+ };
941
+ }
942
+ catch (err) {
943
+ return {
944
+ success: false,
945
+ error: {
946
+ code: 'network_error',
947
+ message: err instanceof Error ? err.message : 'Network error',
948
+ status: 500,
949
+ retryable: true,
950
+ raw: err,
951
+ },
952
+ };
953
+ }
954
+ }
955
+ /**
956
+ * Detect auth type from Authorization header
957
+ */
958
+ detectAuthType(authHeader) {
959
+ if (!authHeader)
960
+ return 'api';
961
+ // Consumer auth typically uses session tokens or OAuth
962
+ // API auth uses API keys starting with specific prefixes
963
+ if (authHeader.includes('sk-ant-') ||
964
+ authHeader.includes('sk-') ||
965
+ authHeader.includes('Bearer sk-')) {
966
+ return 'api';
967
+ }
968
+ // Default to consumer if it looks like a session token
969
+ if (authHeader.startsWith('Bearer ') && authHeader.length > 100) {
970
+ return 'consumer';
971
+ }
972
+ return 'api';
973
+ }
974
+ /**
975
+ * Estimate cost based on provider and usage
976
+ */
977
+ estimateCost(provider, usage) {
978
+ if (!usage)
979
+ return 0;
980
+ // Approximate pricing per 1K tokens
981
+ const pricing = {
982
+ anthropic: { input: 0.003, output: 0.015 }, // Claude 3.5 Sonnet
983
+ openai: { input: 0.005, output: 0.015 }, // GPT-4o
984
+ openrouter: { input: 0.003, output: 0.015 }, // Varies
985
+ };
986
+ const rates = pricing[provider] ?? { input: 0.003, output: 0.015 };
987
+ return ((usage.prompt_tokens / 1000) * rates.input + (usage.completion_tokens / 1000) * rates.output);
988
+ }
989
+ /**
990
+ * Read request body
991
+ */
992
+ readBody(req) {
993
+ return new Promise((resolve, reject) => {
994
+ let body = '';
995
+ req.on('data', (chunk) => (body += chunk));
996
+ req.on('end', () => resolve(body));
997
+ req.on('error', reject);
998
+ });
999
+ }
1000
+ /**
1001
+ * Send error response
1002
+ */
1003
+ sendError(res, status, code, message, runId, guidanceUrl) {
1004
+ const error = {
1005
+ error: {
1006
+ message,
1007
+ type: 'relayplane_error',
1008
+ code,
1009
+ run_id: runId,
1010
+ },
1011
+ };
1012
+ if (guidanceUrl) {
1013
+ error.error['guidance_url'] = guidanceUrl;
1014
+ }
1015
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1016
+ res.end(JSON.stringify(error));
1017
+ }
1018
+ /**
1019
+ * Log message
1020
+ */
1021
+ log(level, message) {
1022
+ if (this.config.verbose || level === 'error') {
1023
+ const timestamp = new Date().toISOString();
1024
+ console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Get the ledger instance (useful for testing)
1029
+ */
1030
+ getLedger() {
1031
+ return this.ledger;
1032
+ }
1033
+ /**
1034
+ * Get the auth gate instance (useful for testing)
1035
+ */
1036
+ getAuthGate() {
1037
+ return this.authGate;
1038
+ }
1039
+ /**
1040
+ * Get the policy engine instance (useful for testing and policy management)
1041
+ */
1042
+ getPolicyEngine() {
1043
+ return this.policyEngine;
1044
+ }
1045
+ /**
1046
+ * Get the routing engine instance (Phase 3)
1047
+ */
1048
+ getRoutingEngine() {
1049
+ return this.routingEngine;
1050
+ }
1051
+ /**
1052
+ * Get the capability registry instance (Phase 3)
1053
+ */
1054
+ getCapabilityRegistry() {
1055
+ return this.capabilityRegistry;
1056
+ }
1057
+ /**
1058
+ * Get the provider manager instance (Phase 3)
1059
+ */
1060
+ getProviderManager() {
1061
+ return this.providerManager;
1062
+ }
1063
+ /**
1064
+ * Get the explanation engine instance (Phase 4)
1065
+ */
1066
+ getExplainer() {
1067
+ return this.explainer;
1068
+ }
1069
+ /**
1070
+ * Get the run comparator instance (Phase 4)
1071
+ */
1072
+ getComparator() {
1073
+ return this.comparator;
1074
+ }
1075
+ /**
1076
+ * Get the simulator instance (Phase 4)
1077
+ */
1078
+ getSimulator() {
1079
+ return this.simulator;
1080
+ }
1081
+ }
1082
+ exports.ProxyServer = ProxyServer;
1083
+ /**
1084
+ * Create a new proxy server
1085
+ */
1086
+ function createProxyServer(config) {
1087
+ return new ProxyServer(config);
1088
+ }
1089
+ //# sourceMappingURL=server.js.map