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