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