@relayplane/proxy 1.3.2 → 1.5.0

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