coding-tool-x 3.7.9 → 3.7.11

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.
Files changed (33) hide show
  1. package/dist/web/assets/{Analytics-D14GGF2y.js → Analytics-Ck_00Brj.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-C0TeUSZg.js → ConfigTemplates-Cpw2vUa8.js} +1 -1
  3. package/dist/web/assets/Home-Bft3Kztl.js +1 -0
  4. package/dist/web/assets/Home-CHZJkkjH.css +1 -0
  5. package/dist/web/assets/{PluginManager-DPZvPrT4.js → PluginManager-2RlvEJCT.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DUaiCvsF.js → ProjectList-_ecCngpl.js} +1 -1
  7. package/dist/web/assets/{SessionList-XdHNM61o.js → SessionList-DlWcw3hO.js} +1 -1
  8. package/dist/web/assets/{SkillManager-I4LFlsVN.js → SkillManager-CnY7IXkX.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-zMI7Lhzf.js → WorkspaceManager-3mqghi09.js} +1 -1
  10. package/dist/web/assets/{index-C5nLTbOk.css → index-BceXgSSe.css} +1 -1
  11. package/dist/web/assets/index-k5oP7c-A.js +2 -0
  12. package/dist/web/index.html +2 -2
  13. package/package.json +1 -1
  14. package/src/commands/toggle-proxy.js +7 -8
  15. package/src/commands/ui.js +11 -1
  16. package/src/config/default.js +1 -1
  17. package/src/index.js +41 -15
  18. package/src/server/api/omp-channels.js +16 -5
  19. package/src/server/api/omp-config.js +1 -1
  20. package/src/server/api/omp-proxy.js +13 -42
  21. package/src/server/index.js +3 -3
  22. package/src/server/omp-proxy-server.js +129 -10
  23. package/src/server/services/channel-scheduler.js +59 -9
  24. package/src/server/services/omp-auth-gateway-client.js +62 -0
  25. package/src/server/services/omp-channels.js +109 -12
  26. package/src/server/services/omp-gateway-routing.js +295 -0
  27. package/src/server/services/omp-gateway.js +919 -0
  28. package/src/server/services/omp-session-log-observer.js +138 -0
  29. package/src/server/services/omp-sessions.js +80 -0
  30. package/src/server/services/omp-settings-manager.js +358 -89
  31. package/dist/web/assets/Home-CUh_BsGm.js +0 -1
  32. package/dist/web/assets/Home-CqnBXFxj.css +0 -1
  33. package/dist/web/assets/index-CCP7sRMP.js +0 -2
@@ -0,0 +1,919 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const http = require('http');
5
+ const https = require('https');
6
+ const httpProxy = require('http-proxy');
7
+ const { URL } = require('url');
8
+ const { allocateChannel, releaseChannel } = require('./channel-scheduler');
9
+ const { recordSuccess, recordFailure } = require('./channel-health');
10
+ const { recordRequest: recordOmpRequest } = require('./omp-statistics-service');
11
+ const {
12
+ createTokenData,
13
+ mergeUsageIntoTokenData,
14
+ parseNonStreamingUsage,
15
+ parseSSEEventText,
16
+ parseSSEUsage,
17
+ splitSSEEvents
18
+ } = require('./base/response-usage-parser');
19
+ const {
20
+ publishFailureLog: publishSharedFailureLog,
21
+ publishUsageLog: publishSharedUsageLog
22
+ } = require('./proxy-log-helper');
23
+ const { attachServerShutdownHandling, expediteServerShutdown } = require('./server-shutdown');
24
+ const { resolveOmpGatewayRoute } = require('./omp-gateway-routing');
25
+ const { broadcastLog } = require('../websocket-server');
26
+
27
+ const RETRYABLE_STATUS = new Set([401, 403, 429, 502, 503, 504]);
28
+ const MAX_REQUEST_BYTES = 100 * 1024 * 1024;
29
+
30
+ function createAbortError(message = 'OMP downstream client disconnected') {
31
+ return Object.assign(new Error(message), { name: 'AbortError' });
32
+ }
33
+
34
+ function normalizeHeaderMap(value) {
35
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
36
+ }
37
+
38
+ function collectChannelHeaders(channel = {}) {
39
+ return {
40
+ ...normalizeHeaderMap(channel.providerConfig?.headers),
41
+ ...normalizeHeaderMap(channel.headers)
42
+ };
43
+ }
44
+
45
+ function buildUpstreamRequestPath(baseUrl, requestUrl) {
46
+ const base = new URL(baseUrl);
47
+ const request = new URL(requestUrl, 'http://127.0.0.1');
48
+ const basePath = base.pathname === '/' ? '' : base.pathname.replace(/\/+$/, '');
49
+ const requestedPath = request.pathname || '/';
50
+ const pathAlreadyIncludesBase = basePath
51
+ && (requestedPath === basePath || requestedPath.startsWith(`${basePath}/`));
52
+ const pathname = pathAlreadyIncludesBase
53
+ ? requestedPath
54
+ : `${basePath}/${requestedPath.replace(/^\/+/, '')}` || '/';
55
+ const search = new URLSearchParams(base.searchParams);
56
+ request.searchParams.forEach((value, name) => search.set(name, value));
57
+ const query = search.toString();
58
+ return `${pathname.startsWith('/') ? pathname : `/${pathname}`}${query ? `?${query}` : ''}`;
59
+ }
60
+
61
+ function rewriteCredential(headers, requestUrl, route, channel, options = {}) {
62
+ const next = { ...headers };
63
+ delete next.host;
64
+ delete next['content-length'];
65
+ delete next['accept-encoding'];
66
+ if (!options.preserveConnection) {
67
+ delete next.connection;
68
+ }
69
+
70
+ const authHeaders = ['authorization', 'x-api-key', 'x-goog-api-key', 'api-key'];
71
+ const upstreamKey = String(channel.apiKey || '');
72
+ for (const name of authHeaders) {
73
+ delete next[name];
74
+ }
75
+
76
+ const url = new URL(requestUrl, 'http://127.0.0.1');
77
+ for (const name of ['key', 'api_key', 'access_token', 'token']) {
78
+ url.searchParams.delete(name);
79
+ }
80
+
81
+ if (channel.authMode !== 'none' && upstreamKey) {
82
+ if (route.authMode === 'oauth') {
83
+ next.authorization = `Bearer ${upstreamKey}`;
84
+ } else if (route.providerApi === 'anthropic-messages') {
85
+ next['x-api-key'] = upstreamKey;
86
+ } else if (route.providerApi === 'azure-openai-responses') {
87
+ next['api-key'] = upstreamKey;
88
+ } else if (route.providerApi.startsWith('google-')) {
89
+ next['x-goog-api-key'] = upstreamKey;
90
+ } else {
91
+ next.authorization = `Bearer ${upstreamKey}`;
92
+ }
93
+ }
94
+
95
+ Object.entries(collectChannelHeaders(channel)).forEach(([name, value]) => {
96
+ next[String(name).toLowerCase()] = String(value);
97
+ });
98
+ return { headers: next, path: `${url.pathname}${url.search}` };
99
+ }
100
+
101
+ function readRequestBody(req) {
102
+ return new Promise((resolve, reject) => {
103
+ const chunks = [];
104
+ let size = 0;
105
+ let settled = false;
106
+ const fail = (error) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ reject(error);
110
+ };
111
+ req.on('data', (chunk) => {
112
+ if (settled) return;
113
+ size += chunk.length;
114
+ if (size > MAX_REQUEST_BYTES) {
115
+ fail(Object.assign(new Error('OMP gateway request body is too large'), { statusCode: 413 }));
116
+ req.destroy();
117
+ return;
118
+ }
119
+ chunks.push(chunk);
120
+ });
121
+ req.on('end', () => {
122
+ if (settled) return;
123
+ settled = true;
124
+ resolve(Buffer.concat(chunks));
125
+ });
126
+ req.on('error', fail);
127
+ req.on('aborted', () => fail(new Error('Client aborted request')));
128
+ });
129
+ }
130
+
131
+ function extractModelId(body, contentType = '') {
132
+ if (!body?.length || !String(contentType).includes('json')) return '';
133
+ try {
134
+ const parsed = JSON.parse(body.toString('utf8'));
135
+ if (typeof parsed.model === 'string') return parsed.model.trim();
136
+ if (parsed.model && typeof parsed.model === 'object') {
137
+ return String(parsed.model.id || parsed.model.name || '').trim();
138
+ }
139
+ return String(parsed.modelId || parsed.model_id || '').trim();
140
+ } catch {
141
+ return '';
142
+ }
143
+ }
144
+
145
+ function normalizeModelId(value) {
146
+ return String(value || '').trim().replace(/:(minimal|low|medium|high|xhigh|off)$/, '');
147
+ }
148
+
149
+ function channelSupportsModel(channel, modelId) {
150
+ const requested = normalizeModelId(modelId).toLowerCase();
151
+ if (!requested) return true;
152
+ const declared = [];
153
+ (Array.isArray(channel.allowedModels) ? channel.allowedModels : []).forEach(value => declared.push(value));
154
+ (Array.isArray(channel.models) ? channel.models : []).forEach((model) => {
155
+ declared.push(typeof model === 'string' ? model : (model?.id || model?.name));
156
+ });
157
+ if (channel.model) declared.push(channel.model);
158
+ const normalized = [...new Set(declared.map(normalizeModelId).filter(Boolean).map(value => value.toLowerCase()))];
159
+ return normalized.length === 0 || normalized.includes(requested);
160
+ }
161
+
162
+ function writeJson(res, statusCode, payload) {
163
+ if (res.headersSent || res.destroyed) return;
164
+ const body = Buffer.from(JSON.stringify(payload));
165
+ res.writeHead(statusCode, {
166
+ 'content-type': 'application/json; charset=utf-8',
167
+ 'content-length': body.length
168
+ });
169
+ res.end(body);
170
+ }
171
+
172
+ function constantTimeEqual(left, right) {
173
+ const leftBuffer = Buffer.from(String(left || ''));
174
+ const rightBuffer = Buffer.from(String(right || ''));
175
+ return leftBuffer.length === rightBuffer.length
176
+ && crypto.timingSafeEqual(leftBuffer, rightBuffer);
177
+ }
178
+
179
+ function hasValidCapability(req, parsedUrl, route) {
180
+ if (route.authMode === 'none') return true;
181
+ const candidates = [
182
+ req.headers.authorization,
183
+ req.headers['x-api-key'],
184
+ req.headers['x-goog-api-key'],
185
+ req.headers['api-key'],
186
+ parsedUrl.searchParams.get('key'),
187
+ parsedUrl.searchParams.get('api_key'),
188
+ parsedUrl.searchParams.get('access_token'),
189
+ parsedUrl.searchParams.get('token')
190
+ ].flatMap((value) => Array.isArray(value) ? value : [value])
191
+ .filter(value => value !== undefined && value !== null)
192
+ .map((value) => String(value).replace(/^Bearer\s+/i, '').trim());
193
+ return candidates.some(candidate => constantTimeEqual(candidate, route.capability));
194
+ }
195
+
196
+ function mergeParsedUsage(tokenData, value, eventType = '') {
197
+ if (Array.isArray(value)) {
198
+ value.forEach(item => mergeParsedUsage(tokenData, item, eventType));
199
+ return;
200
+ }
201
+ mergeUsageIntoTokenData(
202
+ tokenData,
203
+ eventType ? parseSSEUsage(value, eventType) : parseNonStreamingUsage(value)
204
+ );
205
+ }
206
+
207
+ function createResponseUsageMonitor(contentType = '') {
208
+ const tokenData = createTokenData();
209
+ const streaming = String(contentType).toLowerCase().includes('text/event-stream');
210
+ const chunks = [];
211
+ let bufferedBytes = 0;
212
+ let sseBuffer = '';
213
+ let observationDisabled = false;
214
+
215
+ const parseEvent = (eventText) => {
216
+ const event = parseSSEEventText(eventText);
217
+ if (!event) return;
218
+ try {
219
+ mergeParsedUsage(tokenData, JSON.parse(event.data), event.eventType);
220
+ } catch {
221
+ // Usage observation must never alter or reject the transparent response.
222
+ }
223
+ };
224
+
225
+ return {
226
+ observe(chunk) {
227
+ if (observationDisabled) return;
228
+ if (streaming) {
229
+ if (Buffer.byteLength(sseBuffer) + chunk.length > MAX_REQUEST_BYTES) {
230
+ observationDisabled = true;
231
+ sseBuffer = '';
232
+ return;
233
+ }
234
+ sseBuffer += chunk.toString('utf8');
235
+ const split = splitSSEEvents(sseBuffer);
236
+ split.events.forEach(parseEvent);
237
+ sseBuffer = split.remainder;
238
+ } else if (bufferedBytes + chunk.length <= MAX_REQUEST_BYTES) {
239
+ chunks.push(Buffer.from(chunk));
240
+ bufferedBytes += chunk.length;
241
+ } else {
242
+ observationDisabled = true;
243
+ chunks.length = 0;
244
+ }
245
+ },
246
+ finish() {
247
+ if (observationDisabled) {
248
+ return {
249
+ model: tokenData.model,
250
+ tokens: {
251
+ input: tokenData.inputTokens,
252
+ output: tokenData.outputTokens,
253
+ cacheCreation: tokenData.cacheCreation,
254
+ cacheRead: tokenData.cacheRead,
255
+ cached: tokenData.cachedTokens,
256
+ reasoning: tokenData.reasoningTokens,
257
+ total: tokenData.totalTokens
258
+ }
259
+ };
260
+ }
261
+ if (streaming) {
262
+ parseEvent(sseBuffer);
263
+ } else if (chunks.length > 0) {
264
+ try {
265
+ mergeParsedUsage(tokenData, JSON.parse(Buffer.concat(chunks).toString('utf8')));
266
+ } catch {
267
+ // Non-JSON responses are valid passthrough responses without usage.
268
+ }
269
+ }
270
+ return {
271
+ model: tokenData.model,
272
+ tokens: {
273
+ input: tokenData.inputTokens,
274
+ output: tokenData.outputTokens,
275
+ cacheCreation: tokenData.cacheCreation,
276
+ cacheRead: tokenData.cacheRead,
277
+ cached: tokenData.cachedTokens,
278
+ reasoning: tokenData.reasoningTokens,
279
+ total: tokenData.totalTokens
280
+ }
281
+ };
282
+ }
283
+ };
284
+ }
285
+
286
+ function closeServer(server, forceAfterMs = 1000) {
287
+ if (!server) return Promise.resolve();
288
+ return new Promise((resolve, reject) => {
289
+ const timer = expediteServerShutdown(server, { forceAfterMs });
290
+ server.close((error) => {
291
+ if (timer) clearTimeout(timer);
292
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') {
293
+ reject(error);
294
+ } else {
295
+ resolve();
296
+ }
297
+ });
298
+ });
299
+ }
300
+
301
+ function createOmpGateway(options = {}) {
302
+ const getChannels = options.getChannels;
303
+ const allocate = options.allocateChannel || allocateChannel;
304
+ const release = options.releaseChannel || releaseChannel;
305
+ const onSuccess = options.recordSuccess || recordSuccess;
306
+ const onFailure = options.recordFailure || recordFailure;
307
+ const publishUsage = options.publishUsageLog || ((data) => {
308
+ const enrich = (payload) => broadcastLog({
309
+ ...payload,
310
+ originalProvider: data.originalProvider,
311
+ providerApi: data.providerApi,
312
+ routingGroup: data.routingGroup,
313
+ actualChannelId: data.channelId,
314
+ upstreamModel: data.model,
315
+ switchReason: data.switchReason || undefined,
316
+ attemptedChannels: data.attemptedChannels
317
+ });
318
+ return publishSharedUsageLog({
319
+ source: 'omp',
320
+ metadata: {
321
+ id: data.requestId,
322
+ channel: data.channel,
323
+ channelId: data.channelId,
324
+ originalModel: data.originalModel,
325
+ startTime: data.startTime
326
+ },
327
+ model: data.model || data.originalModel,
328
+ tokens: data.tokens,
329
+ broadcastLog: enrich,
330
+ recordRequest: recordOmpRequest,
331
+ recordSuccess: null
332
+ });
333
+ });
334
+ const publishFailure = options.publishFailureLog || ((data) => {
335
+ const enrich = (payload) => broadcastLog({
336
+ ...payload,
337
+ originalProvider: data.originalProvider,
338
+ providerApi: data.providerApi,
339
+ routingGroup: data.routingGroup,
340
+ actualChannelId: data.channelId,
341
+ switchReason: data.switchReason || undefined,
342
+ attemptedChannels: data.attemptedChannels
343
+ });
344
+ return publishSharedFailureLog({
345
+ source: 'omp',
346
+ metadata: {
347
+ id: data.requestId,
348
+ channel: data.channel,
349
+ model: data.model
350
+ },
351
+ channel: data.channel,
352
+ model: data.model,
353
+ message: data.message,
354
+ error: data.error,
355
+ statusCode: data.statusCode,
356
+ stage: data.stage,
357
+ broadcastLog: enrich
358
+ });
359
+ });
360
+ const websocketProxy = httpProxy.createProxyServer({
361
+ ws: true,
362
+ changeOrigin: true,
363
+ xfwd: false
364
+ });
365
+
366
+ let server = null;
367
+ let host = '127.0.0.1';
368
+ let port = null;
369
+ let secret = '';
370
+ let draining = false;
371
+ let inflightRequests = 0;
372
+ const activeUpstreams = new Set();
373
+
374
+ function gatewayDescriptor() {
375
+ return { host, port, secret };
376
+ }
377
+
378
+ function currentChannels() {
379
+ const value = typeof getChannels === 'function' ? getChannels() : [];
380
+ return Array.isArray(value?.channels) ? value.channels : (Array.isArray(value) ? value : []);
381
+ }
382
+
383
+ async function allocateForRoute(route, excludedIds = [], modelId = '', signal = null) {
384
+ const channels = currentChannels();
385
+ const candidateIds = route.channelIds.filter((channelId) => {
386
+ const channel = channels.find(item => item.id === channelId);
387
+ return channel && channelSupportsModel(channel, modelId);
388
+ });
389
+ if (candidateIds.length === 0) {
390
+ throw Object.assign(
391
+ new Error(`No OMP channel in routing group "${route.routingGroup}" supports model "${modelId}"`),
392
+ { statusCode: 422 }
393
+ );
394
+ }
395
+ return allocate({
396
+ source: 'omp',
397
+ enableSessionBinding: false,
398
+ candidateIds,
399
+ excludeChannelIds: [...excludedIds],
400
+ routingGroup: route.routingGroup,
401
+ providerKey: route.providerKey,
402
+ providerApi: route.providerApi,
403
+ modelId: normalizeModelId(modelId),
404
+ signal
405
+ });
406
+ }
407
+
408
+ function forwardAttempt(req, route, channel, body, signal) {
409
+ return new Promise((resolve, reject) => {
410
+ let released = false;
411
+ const releaseOnce = () => {
412
+ if (released) return;
413
+ released = true;
414
+ release(channel.id, 'omp');
415
+ };
416
+ if (signal?.aborted) {
417
+ releaseOnce();
418
+ reject(createAbortError());
419
+ return;
420
+ }
421
+
422
+ let upstream;
423
+ try {
424
+ upstream = new URL(channel.baseUrl);
425
+ } catch (error) {
426
+ releaseOnce();
427
+ reject(error);
428
+ return;
429
+ }
430
+
431
+ const upstreamPath = buildUpstreamRequestPath(channel.baseUrl, route.upstreamPath);
432
+ const rewritten = rewriteCredential(req.headers, upstreamPath, route, channel);
433
+ const transport = upstream.protocol === 'https:' ? https : http;
434
+ let upstreamResponse = null;
435
+ const upstreamRequest = transport.request({
436
+ protocol: upstream.protocol,
437
+ hostname: upstream.hostname,
438
+ port: upstream.port || undefined,
439
+ method: req.method,
440
+ path: rewritten.path,
441
+ headers: {
442
+ ...rewritten.headers,
443
+ host: upstream.host,
444
+ 'content-length': body.length
445
+ }
446
+ }, (response) => {
447
+ upstreamResponse = response;
448
+ activeUpstreams.delete(upstreamRequest);
449
+ activeUpstreams.add(response);
450
+ response.once('close', () => {
451
+ signal?.removeEventListener('abort', abortUpstream);
452
+ });
453
+ const statusCode = response.statusCode || 502;
454
+ resolve({
455
+ statusCode,
456
+ response,
457
+ channel,
458
+ release: releaseOnce
459
+ });
460
+ });
461
+
462
+ activeUpstreams.add(upstreamRequest);
463
+ const abortUpstream = () => {
464
+ const error = createAbortError();
465
+ if (upstreamResponse) {
466
+ upstreamResponse.destroy(error);
467
+ } else {
468
+ upstreamRequest.destroy(error);
469
+ }
470
+ };
471
+ signal?.addEventListener('abort', abortUpstream, { once: true });
472
+ upstreamRequest.on('error', (error) => {
473
+ signal?.removeEventListener('abort', abortUpstream);
474
+ activeUpstreams.delete(upstreamRequest);
475
+ releaseOnce();
476
+ reject(error);
477
+ });
478
+ upstreamRequest.end(body);
479
+ });
480
+ }
481
+
482
+ async function handleProxyRequest(req, res, route, signal) {
483
+ const body = await readRequestBody(req);
484
+ if (signal?.aborted) throw createAbortError();
485
+ const modelId = extractModelId(body, req.headers['content-type']);
486
+ const attemptedIds = [];
487
+ const attemptedChannels = [];
488
+ const requestId = `omp-${crypto.randomUUID()}`;
489
+ const startTime = Date.now();
490
+ let lastError = null;
491
+ let switchReason = '';
492
+
493
+ for (let attempt = 0; attempt < 2; attempt++) {
494
+ let channel;
495
+ try {
496
+ channel = await allocateForRoute(route, attemptedIds, modelId, signal);
497
+ } catch (error) {
498
+ lastError = error;
499
+ break;
500
+ }
501
+ if (signal?.aborted) {
502
+ release(channel.id, 'omp');
503
+ lastError = createAbortError();
504
+ break;
505
+ }
506
+ attemptedIds.push(channel.id);
507
+ attemptedChannels.push(channel.name || channel.id);
508
+
509
+ try {
510
+ const result = await forwardAttempt(req, route, channel, body, signal);
511
+ if (attempt === 0 && RETRYABLE_STATUS.has(result.statusCode)) {
512
+ result.response.once('close', () => activeUpstreams.delete(result.response));
513
+ result.response.resume();
514
+ result.release();
515
+ onFailure(channel.id, 'omp');
516
+ switchReason = `http-${result.statusCode}`;
517
+ publishFailure({
518
+ source: 'omp',
519
+ requestId,
520
+ channel: channel.name || channel.id,
521
+ channelId: channel.id,
522
+ originalProvider: route.providerKey,
523
+ providerApi: route.providerApi,
524
+ routingGroup: route.routingGroup,
525
+ model: modelId,
526
+ statusCode: result.statusCode,
527
+ stage: 'dynamic-switch',
528
+ switchReason,
529
+ attemptedChannels: [...attemptedChannels],
530
+ message: `OMP upstream returned HTTP ${result.statusCode}`
531
+ });
532
+ lastError = Object.assign(new Error(`OMP upstream returned HTTP ${result.statusCode}`), {
533
+ statusCode: result.statusCode
534
+ });
535
+ continue;
536
+ }
537
+
538
+ res.writeHead(result.statusCode, result.response.headers);
539
+ const usageMonitor = createResponseUsageMonitor(result.response.headers['content-type']);
540
+ await new Promise((resolve) => {
541
+ let completed = false;
542
+ const finish = (error) => {
543
+ if (completed) return;
544
+ completed = true;
545
+ activeUpstreams.delete(result.response);
546
+ result.release();
547
+ if (result.statusCode < 400 && !error) {
548
+ const usage = usageMonitor.finish();
549
+ onSuccess(channel.id, 'omp');
550
+ publishUsage({
551
+ source: 'omp',
552
+ requestId,
553
+ startTime,
554
+ channel: channel.name || channel.id,
555
+ channelId: channel.id,
556
+ originalProvider: route.providerKey,
557
+ providerApi: route.providerApi,
558
+ routingGroup: route.routingGroup,
559
+ originalModel: modelId,
560
+ model: usage.model || modelId,
561
+ tokens: usage.tokens,
562
+ switchReason,
563
+ attemptedChannels: [...attemptedChannels]
564
+ });
565
+ } else {
566
+ onFailure(channel.id, 'omp');
567
+ publishFailure({
568
+ source: 'omp',
569
+ requestId,
570
+ channel: channel.name || channel.id,
571
+ channelId: channel.id,
572
+ originalProvider: route.providerKey,
573
+ providerApi: route.providerApi,
574
+ routingGroup: route.routingGroup,
575
+ model: modelId,
576
+ error,
577
+ statusCode: result.statusCode,
578
+ stage: error ? 'response-stream' : 'upstream-response',
579
+ switchReason,
580
+ attemptedChannels: [...attemptedChannels],
581
+ message: error?.message || `OMP upstream returned HTTP ${result.statusCode}`
582
+ });
583
+ }
584
+ resolve();
585
+ };
586
+ result.response.on('data', chunk => usageMonitor.observe(chunk));
587
+ result.response.once('end', () => finish());
588
+ result.response.once('close', () => finish(new Error('OMP upstream response closed early')));
589
+ result.response.once('error', (error) => {
590
+ if (!res.destroyed) res.destroy(error);
591
+ finish(error);
592
+ });
593
+ res.once('close', () => {
594
+ if (!res.writableEnded) {
595
+ result.response.destroy(new Error('OMP downstream client disconnected'));
596
+ }
597
+ });
598
+ result.response.pipe(res);
599
+ });
600
+ return;
601
+ } catch (error) {
602
+ lastError = error;
603
+ if (error?.name === 'AbortError') {
604
+ break;
605
+ }
606
+ onFailure(channel.id, 'omp');
607
+ switchReason = error.code || error.message || 'connection-error';
608
+ publishFailure({
609
+ source: 'omp',
610
+ requestId,
611
+ channel: channel.name || channel.id,
612
+ channelId: channel.id,
613
+ originalProvider: route.providerKey,
614
+ providerApi: route.providerApi,
615
+ routingGroup: route.routingGroup,
616
+ model: modelId,
617
+ error,
618
+ statusCode: error.statusCode,
619
+ stage: attempt === 0 ? 'dynamic-switch' : 'upstream-connect',
620
+ switchReason,
621
+ attemptedChannels: [...attemptedChannels]
622
+ });
623
+ }
624
+ }
625
+
626
+ throw lastError || new Error('No compatible OMP channel is available');
627
+ }
628
+
629
+ async function handleRequest(req, res) {
630
+ if (req.method === 'GET' && req.url === '/healthz') {
631
+ writeJson(res, 200, {
632
+ ok: true,
633
+ service: 'omp-gateway',
634
+ draining,
635
+ inflightRequests
636
+ });
637
+ return;
638
+ }
639
+ if (draining) {
640
+ writeJson(res, 503, { error: 'OMP gateway is draining' });
641
+ return;
642
+ }
643
+
644
+ let parsed;
645
+ try {
646
+ parsed = new URL(req.url, `http://${host}`);
647
+ } catch {
648
+ writeJson(res, 400, { error: 'Invalid OMP gateway URL' });
649
+ return;
650
+ }
651
+ let route;
652
+ try {
653
+ route = resolveOmpGatewayRoute(parsed.pathname, currentChannels(), gatewayDescriptor());
654
+ } catch (error) {
655
+ writeJson(res, 503, { error: error.message });
656
+ return;
657
+ }
658
+ if (!route) {
659
+ writeJson(res, 404, { error: 'Unknown OMP gateway route' });
660
+ return;
661
+ }
662
+ if (!hasValidCapability(req, parsed, route)) {
663
+ writeJson(res, 401, { error: 'Invalid OMP gateway capability' });
664
+ return;
665
+ }
666
+ route.upstreamPath = `${route.upstreamPath}${parsed.search}`;
667
+
668
+ inflightRequests++;
669
+ const abortController = new AbortController();
670
+ const abortDownstream = () => abortController.abort();
671
+ const abortClosedResponse = () => {
672
+ if (!res.writableEnded) abortDownstream();
673
+ };
674
+ req.once('aborted', abortDownstream);
675
+ res.once('close', abortClosedResponse);
676
+ try {
677
+ await handleProxyRequest(req, res, route, abortController.signal);
678
+ } catch (error) {
679
+ if (error?.name !== 'AbortError') {
680
+ writeJson(res, error.statusCode || 502, { error: error.message || 'OMP upstream request failed' });
681
+ }
682
+ } finally {
683
+ req.removeListener('aborted', abortDownstream);
684
+ res.removeListener('close', abortClosedResponse);
685
+ inflightRequests = Math.max(0, inflightRequests - 1);
686
+ }
687
+ }
688
+
689
+ function rejectUpgrade(socket, statusCode, message) {
690
+ if (!socket || socket.destroyed) return;
691
+ const body = JSON.stringify({ error: message });
692
+ const statusText = {
693
+ 401: 'Unauthorized',
694
+ 404: 'Not Found',
695
+ 503: 'Service Unavailable'
696
+ }[statusCode] || 'Bad Gateway';
697
+ socket.end([
698
+ `HTTP/1.1 ${statusCode} ${statusText}`,
699
+ 'Content-Type: application/json; charset=utf-8',
700
+ `Content-Length: ${Buffer.byteLength(body)}`,
701
+ 'Connection: close',
702
+ '',
703
+ body
704
+ ].join('\r\n'));
705
+ }
706
+
707
+ async function handleUpgrade(req, socket, head) {
708
+ if (draining) {
709
+ rejectUpgrade(socket, 503, 'OMP gateway is draining');
710
+ return;
711
+ }
712
+ let parsed;
713
+ try {
714
+ parsed = new URL(req.url, `http://${host}`);
715
+ } catch {
716
+ rejectUpgrade(socket, 404, 'Invalid OMP gateway URL');
717
+ return;
718
+ }
719
+ let route;
720
+ try {
721
+ route = resolveOmpGatewayRoute(parsed.pathname, currentChannels(), gatewayDescriptor());
722
+ } catch (error) {
723
+ rejectUpgrade(socket, 503, error.message);
724
+ return;
725
+ }
726
+ if (!route) {
727
+ rejectUpgrade(socket, 404, 'Unknown OMP gateway route');
728
+ return;
729
+ }
730
+ if (!hasValidCapability(req, parsed, route)) {
731
+ rejectUpgrade(socket, 401, 'Invalid OMP gateway capability');
732
+ return;
733
+ }
734
+ route.upstreamPath = `${route.upstreamPath}${parsed.search}`;
735
+
736
+ let channel;
737
+ try {
738
+ channel = await allocateForRoute(route);
739
+ } catch (error) {
740
+ rejectUpgrade(socket, 503, error.message || 'No compatible OMP channel is available');
741
+ return;
742
+ }
743
+
744
+ inflightRequests++;
745
+ const requestId = `omp-${crypto.randomUUID()}`;
746
+ const startTime = Date.now();
747
+ const websocketModel = normalizeModelId(channel.model || '');
748
+ let released = false;
749
+ let failed = false;
750
+ let failureRecorded = false;
751
+ const markFailedOnce = (error) => {
752
+ failed = true;
753
+ if (failureRecorded) return;
754
+ failureRecorded = true;
755
+ onFailure(channel.id, 'omp');
756
+ publishFailure({
757
+ source: 'omp',
758
+ requestId,
759
+ channel: channel.name || channel.id,
760
+ channelId: channel.id,
761
+ originalProvider: route.providerKey,
762
+ providerApi: route.providerApi,
763
+ routingGroup: route.routingGroup,
764
+ model: websocketModel,
765
+ error,
766
+ stage: 'websocket',
767
+ attemptedChannels: [channel.name || channel.id]
768
+ });
769
+ };
770
+ const releaseOnce = () => {
771
+ if (released) return;
772
+ released = true;
773
+ inflightRequests = Math.max(0, inflightRequests - 1);
774
+ if (!failed) {
775
+ onSuccess(channel.id, 'omp');
776
+ publishUsage({
777
+ source: 'omp',
778
+ requestId,
779
+ startTime,
780
+ channel: channel.name || channel.id,
781
+ channelId: channel.id,
782
+ originalProvider: route.providerKey,
783
+ providerApi: route.providerApi,
784
+ routingGroup: route.routingGroup,
785
+ originalModel: websocketModel,
786
+ model: websocketModel,
787
+ tokens: {},
788
+ attemptedChannels: [channel.name || channel.id]
789
+ });
790
+ }
791
+ release(channel.id, 'omp');
792
+ };
793
+ socket.once('close', releaseOnce);
794
+ socket.once('error', (error) => {
795
+ markFailedOnce(error);
796
+ releaseOnce();
797
+ });
798
+
799
+ try {
800
+ const upstream = new URL(channel.baseUrl);
801
+ const upstreamPath = buildUpstreamRequestPath(channel.baseUrl, route.upstreamPath);
802
+ const rewritten = rewriteCredential(req.headers, upstreamPath, route, channel, {
803
+ preserveConnection: true
804
+ });
805
+ req.url = rewritten.path;
806
+ req.headers = {
807
+ ...rewritten.headers,
808
+ host: upstream.host
809
+ };
810
+ req.__ompGatewayRelease = releaseOnce;
811
+ req.__ompGatewayFail = markFailedOnce;
812
+ websocketProxy.ws(req, socket, head, {
813
+ target: upstream.origin,
814
+ changeOrigin: true,
815
+ secure: upstream.protocol === 'https:'
816
+ });
817
+ } catch (error) {
818
+ markFailedOnce(error);
819
+ releaseOnce();
820
+ rejectUpgrade(socket, 503, error.message || 'OMP websocket proxy failed');
821
+ }
822
+ }
823
+
824
+ websocketProxy.on('error', (error, req, socket) => {
825
+ req?.__ompGatewayFail?.(error);
826
+ req?.__ompGatewayRelease?.();
827
+ if (socket && !socket.destroyed) {
828
+ socket.destroy(error);
829
+ }
830
+ });
831
+
832
+ async function start(startOptions = {}) {
833
+ if (server?.listening) return status();
834
+ host = startOptions.host || '127.0.0.1';
835
+ port = Number.isInteger(startOptions.port) ? startOptions.port : 20092;
836
+ if (port < 0 || port > 65535) {
837
+ throw new Error(`Invalid OMP gateway port: ${port}`);
838
+ }
839
+ secret = String(startOptions.secret || '');
840
+ if (!secret) {
841
+ throw new Error('OMP gateway secret is required');
842
+ }
843
+ draining = false;
844
+ server = attachServerShutdownHandling(http.createServer((req, res) => {
845
+ handleRequest(req, res).catch((error) => {
846
+ writeJson(res, 500, { error: error.message || 'OMP gateway request failed' });
847
+ });
848
+ }));
849
+ server.on('upgrade', (req, socket, head) => {
850
+ handleUpgrade(req, socket, head).catch((error) => {
851
+ rejectUpgrade(socket, 503, error.message || 'OMP websocket proxy failed');
852
+ });
853
+ });
854
+
855
+ try {
856
+ await new Promise((resolve, reject) => {
857
+ const onError = (error) => {
858
+ server.removeListener('listening', onListening);
859
+ reject(error);
860
+ };
861
+ const onListening = () => {
862
+ server.removeListener('error', onError);
863
+ resolve();
864
+ };
865
+ server.once('error', onError);
866
+ server.once('listening', onListening);
867
+ server.listen(port, host);
868
+ });
869
+ } catch (error) {
870
+ server = null;
871
+ port = null;
872
+ throw error;
873
+ }
874
+ port = server.address().port;
875
+ return status();
876
+ }
877
+
878
+ async function stop(stopOptions = {}) {
879
+ if (!server) return status();
880
+ draining = true;
881
+ await closeServer(server, stopOptions.forceAfterMs);
882
+ activeUpstreams.clear();
883
+ server = null;
884
+ port = null;
885
+ draining = false;
886
+ inflightRequests = 0;
887
+ return status();
888
+ }
889
+
890
+ function cancelDraining() {
891
+ draining = false;
892
+ }
893
+
894
+ function beginDraining() {
895
+ draining = true;
896
+ }
897
+
898
+ function status() {
899
+ return {
900
+ listening: Boolean(server?.listening),
901
+ draining,
902
+ port,
903
+ inflightRequests
904
+ };
905
+ }
906
+
907
+ return {
908
+ start,
909
+ stop,
910
+ status,
911
+ beginDraining,
912
+ cancelDraining,
913
+ descriptor: gatewayDescriptor
914
+ };
915
+ }
916
+
917
+ module.exports = {
918
+ createOmpGateway
919
+ };