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/README.md +6 -2
- package/index.mjs +75 -0
- package/package.json +1 -1
- package/src/accounting-cli.mjs +1 -1
- package/src/artifact-cli.mjs +67 -0
- package/src/artifact-lifecycle.mjs +67 -0
- package/src/artifact-recovery.mjs +138 -0
- package/src/artifact-store.mjs +44 -0
- package/src/cache-attribution.mjs +13 -3
- package/src/context-audit-cli.mjs +104 -0
- package/src/context-capture.mjs +200 -0
- package/src/context-classifier.mjs +142 -0
- package/src/context-footprint.mjs +299 -0
- package/src/context-transform.mjs +146 -17
- package/src/core.mjs +300 -35
- package/src/f1-telemetry.mjs +80 -0
- package/src/f4-telemetry.mjs +183 -0
- package/src/gateway-gate-cli.mjs +88 -0
- package/src/gateway-gate.mjs +412 -0
- package/src/history-archive.mjs +80 -0
- package/src/history-disclosure.mjs +70 -0
- package/src/hook-cli.mjs +17 -1
- package/src/lazy-mcp-gateway-stdio.mjs +59 -0
- package/src/lazy-mcp-gateway.mjs +295 -0
- package/src/mcp-server.mjs +72 -11
- package/src/metrics.mjs +4 -3
- package/src/provider-usage.mjs +103 -23
- package/src/proxy.mjs +435 -19
- package/src/result-disclosure.mjs +115 -0
- package/src/slice.mjs +419 -0
- package/src/statusline.mjs +5 -9
- package/src/telemetry.mjs +155 -29
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 (
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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"
|
|
125
|
-
|
|
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({
|
|
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
|
-
|
|
212
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
}
|