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