sandoichi 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proxy.mjs CHANGED
@@ -1,7 +1,13 @@
1
+ import net from 'node:net';
1
2
  import http from 'node:http';
3
+ import path from 'node:path';
4
+ import tls from 'node:tls';
5
+ import { brotliDecompressSync, gunzipSync, inflateSync, zstdDecompressSync } from 'node:zlib';
2
6
 
3
7
  import { estimateTokens } from './core.mjs';
8
+ import { buildContextCaptureRecord, recordContextCapture } from './context-capture.mjs';
4
9
  import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
10
+ import { publishF1Telemetry } from './f1-telemetry.mjs';
5
11
  import { recordProxyRequest } from './proxy-metrics.mjs';
6
12
  import {
7
13
  closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordFailure,
@@ -26,6 +32,7 @@ function recordProxyTelemetry({ env, provider, transformed, beforeText, afterTex
26
32
  incrementCounter({
27
33
  statePaths,
28
34
  day: todayUtc(),
35
+ pluginVersion: PLUGIN_VERSION,
29
36
  event: 'proxy_summary',
30
37
  provider: telemetryProvider(provider),
31
38
  mode: 'enforce',
@@ -47,7 +54,7 @@ function recordProxyFailure({ env, provider, failureStage }) {
47
54
  const statePaths = defaultTelemetryStatePaths(env);
48
55
  const day = todayUtc();
49
56
  recordFailure({
50
- statePaths, day, event: 'proxy_failure_summary',
57
+ statePaths, day, pluginVersion: PLUGIN_VERSION, event: 'proxy_failure_summary',
51
58
  provider: telemetryProvider(provider), failureStage,
52
59
  });
53
60
  closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
@@ -58,8 +65,10 @@ const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
58
65
  const HOP_BY_HOP_HEADERS = new Set([
59
66
  'connection', 'content-length', 'keep-alive', 'proxy-authenticate',
60
67
  'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host',
61
- 'accept-encoding', 'content-encoding',
68
+ 'accept-encoding', 'content-encoding', 'x-sando-session-key',
62
69
  ]);
70
+ const REQUEST_EXCLUDED_HEADERS = new Set(HOP_BY_HOP_HEADERS);
71
+ REQUEST_EXCLUDED_HEADERS.delete('content-encoding');
63
72
 
64
73
  function assertUpstream(value) {
65
74
  let url;
@@ -99,13 +108,32 @@ function targetUrl(upstream, requestUrl) {
99
108
  function forwardedHeaders(request) {
100
109
  const headers = new Headers();
101
110
  for (const [name, value] of Object.entries(request.headers)) {
102
- if (HOP_BY_HOP_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
111
+ if (REQUEST_EXCLUDED_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
103
112
  headers.set(name, Array.isArray(value) ? value.join(', ') : value);
104
113
  }
105
114
  headers.set('accept-encoding', 'identity');
106
115
  return headers;
107
116
  }
108
117
 
118
+ function decodeRequestBody(rawBody, contentEncoding) {
119
+ const encodings = String(contentEncoding ?? '')
120
+ .split(',')
121
+ .map((encoding) => encoding.trim().toLowerCase())
122
+ .filter((encoding) => encoding && encoding !== 'identity');
123
+ if (encodings.length === 0) return rawBody;
124
+ try {
125
+ return encodings.reverse().reduce((body, encoding) => {
126
+ if (encoding === 'gzip' || encoding === 'x-gzip') return gunzipSync(body);
127
+ if (encoding === 'deflate') return inflateSync(body);
128
+ if (encoding === 'br') return brotliDecompressSync(body);
129
+ if (encoding === 'zstd') return zstdDecompressSync(body);
130
+ throw new Error(`unsupported request content encoding: ${encoding}`);
131
+ }, rawBody);
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
109
137
  function responseHeaders(response) {
110
138
  const headers = {};
111
139
  response.headers.forEach((value, name) => {
@@ -116,17 +144,70 @@ function responseHeaders(response) {
116
144
 
117
145
  const MAX_USAGE_SCAN_BYTES = 2 * 1024 * 1024;
118
146
 
119
- /** Merges every `"usage":{...}` object seen across a (possibly streamed) Anthropic
120
- * response: `message_start` carries input/cache token counts, `message_delta`
121
- * carries the final output count, and later objects overwrite matching keys. */
147
+ function readJsonObject(text, start) {
148
+ if (text[start] !== '{') return null;
149
+ let depth = 0;
150
+ let quoted = false;
151
+ let escaped = false;
152
+ for (let index = start; index < text.length; index += 1) {
153
+ const character = text[index];
154
+ if (quoted) {
155
+ if (escaped) escaped = false;
156
+ else if (character === '\\') escaped = true;
157
+ else if (character === '"') quoted = false;
158
+ continue;
159
+ }
160
+ if (character === '"') {
161
+ quoted = true;
162
+ continue;
163
+ }
164
+ if (character === '{') depth += 1;
165
+ else if (character === '}') {
166
+ depth -= 1;
167
+ if (depth === 0) return text.slice(start, index + 1);
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** Merges every usage object seen across a streamed provider response. */
122
174
  function extractUsage(text) {
123
175
  let usage = null;
124
- for (const match of text.matchAll(/"usage":\s*(\{[^{}]*\})/g)) {
125
- try { usage = { ...usage, ...JSON.parse(match[1]) }; } catch { /* ignore malformed fragment */ }
176
+ for (const match of text.matchAll(/"usage"\s*:/g)) {
177
+ const start = text.indexOf('{', match.index + match[0].length);
178
+ const fragment = start < 0 ? null : readJsonObject(text, start);
179
+ if (!fragment) continue;
180
+ try { usage = { ...usage, ...JSON.parse(fragment) }; } catch { /* ignore malformed fragment */ }
126
181
  }
127
182
  return usage;
128
183
  }
129
184
 
185
+ function detectCaptureProvider(body, headers) {
186
+ const provider = detectProviderBody(body, headers);
187
+ if (provider) return provider;
188
+ if (Array.isArray(body?.input) && typeof body.prompt_cache_key === 'string' && body.prompt_cache_key.length > 0) {
189
+ return 'openai-responses';
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function resolveContextSessionKey(value, { provider, body, headers }) {
195
+ let candidate = value;
196
+ if (typeof value === 'function') {
197
+ try { candidate = value({ provider, body, headers }); } catch { return null; }
198
+ }
199
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
200
+ const header = headers['x-sando-session-key'];
201
+ if (typeof header === 'string' && header.length > 0) return header;
202
+ if (provider === 'anthropic' && typeof body?.metadata?.user_id === 'string' && body.metadata.user_id.length > 0) {
203
+ return `anthropic-metadata-user:${body.metadata.user_id}`;
204
+ }
205
+ if (provider === 'openai-responses' && typeof body?.prompt_cache_key === 'string' && body.prompt_cache_key.length > 0) {
206
+ return `openai-responses-prompt-cache:${body.prompt_cache_key}`;
207
+ }
208
+ return null;
209
+ }
210
+
130
211
  async function pipeResponse(response, outgoing, onText) {
131
212
  outgoing.writeHead(response.status, response.statusText, responseHeaders(response));
132
213
  if (!response.body) {
@@ -151,6 +232,151 @@ function jsonResponse(outgoing, status, body) {
151
232
  outgoing.end(text);
152
233
  }
153
234
 
235
+ const MAX_WEBSOCKET_HANDSHAKE_BYTES = 64 * 1024;
236
+ const MAX_WEBSOCKET_MESSAGE_BYTES = 16 * 1024 * 1024;
237
+
238
+ function connectWebSocketUpstream(target) {
239
+ return new Promise((resolve, reject) => {
240
+ const options = {
241
+ host: target.hostname,
242
+ port: Number(target.port) || (target.protocol === 'https:' ? 443 : 80),
243
+ ...(target.protocol === 'https:' ? { servername: target.hostname } : {}),
244
+ };
245
+ let connected = false;
246
+ const onError = (error) => {
247
+ if (!connected) reject(error);
248
+ };
249
+ const socket = target.protocol === 'https:'
250
+ ? tls.connect(options, () => {
251
+ connected = true;
252
+ socket.off('error', onError);
253
+ resolve(socket);
254
+ })
255
+ : net.connect(options, () => {
256
+ connected = true;
257
+ socket.off('error', onError);
258
+ resolve(socket);
259
+ });
260
+ socket.once('error', onError);
261
+ });
262
+ }
263
+
264
+ function websocketUpgradeRequest(request, target) {
265
+ const excluded = new Set([
266
+ 'accept-encoding', 'connection', 'content-length', 'content-encoding',
267
+ 'host', 'sec-websocket-extensions', 'transfer-encoding', 'upgrade',
268
+ 'x-sando-session-key',
269
+ ]);
270
+ const lines = [
271
+ `GET ${target.pathname}${target.search} HTTP/1.1`,
272
+ `Host: ${target.host}`,
273
+ 'Connection: Upgrade',
274
+ 'Upgrade: websocket',
275
+ ];
276
+ for (const [name, value] of Object.entries(request.headers)) {
277
+ if (excluded.has(name.toLowerCase()) || value === undefined) continue;
278
+ const values = Array.isArray(value) ? value : [value];
279
+ for (const item of values) lines.push(`${name}: ${item}`);
280
+ }
281
+ return Buffer.from(`${lines.join('\r\n')}\r\n\r\n`, 'utf8');
282
+ }
283
+
284
+ function websocketInspector(onMessage) {
285
+ let buffered = Buffer.alloc(0);
286
+ let fragments = [];
287
+ let fragmentBytes = 0;
288
+
289
+ function emit(payload) {
290
+ if (payload.length > MAX_WEBSOCKET_MESSAGE_BYTES) return;
291
+ try { onMessage(payload.toString('utf8')); } catch { /* inspection is best-effort */ }
292
+ }
293
+
294
+ return (chunk) => {
295
+ if (!Buffer.isBuffer(chunk) || chunk.length === 0) return;
296
+ buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]);
297
+ while (buffered.length >= 2) {
298
+ const first = buffered[0];
299
+ const second = buffered[1];
300
+ const masked = (second & 0x80) !== 0;
301
+ let length = second & 0x7f;
302
+ let offset = 2;
303
+ if (length === 126) {
304
+ if (buffered.length < 4) return;
305
+ length = buffered.readUInt16BE(2);
306
+ offset = 4;
307
+ } else if (length === 127) {
308
+ if (buffered.length < 10) return;
309
+ const longLength = buffered.readBigUInt64BE(2);
310
+ if (longLength > BigInt(MAX_WEBSOCKET_MESSAGE_BYTES)) {
311
+ buffered = Buffer.alloc(0);
312
+ fragments = [];
313
+ fragmentBytes = 0;
314
+ return;
315
+ }
316
+ length = Number(longLength);
317
+ offset = 10;
318
+ }
319
+ const maskOffset = masked ? offset : 0;
320
+ const frameBytes = offset + (masked ? 4 : 0) + length;
321
+ if (buffered.length < frameBytes) return;
322
+ const frame = buffered.subarray(0, frameBytes);
323
+ buffered = buffered.subarray(frameBytes);
324
+ let payload = frame.subarray(offset + (masked ? 4 : 0));
325
+ if (masked) {
326
+ payload = Buffer.from(payload);
327
+ const mask = frame.subarray(maskOffset, maskOffset + 4);
328
+ for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4];
329
+ }
330
+ const opcode = first & 0x0f;
331
+ const fin = (first & 0x80) !== 0;
332
+ if (opcode === 0x1 || opcode === 0x2) {
333
+ fragments = [payload];
334
+ fragmentBytes = payload.length;
335
+ if (fin) {
336
+ emit(payload);
337
+ fragments = [];
338
+ fragmentBytes = 0;
339
+ }
340
+ } else if (opcode === 0x0 && fragments.length > 0) {
341
+ fragmentBytes += payload.length;
342
+ if (fragmentBytes > MAX_WEBSOCKET_MESSAGE_BYTES) {
343
+ fragments = [];
344
+ fragmentBytes = 0;
345
+ } else {
346
+ fragments.push(payload);
347
+ if (fin) {
348
+ emit(Buffer.concat(fragments));
349
+ fragments = [];
350
+ fragmentBytes = 0;
351
+ }
352
+ }
353
+ }
354
+ }
355
+ };
356
+ }
357
+
358
+ function findResponsesRequest(value, seen = new Set(), depth = 0) {
359
+ if (depth > 8 || value === null || typeof value !== 'object' || seen.has(value)) return null;
360
+ seen.add(value);
361
+ if (!Array.isArray(value) && Array.isArray(value.input)
362
+ && typeof value.prompt_cache_key === 'string' && value.prompt_cache_key.length > 0) return value;
363
+ const values = Array.isArray(value) ? value : Object.values(value);
364
+ for (const child of values) {
365
+ const found = findResponsesRequest(child, seen, depth + 1);
366
+ if (found) return found;
367
+ }
368
+ return null;
369
+ }
370
+
371
+ function websocketCompletion(text) {
372
+ try {
373
+ const value = JSON.parse(text);
374
+ return value?.type === 'response.completed' || value?.type === 'response.done';
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
154
380
  function createSemanticStats(candidates) {
155
381
  return {
156
382
  candidates: candidates.length,
@@ -181,12 +407,55 @@ async function observeSemanticCandidates({ provider, candidates, semanticCompact
181
407
  }
182
408
  }
183
409
 
184
- export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath, env = process.env } = {}) {
410
+ export async function createProviderProxy({
411
+ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
412
+ semanticCompactor, metricsPath, contextCapturePath, contextCaptureHost, contextSessionKey,
413
+ f1TelemetryPublisher = publishF1Telemetry, transformProviderRequests = false,
414
+ historyArchiveRoot,
415
+ env = process.env,
416
+ } = {}) {
185
417
  const upstreamUrl = assertUpstream(upstream);
186
418
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('port is invalid');
187
419
  if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1024) throw new TypeError('maxBodyBytes is invalid');
420
+ if (typeof transformProviderRequests !== 'boolean') throw new TypeError('transformProviderRequests is invalid');
421
+ if (transformProviderRequests && policy?.strategies?.recoverableArchive === true
422
+ && (typeof historyArchiveRoot !== 'string' || !path.isAbsolute(historyArchiveRoot))) {
423
+ throw new TypeError('historyArchiveRoot must be an absolute path');
424
+ }
188
425
  let lastStats = null;
189
426
  let lastRequestAt = null;
427
+ const capturedContextSessions = new Set();
428
+ const upgradedSockets = new Set();
429
+
430
+ function persistCapture({ provider, body, rawBody, sessionKey, model, providerUsage }) {
431
+ if (!contextCapturePath || !sessionKey) return false;
432
+ try {
433
+ const record = buildContextCaptureRecord({
434
+ host: contextCaptureHost ?? (provider === 'anthropic' ? 'claude' : 'codex'),
435
+ provider,
436
+ rawBody,
437
+ requestBody: body,
438
+ sessionKey,
439
+ model,
440
+ providerUsage,
441
+ });
442
+ if (!record) return false;
443
+ if (capturedContextSessions.has(record.sessionKeyDigest)) return true;
444
+ const reported = record.report.tokenAccounting.providerReported;
445
+ if (providerUsage !== undefined && providerUsage !== null && !reported) return false;
446
+ if (reported?.outputTokens === 0) return false;
447
+ recordContextCapture({ storagePath: contextCapturePath, record });
448
+ capturedContextSessions.add(record.sessionKeyDigest);
449
+ if (env.SANDO_F1_TELEMETRY === '1' && typeof f1TelemetryPublisher === 'function') {
450
+ try {
451
+ Promise.resolve(f1TelemetryPublisher({ record, endpoint: env.SANDO_F1_TELEMETRY_ENDPOINT }))
452
+ .catch(() => {});
453
+ } catch { /* local telemetry must never affect the proxied response */ }
454
+ }
455
+ return true;
456
+ } catch { /* capture is best-effort and must never affect the proxied response */ }
457
+ return false;
458
+ }
190
459
 
191
460
  const server = http.createServer(async (request, outgoing) => {
192
461
  let failureProvider = null;
@@ -205,35 +474,47 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
205
474
  return;
206
475
  }
207
476
  let body = rawBody;
477
+ const inspectionBody = decodeRequestBody(rawBody, request.headers['content-encoding']);
208
478
  let recordProvider = null;
209
479
  let recordModel = null;
210
480
  let recordStats = null;
211
- if (rawBody.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
212
- let parsed;
481
+ let captureProvider = null;
482
+ let captureModel = null;
483
+ let captureSessionKey = null;
484
+ let parsed;
485
+ if (inspectionBody?.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
213
486
  try {
214
- parsed = JSON.parse(rawBody.toString('utf8'));
487
+ parsed = JSON.parse(inspectionBody.toString('utf8'));
215
488
  } catch {
216
489
  recordProxyFailure({ env, provider: null, failureStage: 'input' });
217
490
  }
218
491
  if (parsed) {
219
492
  const provider = detectProviderBody(parsed, request.headers);
220
- failureProvider = provider;
493
+ const observedProvider = detectCaptureProvider(parsed, request.headers);
494
+ failureProvider = provider ?? observedProvider;
495
+ if (observedProvider) {
496
+ captureProvider = observedProvider;
497
+ captureModel = typeof parsed?.model === 'string' ? parsed.model : null;
498
+ captureSessionKey = resolveContextSessionKey(contextSessionKey, {
499
+ provider: observedProvider, body: parsed, headers: request.headers,
500
+ });
501
+ }
221
502
  try {
222
- if (provider) {
503
+ if (provider && transformProviderRequests && inspectionBody === rawBody) {
223
504
  const now = Date.now();
224
505
  const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
225
506
  lastRequestAt = now;
226
- const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
507
+ const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs, historyArchiveRoot });
227
508
  if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
228
509
  const mechanicalContextTrimmedBytes = Math.max(0, rawBody.length - body.length);
229
510
  recordProxyTelemetry({
230
511
  env, provider, transformed,
231
512
  beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
232
513
  });
233
- lastStats = { provider, ...transformed.stats, mechanicalContextTrimmedBytes, changed: transformed.changed, reasons: transformed.reasons };
514
+ lastStats = { provider, ...transformed.stats, mechanicalContextTrimmedBytes, changed: transformed.changed, reasons: transformed.reasons, disclosures: transformed.disclosures };
234
515
  recordProvider = provider;
235
516
  recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
236
- recordStats = { ...transformed.stats, mechanicalContextTrimmedBytes };
517
+ recordStats = { ...transformed.stats, mechanicalContextTrimmedBytes, disclosures: transformed.disclosures };
237
518
  if (typeof semanticCompactor === 'function') {
238
519
  const candidates = listSemanticCandidates({ provider, body: transformed.body });
239
520
  const stats = createSemanticStats(candidates);
@@ -263,7 +544,8 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
263
544
  }
264
545
  let responseText = '';
265
546
  try {
266
- await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
547
+ const observeResponse = metricsPath || (contextCapturePath && captureProvider);
548
+ await pipeResponse(response, outgoing, observeResponse ? (chunk) => { responseText += chunk; } : undefined);
267
549
  } catch {
268
550
  recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
269
551
  if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
@@ -278,6 +560,14 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
278
560
  });
279
561
  } catch { /* metrics are best-effort and must never affect the proxied response */ }
280
562
  }
563
+ persistCapture({
564
+ provider: captureProvider,
565
+ body: parsed,
566
+ rawBody: inspectionBody,
567
+ sessionKey: captureSessionKey,
568
+ model: captureModel,
569
+ providerUsage: extractUsage(responseText),
570
+ });
281
571
  } catch (error) {
282
572
  recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
283
573
  if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
@@ -285,6 +575,129 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
285
575
  }
286
576
  });
287
577
 
578
+ server.on('upgrade', async (request, client, head) => {
579
+ upgradedSockets.add(client);
580
+ let upstreamSocket;
581
+ let established = false;
582
+ let closed = false;
583
+ let upstreamBuffer = Buffer.alloc(0);
584
+ const clientQueue = head?.length ? [Buffer.from(head)] : [];
585
+ let requestBody = null;
586
+ let requestBodyRaw = null;
587
+ let requestSessionKey = null;
588
+ let requestModel = null;
589
+ let responseUsage = null;
590
+ let captured = false;
591
+
592
+ const captureMessage = (text, direction) => {
593
+ if (!contextCapturePath) return;
594
+ if (direction === 'client') {
595
+ let parsed;
596
+ try { parsed = JSON.parse(text); } catch { return; }
597
+ const candidate = findResponsesRequest(parsed);
598
+ if (!candidate) return;
599
+ requestBody = candidate;
600
+ requestBodyRaw = Buffer.from(JSON.stringify(candidate), 'utf8');
601
+ requestModel = typeof candidate.model === 'string' ? candidate.model : null;
602
+ requestSessionKey = resolveContextSessionKey(contextSessionKey, {
603
+ provider: 'openai-responses', body: candidate, headers: request.headers,
604
+ });
605
+ } else {
606
+ const usage = extractUsage(text);
607
+ if (usage) responseUsage = { ...responseUsage, ...usage };
608
+ if (websocketCompletion(text)) {
609
+ captured = persistCapture({
610
+ provider: 'openai-responses',
611
+ body: requestBody,
612
+ rawBody: requestBodyRaw,
613
+ sessionKey: requestSessionKey,
614
+ model: requestModel,
615
+ providerUsage: responseUsage,
616
+ });
617
+ }
618
+ }
619
+ };
620
+
621
+ const inspectClient = websocketInspector((text) => captureMessage(text, 'client'));
622
+ const inspectUpstream = websocketInspector((text) => captureMessage(text, 'upstream'));
623
+
624
+ const destroy = () => {
625
+ if (closed) return;
626
+ closed = true;
627
+ client.destroy();
628
+ upstreamSocket?.destroy();
629
+ };
630
+
631
+ const forwardQueuedClientData = () => {
632
+ for (const chunk of clientQueue.splice(0)) {
633
+ inspectClient(chunk);
634
+ if (!upstreamSocket.destroyed) upstreamSocket.write(chunk);
635
+ }
636
+ };
637
+
638
+ const onClientData = (chunk) => {
639
+ if (!established) {
640
+ clientQueue.push(Buffer.from(chunk));
641
+ return;
642
+ }
643
+ inspectClient(chunk);
644
+ if (!upstreamSocket.destroyed) upstreamSocket.write(chunk);
645
+ };
646
+
647
+ client.on('data', onClientData);
648
+ client.once('error', destroy);
649
+ client.once('close', () => {
650
+ upgradedSockets.delete(client);
651
+ if (!captured) persistCapture({
652
+ provider: 'openai-responses', body: requestBody, rawBody: requestBodyRaw,
653
+ sessionKey: requestSessionKey, model: requestModel, providerUsage: responseUsage,
654
+ });
655
+ destroy();
656
+ });
657
+
658
+ try {
659
+ const target = targetUrl(upstreamUrl, request.url);
660
+ upstreamSocket = await connectWebSocketUpstream(target);
661
+ upgradedSockets.add(upstreamSocket);
662
+ upstreamSocket.once('error', destroy);
663
+ upstreamSocket.once('close', () => {
664
+ upgradedSockets.delete(upstreamSocket);
665
+ if (!closed) client.destroy();
666
+ });
667
+ const upgrade = websocketUpgradeRequest(request, target);
668
+ let handshakeComplete = false;
669
+ upstreamSocket.on('data', (chunk) => {
670
+ if (!handshakeComplete) {
671
+ upstreamBuffer = upstreamBuffer.length === 0 ? chunk : Buffer.concat([upstreamBuffer, chunk]);
672
+ if (upstreamBuffer.length > MAX_WEBSOCKET_HANDSHAKE_BYTES) {
673
+ destroy();
674
+ return;
675
+ }
676
+ const boundary = upstreamBuffer.indexOf('\r\n\r\n');
677
+ if (boundary < 0) return;
678
+ const handshake = upstreamBuffer;
679
+ upstreamBuffer = Buffer.alloc(0);
680
+ handshakeComplete = /^HTTP\/1\.1 101\b/m.test(handshake.subarray(0, boundary).toString('latin1'));
681
+ client.write(handshake);
682
+ if (!handshakeComplete) {
683
+ destroy();
684
+ return;
685
+ }
686
+ established = true;
687
+ inspectUpstream(handshake.subarray(boundary + 4));
688
+ forwardQueuedClientData();
689
+ return;
690
+ }
691
+ inspectUpstream(chunk);
692
+ if (!client.destroyed) client.write(chunk);
693
+ });
694
+ upstreamSocket.write(upgrade);
695
+ } catch {
696
+ destroy();
697
+ return;
698
+ }
699
+ });
700
+
288
701
  await new Promise((resolve, reject) => {
289
702
  server.once('error', reject);
290
703
  server.listen(port, host, resolve);
@@ -297,6 +710,9 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
297
710
  port: actualPort,
298
711
  url: `http://${host}:${actualPort}`,
299
712
  get lastStats() { return lastStats; },
300
- close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
713
+ close: () => new Promise((resolve, reject) => {
714
+ for (const socket of upgradedSockets) socket.destroy();
715
+ server.close((error) => error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve());
716
+ }),
301
717
  };
302
718
  }