copillm 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agentconfig/render.js +169 -25
- package/dist/cli/configCommands.js +15 -2
- package/dist/cli/launchAgent.js +8 -0
- package/dist/cli/processSafetyNet.js +52 -0
- package/dist/cli/resolveAgent.js +4 -2
- package/dist/cli.js +139 -18
- package/dist/config/home.js +3 -0
- package/dist/config/logging.js +27 -5
- package/dist/models/anthropicDefaults.js +1 -0
- package/dist/server/proxy.js +292 -26
- package/dist/server/requestLifecycle.js +115 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +77 -6
- package/package.json +1 -1
package/dist/server/proxy.js
CHANGED
|
@@ -10,6 +10,7 @@ import { translateOpenAIStreamToAnthropic, writeAnthropicPrelude } from "../tran
|
|
|
10
10
|
import { buildCodexCatalog } from "./codexSchema.js";
|
|
11
11
|
import { getGithubUserSummary, GithubUserFetchError } from "./debugInfo.js";
|
|
12
12
|
import { buildAnthropicModelsResponse } from "./anthropicModelsResponse.js";
|
|
13
|
+
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson, safeWrite } from "./requestLifecycle.js";
|
|
13
14
|
const COPILOT_HEADERS = {
|
|
14
15
|
"Content-Type": "application/json",
|
|
15
16
|
"Copilot-Integration-Id": "vscode-chat",
|
|
@@ -49,6 +50,7 @@ export async function startProxyServer(input) {
|
|
|
49
50
|
const requestId = randomUUID();
|
|
50
51
|
const startedAt = Date.now();
|
|
51
52
|
const pathname = safePathname(req.url);
|
|
53
|
+
const lifecycle = attachRequestLifecycle(req, res, input.logger, requestId);
|
|
52
54
|
res.on("finish", () => {
|
|
53
55
|
input.logger.info({
|
|
54
56
|
event: "http_request",
|
|
@@ -148,6 +150,7 @@ export async function startProxyServer(input) {
|
|
|
148
150
|
}
|
|
149
151
|
await handleDebug(res, {
|
|
150
152
|
config: input.config,
|
|
153
|
+
logger: input.logger,
|
|
151
154
|
tokenManager: input.tokenManager,
|
|
152
155
|
githubToken: input.githubToken,
|
|
153
156
|
port: input.port
|
|
@@ -192,6 +195,17 @@ export async function startProxyServer(input) {
|
|
|
192
195
|
beginAnthropicSseResponse(res, req);
|
|
193
196
|
prelude = writeAnthropicPrelude(res, requestedModel ?? "");
|
|
194
197
|
}
|
|
198
|
+
input.logger.debug({
|
|
199
|
+
event: "request_prepared",
|
|
200
|
+
request_id: requestId,
|
|
201
|
+
route: route.kind,
|
|
202
|
+
anthro_shape: route.anthroShape,
|
|
203
|
+
requested_model: requestedModel,
|
|
204
|
+
upstream_model: readRequestedModel(upstreamBody),
|
|
205
|
+
model_resolution_rule: resolvedModel?.rule ?? null,
|
|
206
|
+
upstream_path: upstreamPath,
|
|
207
|
+
...summarizeUpstreamPayload(upstreamBody)
|
|
208
|
+
}, "prepared upstream request");
|
|
195
209
|
try {
|
|
196
210
|
const upstream = await postToCopilot({
|
|
197
211
|
tokenManager: input.tokenManager,
|
|
@@ -199,11 +213,22 @@ export async function startProxyServer(input) {
|
|
|
199
213
|
body: upstreamBody,
|
|
200
214
|
requestId,
|
|
201
215
|
logger: input.logger,
|
|
202
|
-
upstreamPath
|
|
216
|
+
upstreamPath,
|
|
217
|
+
signal: lifecycle.signal
|
|
218
|
+
});
|
|
219
|
+
await forwardResponse(upstream, route.anthroShape, res, {
|
|
220
|
+
requestedModel: requestedModel ?? undefined,
|
|
221
|
+
prelude,
|
|
222
|
+
logger: input.logger,
|
|
223
|
+
requestId
|
|
203
224
|
});
|
|
204
|
-
await forwardResponse(upstream, route.anthroShape, res, requestedModel ?? undefined, prelude);
|
|
205
225
|
}
|
|
206
226
|
catch (error) {
|
|
227
|
+
if (isBenignSocketError(error)) {
|
|
228
|
+
input.logger.debug({ event: "upstream_aborted", request_id: requestId, err: error }, "upstream request aborted (client disconnected)");
|
|
229
|
+
safeEnd(res);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
207
232
|
if (error instanceof CopilotTokenManagerError) {
|
|
208
233
|
if (prelude) {
|
|
209
234
|
writeAnthropicSseError(res, prelude, "token_refresh_failed");
|
|
@@ -232,8 +257,31 @@ export async function startProxyServer(input) {
|
|
|
232
257
|
sendJson(res, 400, { error: error.code, detail: error.message });
|
|
233
258
|
return;
|
|
234
259
|
}
|
|
235
|
-
|
|
260
|
+
if (isBenignSocketError(error)) {
|
|
261
|
+
input.logger.debug({ event: "request_client_gone", request_id: requestId, err: error }, "request handler aborted because client disconnected");
|
|
262
|
+
safeEnd(res);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
input.logger.error({ err: error, request_id: requestId }, "request failed");
|
|
236
266
|
sendJson(res, 500, { error: "internal_error" });
|
|
267
|
+
safeEnd(res);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
server.on("clientError", (err, socket) => {
|
|
271
|
+
input.logger.debug({ event: "client_error", code: err?.code }, "malformed HTTP from client");
|
|
272
|
+
if (socket.writable) {
|
|
273
|
+
try {
|
|
274
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Socket likely already gone — nothing to do.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
socket.destroy();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// best effort
|
|
237
285
|
}
|
|
238
286
|
});
|
|
239
287
|
await new Promise((resolve, reject) => {
|
|
@@ -256,8 +304,29 @@ async function postToCopilot(input) {
|
|
|
256
304
|
let forceRefresh = false;
|
|
257
305
|
let authRefreshRetried = false;
|
|
258
306
|
for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt += 1) {
|
|
307
|
+
if (input.signal?.aborted) {
|
|
308
|
+
throw abortErrorFromSignal(input.signal);
|
|
309
|
+
}
|
|
259
310
|
try {
|
|
260
|
-
const
|
|
311
|
+
const attemptStartedAt = Date.now();
|
|
312
|
+
input.logger.debug({
|
|
313
|
+
event: "upstream_request",
|
|
314
|
+
request_id: input.requestId,
|
|
315
|
+
attempt,
|
|
316
|
+
upstream_path: input.upstreamPath,
|
|
317
|
+
force_refresh: forceRefresh
|
|
318
|
+
}, "posting upstream request");
|
|
319
|
+
const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
|
|
320
|
+
input.logger.debug({
|
|
321
|
+
event: "upstream_response",
|
|
322
|
+
request_id: input.requestId,
|
|
323
|
+
attempt,
|
|
324
|
+
upstream_path: input.upstreamPath,
|
|
325
|
+
status_code: response.status,
|
|
326
|
+
duration_ms: Date.now() - attemptStartedAt,
|
|
327
|
+
content_type: response.headers.get("content-type"),
|
|
328
|
+
retry_after: response.headers.get("retry-after")
|
|
329
|
+
}, "received upstream response");
|
|
261
330
|
forceRefresh = false;
|
|
262
331
|
if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
263
332
|
authRefreshRetried = true;
|
|
@@ -275,6 +344,10 @@ async function postToCopilot(input) {
|
|
|
275
344
|
return response;
|
|
276
345
|
}
|
|
277
346
|
catch (error) {
|
|
347
|
+
if (isBenignSocketError(error)) {
|
|
348
|
+
// Client disconnected — propagate so the request handler can clean up.
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
278
351
|
if (!isRetryableTransportError(error) || attempt >= MAX_UPSTREAM_ATTEMPTS) {
|
|
279
352
|
throw error;
|
|
280
353
|
}
|
|
@@ -284,7 +357,7 @@ async function postToCopilot(input) {
|
|
|
284
357
|
}
|
|
285
358
|
throw new Error("Upstream retry budget exhausted unexpectedly.");
|
|
286
359
|
}
|
|
287
|
-
async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath) {
|
|
360
|
+
async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath, signal) {
|
|
288
361
|
const bearer = await tokenManager.ensureToken({ forceRefresh });
|
|
289
362
|
return fetch(`${accountBaseUrl(accountType)}${upstreamPath}`, {
|
|
290
363
|
method: "POST",
|
|
@@ -293,46 +366,70 @@ async function postWithCurrentBearer(tokenManager, accountType, body, forceRefre
|
|
|
293
366
|
Authorization: `Bearer ${bearer}`,
|
|
294
367
|
"X-Request-Id": requestId
|
|
295
368
|
},
|
|
296
|
-
body: JSON.stringify(body)
|
|
369
|
+
body: JSON.stringify(body),
|
|
370
|
+
signal
|
|
297
371
|
});
|
|
298
372
|
}
|
|
299
|
-
|
|
373
|
+
function abortErrorFromSignal(signal) {
|
|
374
|
+
const reason = signal.reason;
|
|
375
|
+
if (reason instanceof Error) {
|
|
376
|
+
return reason;
|
|
377
|
+
}
|
|
378
|
+
const err = new Error("Request aborted by client");
|
|
379
|
+
err.name = "AbortError";
|
|
380
|
+
return err;
|
|
381
|
+
}
|
|
382
|
+
async function forwardResponse(upstream, anthroShape, res, diagnostics) {
|
|
300
383
|
if (!upstream.ok) {
|
|
301
|
-
await
|
|
384
|
+
const upstreamError = await readUpstreamError(upstream);
|
|
385
|
+
const category = upstreamStatusCategory(upstream.status);
|
|
386
|
+
diagnostics.logger.warn({
|
|
387
|
+
event: "upstream_non_ok",
|
|
388
|
+
request_id: diagnostics.requestId,
|
|
389
|
+
status_code: upstream.status,
|
|
390
|
+
error: category,
|
|
391
|
+
upstream_content_type: upstreamError.contentType,
|
|
392
|
+
upstream_error_code: upstreamError.code,
|
|
393
|
+
upstream_error_type: upstreamError.type,
|
|
394
|
+
upstream_error_message: upstreamError.message,
|
|
395
|
+
upstream_response_bytes: upstreamError.responseBytes
|
|
396
|
+
}, "upstream request failed");
|
|
397
|
+
const message = formatUpstreamErrorMessage(category, upstreamError);
|
|
398
|
+
const prelude = diagnostics.prelude ?? null;
|
|
302
399
|
if (prelude) {
|
|
303
|
-
writeAnthropicSseError(res, prelude,
|
|
400
|
+
writeAnthropicSseError(res, prelude, message);
|
|
304
401
|
return;
|
|
305
402
|
}
|
|
306
|
-
sendJson(res, upstream.status,
|
|
403
|
+
sendJson(res, upstream.status, buildUpstreamErrorPayload(category, upstream.status, diagnostics.requestId, upstreamError, anthroShape));
|
|
307
404
|
return;
|
|
308
405
|
}
|
|
309
406
|
if (isEventStream(upstream)) {
|
|
310
407
|
if (anthroShape) {
|
|
311
408
|
if (!upstream.body) {
|
|
312
|
-
if (prelude) {
|
|
313
|
-
writeAnthropicSseError(res, prelude, "invalid_upstream_response");
|
|
409
|
+
if (diagnostics.prelude) {
|
|
410
|
+
writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
|
|
314
411
|
return;
|
|
315
412
|
}
|
|
316
413
|
sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
|
|
317
414
|
return;
|
|
318
415
|
}
|
|
319
|
-
if (!prelude) {
|
|
416
|
+
if (!diagnostics.prelude) {
|
|
320
417
|
beginAnthropicSseResponse(res);
|
|
321
418
|
}
|
|
322
419
|
const upstreamReadable = Readable.fromWeb(upstream.body);
|
|
323
420
|
await translateOpenAIStreamToAnthropic({
|
|
324
421
|
upstream: upstreamReadable,
|
|
325
422
|
downstream: res,
|
|
326
|
-
fallbackModel: requestedModel,
|
|
327
|
-
preEmittedMessageId: prelude?.messageId
|
|
423
|
+
fallbackModel: diagnostics.requestedModel,
|
|
424
|
+
preEmittedMessageId: diagnostics.prelude?.messageId
|
|
328
425
|
});
|
|
329
426
|
return;
|
|
330
427
|
}
|
|
331
428
|
await pipeEventStream(upstream, res);
|
|
332
429
|
return;
|
|
333
430
|
}
|
|
334
|
-
if (prelude) {
|
|
335
|
-
writeAnthropicSseError(res, prelude, "invalid_upstream_response");
|
|
431
|
+
if (diagnostics.prelude) {
|
|
432
|
+
writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
|
|
336
433
|
return;
|
|
337
434
|
}
|
|
338
435
|
let json;
|
|
@@ -386,7 +483,17 @@ async function pipeEventStream(upstream, res) {
|
|
|
386
483
|
if (res.socket && typeof res.socket.setNoDelay === "function") {
|
|
387
484
|
res.socket.setNoDelay(true);
|
|
388
485
|
}
|
|
389
|
-
|
|
486
|
+
try {
|
|
487
|
+
await pipeline(Readable.fromWeb(upstream.body), res);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
if (isBenignSocketError(error)) {
|
|
491
|
+
// Client went away mid-stream — normal for SSE consumers (Codex,
|
|
492
|
+
// Claude Code, pi) that cancel pending responses on user input.
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
390
497
|
}
|
|
391
498
|
function isEventStream(upstream) {
|
|
392
499
|
const contentType = upstream.headers.get("content-type");
|
|
@@ -415,16 +522,16 @@ function beginAnthropicSseResponse(res, req) {
|
|
|
415
522
|
export function writeAnthropicSseError(res, prelude, code) {
|
|
416
523
|
void prelude;
|
|
417
524
|
try {
|
|
418
|
-
res
|
|
525
|
+
safeWrite(res, `event: message_delta\ndata: ${JSON.stringify({
|
|
419
526
|
type: "message_delta",
|
|
420
527
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
421
528
|
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }
|
|
422
529
|
})}\n\n`);
|
|
423
|
-
res
|
|
424
|
-
res
|
|
530
|
+
safeWrite(res, `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: code } })}\n\n`);
|
|
531
|
+
safeWrite(res, `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
425
532
|
}
|
|
426
533
|
finally {
|
|
427
|
-
res
|
|
534
|
+
safeEnd(res);
|
|
428
535
|
}
|
|
429
536
|
}
|
|
430
537
|
function isLocalRequest(req) {
|
|
@@ -455,9 +562,7 @@ async function readJson(req) {
|
|
|
455
562
|
}
|
|
456
563
|
}
|
|
457
564
|
function sendJson(res, status, payload) {
|
|
458
|
-
res
|
|
459
|
-
res.setHeader("Content-Type", "application/json");
|
|
460
|
-
res.end(JSON.stringify(payload));
|
|
565
|
+
safeSendJson(res, status, payload);
|
|
461
566
|
}
|
|
462
567
|
function readRequestedModel(payload) {
|
|
463
568
|
if (!payload || typeof payload !== "object") {
|
|
@@ -548,7 +653,9 @@ async function handleDebug(res, input) {
|
|
|
548
653
|
uptime_seconds: uptimeSeconds,
|
|
549
654
|
account_type: input.config.accountType,
|
|
550
655
|
selected_models: input.config.selectedModels,
|
|
551
|
-
require_caller_secret: input.config.requireCallerSecret
|
|
656
|
+
require_caller_secret: input.config.requireCallerSecret,
|
|
657
|
+
log_level: input.logger.level,
|
|
658
|
+
log_file: process.env.COPILLM_LOG_FILE ?? null
|
|
552
659
|
},
|
|
553
660
|
auth: {
|
|
554
661
|
bearer_ttl_seconds: bearerTtlSeconds,
|
|
@@ -656,6 +763,165 @@ function upstreamStatusCategory(status) {
|
|
|
656
763
|
}
|
|
657
764
|
return "upstream_error";
|
|
658
765
|
}
|
|
766
|
+
async function readUpstreamError(response) {
|
|
767
|
+
const contentType = response.headers.get("content-type");
|
|
768
|
+
let text;
|
|
769
|
+
try {
|
|
770
|
+
text = await response.text();
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return { contentType, code: null, type: null, message: null, responseBytes: null };
|
|
774
|
+
}
|
|
775
|
+
const trimmed = text.trim();
|
|
776
|
+
const responseBytes = Buffer.byteLength(text, "utf8");
|
|
777
|
+
if (trimmed.length === 0) {
|
|
778
|
+
return { contentType, code: null, type: null, message: null, responseBytes };
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const parsed = JSON.parse(trimmed);
|
|
782
|
+
const extracted = extractErrorFields(parsed);
|
|
783
|
+
if (extracted.message || extracted.code || extracted.type) {
|
|
784
|
+
return { contentType, responseBytes, ...extracted };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// Fall through to a plain-text snippet below.
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
contentType,
|
|
792
|
+
code: null,
|
|
793
|
+
type: null,
|
|
794
|
+
message: truncateForDiagnostics(trimmed),
|
|
795
|
+
responseBytes
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function extractErrorFields(payload) {
|
|
799
|
+
if (!payload || typeof payload !== "object") {
|
|
800
|
+
return { code: null, type: null, message: typeof payload === "string" ? truncateForDiagnostics(payload) : null };
|
|
801
|
+
}
|
|
802
|
+
const record = payload;
|
|
803
|
+
const nested = record.error;
|
|
804
|
+
if (typeof nested === "string") {
|
|
805
|
+
return {
|
|
806
|
+
code: readStringField(record, "code"),
|
|
807
|
+
type: readStringField(record, "type"),
|
|
808
|
+
message: truncateForDiagnostics(nested)
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (nested && typeof nested === "object") {
|
|
812
|
+
const errorRecord = nested;
|
|
813
|
+
return {
|
|
814
|
+
code: readStringField(errorRecord, "code") ?? readStringField(record, "code"),
|
|
815
|
+
type: readStringField(errorRecord, "type") ?? readStringField(record, "type"),
|
|
816
|
+
message: readTruncatedStringField(errorRecord, "message") ??
|
|
817
|
+
readTruncatedStringField(errorRecord, "detail") ??
|
|
818
|
+
readTruncatedStringField(record, "message") ??
|
|
819
|
+
readTruncatedStringField(record, "detail")
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
code: readStringField(record, "code"),
|
|
824
|
+
type: readStringField(record, "type"),
|
|
825
|
+
message: readTruncatedStringField(record, "message") ?? readTruncatedStringField(record, "detail")
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function buildUpstreamErrorPayload(category, statusCode, requestId, upstreamError, anthroShape) {
|
|
829
|
+
const code = upstreamError.code ?? category;
|
|
830
|
+
const type = upstreamError.type ?? category;
|
|
831
|
+
const message = formatUserFacingUpstreamErrorMessage(category, upstreamError);
|
|
832
|
+
if (anthroShape) {
|
|
833
|
+
return {
|
|
834
|
+
type: "error",
|
|
835
|
+
error: {
|
|
836
|
+
type,
|
|
837
|
+
message,
|
|
838
|
+
code,
|
|
839
|
+
upstream_status_code: statusCode,
|
|
840
|
+
request_id: requestId
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
error: {
|
|
846
|
+
type,
|
|
847
|
+
code,
|
|
848
|
+
message,
|
|
849
|
+
upstream_status_code: statusCode,
|
|
850
|
+
request_id: requestId
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function formatUpstreamErrorMessage(category, upstreamError) {
|
|
855
|
+
const parts = [upstreamError.code, upstreamError.type, upstreamError.message].filter((part) => typeof part === "string" && part.length > 0);
|
|
856
|
+
return parts.length > 0 ? `${category}: ${parts.join(": ")}` : category;
|
|
857
|
+
}
|
|
858
|
+
function formatUserFacingUpstreamErrorMessage(category, upstreamError) {
|
|
859
|
+
if (upstreamError.code && upstreamError.message) {
|
|
860
|
+
return `${upstreamError.code}: ${upstreamError.message}`;
|
|
861
|
+
}
|
|
862
|
+
if (upstreamError.message) {
|
|
863
|
+
return upstreamError.message;
|
|
864
|
+
}
|
|
865
|
+
return upstreamError.code ?? upstreamError.type ?? category;
|
|
866
|
+
}
|
|
867
|
+
function readStringField(record, key) {
|
|
868
|
+
const value = record[key];
|
|
869
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
870
|
+
}
|
|
871
|
+
function readTruncatedStringField(record, key) {
|
|
872
|
+
const value = readStringField(record, key);
|
|
873
|
+
return value ? truncateForDiagnostics(value) : null;
|
|
874
|
+
}
|
|
875
|
+
function truncateForDiagnostics(value) {
|
|
876
|
+
const maxChars = 500;
|
|
877
|
+
return value.length > maxChars ? `${value.slice(0, maxChars)}...` : value;
|
|
878
|
+
}
|
|
879
|
+
function summarizeUpstreamPayload(payload) {
|
|
880
|
+
let requestBytes = null;
|
|
881
|
+
try {
|
|
882
|
+
requestBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
requestBytes = null;
|
|
886
|
+
}
|
|
887
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
888
|
+
return { upstream_request_bytes: requestBytes };
|
|
889
|
+
}
|
|
890
|
+
const record = payload;
|
|
891
|
+
const messages = Array.isArray(record.messages) ? record.messages : null;
|
|
892
|
+
const input = Array.isArray(record.input) ? record.input : null;
|
|
893
|
+
return {
|
|
894
|
+
upstream_request_bytes: requestBytes,
|
|
895
|
+
stream: record.stream === true,
|
|
896
|
+
max_tokens: typeof record.max_tokens === "number" ? record.max_tokens : null,
|
|
897
|
+
message_count: messages?.length ?? null,
|
|
898
|
+
input_item_count: input?.length ?? null,
|
|
899
|
+
tool_count: Array.isArray(record.tools) ? record.tools.length : 0,
|
|
900
|
+
text_characters: sumTextCharacters(payload)
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function sumTextCharacters(value) {
|
|
904
|
+
if (typeof value === "string") {
|
|
905
|
+
return value.length;
|
|
906
|
+
}
|
|
907
|
+
if (!value || typeof value !== "object") {
|
|
908
|
+
return 0;
|
|
909
|
+
}
|
|
910
|
+
if (Array.isArray(value)) {
|
|
911
|
+
return value.reduce((total, item) => total + sumTextCharacters(item), 0);
|
|
912
|
+
}
|
|
913
|
+
let total = 0;
|
|
914
|
+
const record = value;
|
|
915
|
+
for (const [key, nested] of Object.entries(record)) {
|
|
916
|
+
if (key === "text" || key === "content" || key === "arguments" || key === "input") {
|
|
917
|
+
total += sumTextCharacters(nested);
|
|
918
|
+
}
|
|
919
|
+
else if (nested && typeof nested === "object" && key !== "data" && key !== "image_url" && key !== "source") {
|
|
920
|
+
total += sumTextCharacters(nested);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return total;
|
|
924
|
+
}
|
|
659
925
|
function healthFailure(error) {
|
|
660
926
|
if (error instanceof CopilotTokenExchangeError) {
|
|
661
927
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Node error codes we treat as "client went away, this is normal" — they
|
|
2
|
+
// must never crash the daemon and must not be reported as errors. SSE clients
|
|
3
|
+
// (Codex, Claude Code, pi) abort streams constantly: user types another
|
|
4
|
+
// prompt, hits Esc, switches turns, etc.
|
|
5
|
+
const BENIGN_SOCKET_ERROR_CODES = new Set([
|
|
6
|
+
"ECONNRESET",
|
|
7
|
+
"EPIPE",
|
|
8
|
+
"ERR_STREAM_PREMATURE_CLOSE",
|
|
9
|
+
"ERR_STREAM_DESTROYED",
|
|
10
|
+
"ERR_STREAM_WRITE_AFTER_END",
|
|
11
|
+
"ERR_HTTP_HEADERS_SENT"
|
|
12
|
+
]);
|
|
13
|
+
export function isBenignSocketError(error) {
|
|
14
|
+
if (!error || typeof error !== "object") {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const err = error;
|
|
18
|
+
if (typeof err.code === "string" && BENIGN_SOCKET_ERROR_CODES.has(err.code)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (err.name === "AbortError") {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (err.cause && isBenignSocketError(err.cause)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
export function attachRequestLifecycle(req, res, logger, requestId) {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
let alive = true;
|
|
32
|
+
const markGone = (source, err) => {
|
|
33
|
+
if (!alive) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
alive = false;
|
|
37
|
+
if (err && !isBenignSocketError(err)) {
|
|
38
|
+
logger.debug({ event: "request_lifecycle_error", request_id: requestId, source, err }, "request stream errored");
|
|
39
|
+
}
|
|
40
|
+
else if (source !== "close" || err) {
|
|
41
|
+
logger.debug({ event: "request_lifecycle_closed", request_id: requestId, source }, "client disconnected");
|
|
42
|
+
}
|
|
43
|
+
if (!controller.signal.aborted) {
|
|
44
|
+
controller.abort();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
res.on("close", () => markGone("close"));
|
|
48
|
+
res.on("error", (err) => markGone("error", err));
|
|
49
|
+
req.on("aborted", () => markGone("aborted"));
|
|
50
|
+
req.on("error", (err) => markGone("error", err));
|
|
51
|
+
return {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
isAlive: () => alive && res.writable && !res.writableEnded
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Writes a JSON response, but is a no-op when the response is already
|
|
58
|
+
* committed or the socket is gone. This is the safe replacement for
|
|
59
|
+
* `res.setHeader(...) + res.end(...)` in any path that might run after a
|
|
60
|
+
* streaming response has started flushing.
|
|
61
|
+
*/
|
|
62
|
+
export function safeSendJson(res, status, payload) {
|
|
63
|
+
if (res.headersSent || !res.writable || res.writableEnded) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
res.statusCode = status;
|
|
68
|
+
res.setHeader("Content-Type", "application/json");
|
|
69
|
+
res.end(JSON.stringify(payload));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (isBenignSocketError(error)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort write to a downstream Writable. Returns false when the chunk
|
|
80
|
+
* could not be delivered (because the stream is destroyed or the write
|
|
81
|
+
* threw a benign socket error). Non-benign errors are re-thrown so they're
|
|
82
|
+
* still visible to the caller.
|
|
83
|
+
*/
|
|
84
|
+
export function safeWrite(downstream, chunk) {
|
|
85
|
+
if (!downstream.writable || downstream.writableEnded || downstream.destroyed) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return downstream.write(chunk);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (isBenignSocketError(error)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Best-effort end of a downstream Writable. Swallows benign socket errors;
|
|
100
|
+
* never throws on a destroyed stream.
|
|
101
|
+
*/
|
|
102
|
+
export function safeEnd(downstream) {
|
|
103
|
+
if (downstream.writableEnded || downstream.destroyed) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
downstream.end();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (isBenignSocketError(error)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|