codex-copilot-dx 0.6.15 → 0.6.18

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.
@@ -1,7 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chatCompletions } from "./copilot.mjs";
3
3
  import { webStreamLines } from "./stream.mjs";
4
- import { abortErrorStatusCode, isAbortLikeError } from "./http-transport.mjs";
4
+ import {
5
+ abortErrorStatusCode,
6
+ isAbortLikeError,
7
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
8
+ readBoundedResponseText,
9
+ } from "./http-transport.mjs";
5
10
  import {
6
11
  incompleteUpstreamStream,
7
12
  invalidUpstreamStream,
@@ -216,7 +221,10 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
216
221
  return false;
217
222
  }
218
223
  if (!resp.ok) {
219
- await onError(resp.status, await resp.text(), undefined, resp);
224
+ await onError(resp.status, await readBoundedResponseText(resp, {
225
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
226
+ label: "Copilot Chat error body",
227
+ }), undefined, resp);
220
228
  return false;
221
229
  }
222
230
  try {
@@ -235,6 +243,13 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
235
243
  let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
236
244
  const toolCalls = new Map();
237
245
  const toolArgumentGuard = new ToolArgumentDeltaGuard();
246
+ const downstreamClosed = Symbol("downstream-closed");
247
+ const configuredEmitEvent = emitEvent;
248
+ emitEvent = async (...args) => {
249
+ const written = await configuredEmitEvent(...args);
250
+ if (written === false) throw downstreamClosed;
251
+ return written;
252
+ };
238
253
  let performanceOutputSeen = false;
239
254
  const markPerformanceOutput = () => {
240
255
  if (performanceOutputSeen) return;
@@ -242,8 +257,6 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
242
257
  markFirstOutput();
243
258
  };
244
259
 
245
- await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
246
-
247
260
  const ensureMessageItem = async () => {
248
261
  if (messageItem) return messageItem;
249
262
  messageItem = { id: `msg_${uid()}`, outputIndex: nextOutputIndex++ };
@@ -331,6 +344,7 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
331
344
  };
332
345
 
333
346
  try {
347
+ await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
334
348
  for await (const line of webStreamLines(resp, {
335
349
  onChunk: () => abort?.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
336
350
  })) {
@@ -386,6 +400,10 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
386
400
  }
387
401
  }
388
402
  } catch (e) {
403
+ if (e === downstreamClosed) {
404
+ await resp.body?.cancel?.().catch?.(() => {});
405
+ return false;
406
+ }
389
407
  const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(abort?.reason) : (e?.statusCode || 502);
390
408
  await onError(statusCode, e?.message || "upstream stream error", e);
391
409
  return false;
@@ -3,7 +3,9 @@ import { applyCopilotResponsesRequestPolicies } from "./copilot-responses-policy
3
3
  import {
4
4
  createRequestAbort,
5
5
  logRequestFailure,
6
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
6
7
  readJsonBody,
8
+ readBoundedResponseText,
7
9
  sendJsonError,
8
10
  sendUpstreamError,
9
11
  writeOrDrain,
@@ -12,6 +14,7 @@ import {
12
14
  CODEX_AUTO_REVIEW_MODEL,
13
15
  modelIsResponsesOnly,
14
16
  modelSupportsChatCompletions,
17
+ resolveCopilotPriorityTierModel,
15
18
  resolveOpenAIModel,
16
19
  } from "./models.mjs";
17
20
  import { chatToResponses, forwardToChat, responsesBodyUsesCustomTools } from "./responses-bridge.mjs";
@@ -19,7 +22,7 @@ import { prepareResponsesChatPayload } from "./responses-chat-payload.mjs";
19
22
  import { prepareResponsesCompactionRequest } from "./responses-compaction.mjs";
20
23
  import { clearResponsesToolOutputPartsCache } from "./responses-content.mjs";
21
24
  import { proxyCopilotResponses } from "./responses-proxy.mjs";
22
- import { responseHistoryMaterializedBytes } from "./response-history.mjs";
25
+ import { acquireResponseHistorySnapshot } from "./response-history.mjs";
23
26
  import {
24
27
  applyResponseHistoryRoutePlan,
25
28
  dropMaterializedResponseHistory,
@@ -90,6 +93,7 @@ export function createResponsesHandler(options) {
90
93
  chatCompletionsFn,
91
94
  getCachedModelEndpointsFn = getCachedModelEndpoints,
92
95
  imagePressure,
96
+ modelRegistry,
93
97
  openAIModelEnv,
94
98
  requestBodyTimeoutMs,
95
99
  responsesPayloadOptions,
@@ -105,10 +109,15 @@ export function createResponsesHandler(options) {
105
109
  let releaseRequest = () => {};
106
110
  let prepared = null;
107
111
  let imagePressureResult = null;
112
+ let historySnapshot = null;
108
113
  let parsed = null;
109
114
  let upstreamPayloadReleased = false;
110
115
  let activeDeadline = Number.POSITIVE_INFINITY;
111
116
  let activeTimeoutReason = "responses_prepare_timeout";
117
+ const releaseHistorySnapshot = () => {
118
+ historySnapshot?.release();
119
+ historySnapshot = null;
120
+ };
112
121
  const assertPrepareActive = () => {
113
122
  if (abort.signal.aborted) throw abort.signal.reason;
114
123
  if (now() < activeDeadline) return;
@@ -152,6 +161,7 @@ export function createResponsesHandler(options) {
152
161
  const releaseUpstreamPayload = (finalContext) => {
153
162
  if (upstreamPayloadReleased) return;
154
163
  upstreamPayloadReleased = true;
164
+ if (finalContext?.historyParentId === null) releaseHistorySnapshot();
155
165
  clearResponsesToolOutputPartsCache(prepared?.body?.input);
156
166
  clearResponsesToolOutputPartsCache(prepared?.historyInputItems);
157
167
  if (finalContext && finalContext !== prepared) {
@@ -170,15 +180,19 @@ export function createResponsesHandler(options) {
170
180
  activeTimeoutReason = "responses_prepare_timeout";
171
181
  abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
172
182
  if (parsed.previous_response_id !== undefined && parsed.previous_response_id !== null) {
173
- const historyBytes = responseHistoryMaterializedBytes(parsed.previous_response_id);
174
- if (historyBytes > 0) {
175
- await releaseRequest.reserveResponseHistory?.(historyBytes, { signal: abort.signal });
183
+ historySnapshot = acquireResponseHistorySnapshot(parsed.previous_response_id, {
184
+ assertActive: assertPrepareActive,
185
+ signal: abort.signal,
186
+ });
187
+ if (historySnapshot.bytes > 0) {
188
+ await releaseRequest.reserveResponseHistory?.(historySnapshot.bytes, { signal: abort.signal });
176
189
  }
177
190
  assertPrepareActive();
178
191
  }
179
192
  prepared = prepareResponsesRequest(parsed, {
180
193
  assertActive: assertPrepareActive,
181
194
  copilotBoundary: false,
195
+ historySnapshot,
182
196
  mutate: true,
183
197
  });
184
198
  parsed = null;
@@ -191,12 +205,22 @@ export function createResponsesHandler(options) {
191
205
  prepared.surface = "responses";
192
206
  const model = prepared.body.model || "unknown";
193
207
  const streaming = prepared.body.stream === true;
194
- const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
208
+ const resolvedModel = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
209
+ const { requestedModel } = resolvedModel;
210
+ let { upstreamModel } = resolvedModel;
211
+ const priorityTierModel = requestedModel === upstreamModel
212
+ ? resolveCopilotPriorityTierModel(upstreamModel, prepared.body.service_tier, modelRegistry?.models)
213
+ : null;
214
+ if (priorityTierModel) {
215
+ upstreamModel = priorityTierModel;
216
+ delete prepared.body.service_tier;
217
+ }
195
218
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
196
219
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
197
220
  console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${streaming}`));
198
221
  const usesCustomTools = responsesBodyUsesCustomTools(prepared.body);
199
- const useNativeResponses = requestedModel === CODEX_AUTO_REVIEW_MODEL
222
+ const useNativeResponses = Boolean(priorityTierModel)
223
+ || requestedModel === CODEX_AUTO_REVIEW_MODEL
200
224
  || isResponsesOnlyModel(upstreamModel, getCachedModelEndpointsFn)
201
225
  || (usesCustomTools && cachedModelSupportsResponses(upstreamModel, getCachedModelEndpointsFn));
202
226
  if (usesCustomTools && !useNativeResponses) throw unsupportedCustomToolsError(upstreamModel);
@@ -209,6 +233,7 @@ export function createResponsesHandler(options) {
209
233
  surface: "responses",
210
234
  });
211
235
  prepared = applyResponseHistoryRoutePlan(prepared, routePlan);
236
+ if (prepared.historyParentId === null) releaseHistorySnapshot();
212
237
  applyCopilotResponsesRequestPolicies(prepared.body);
213
238
  if (routePlan.protocol === "openai-responses") {
214
239
  const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
@@ -241,11 +266,13 @@ export function createResponsesHandler(options) {
241
266
  "Cache-Control": "no-cache",
242
267
  Connection: "keep-alive",
243
268
  });
244
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
269
+ const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
270
+ if (!written) return false;
245
271
  if (event === "response.completed") {
246
272
  rememberResponseHistory(prepared, data.response);
247
273
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
248
274
  }
275
+ return true;
249
276
  }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg, error, upstreamResponse) => {
250
277
  if (!res.headersSent) {
251
278
  if (upstreamResponse) {
@@ -282,7 +309,12 @@ export function createResponsesHandler(options) {
282
309
  onUpstreamStart: startUpstreamTimeout,
283
310
  });
284
311
  releaseChatPayload();
285
- const data = await upstream.text();
312
+ const data = upstream.ok
313
+ ? await upstream.text()
314
+ : await readBoundedResponseText(upstream, {
315
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
316
+ label: "Copilot Chat error body",
317
+ });
286
318
  if (!upstream.ok) {
287
319
  markAdaptiveHttpTimeout(upstream.status);
288
320
  sendUpstreamError(res, upstream, data);
@@ -311,6 +343,7 @@ export function createResponsesHandler(options) {
311
343
  sendJsonError(res, responseError, 502);
312
344
  } finally {
313
345
  releaseUpstreamPayload();
346
+ releaseHistorySnapshot();
314
347
  abort.cleanup();
315
348
  }
316
349
  };
@@ -321,6 +354,7 @@ export function createResponsesCompactHandler(options) {
321
354
  acquireRequest,
322
355
  autoReviewModelResolver,
323
356
  imagePressure,
357
+ modelRegistry,
324
358
  openAIModelEnv,
325
359
  requestBodyTimeoutMs,
326
360
  responsesCompactFn,
@@ -332,7 +366,47 @@ export function createResponsesCompactHandler(options) {
332
366
  const abort = createRequestAbort(req, res);
333
367
  let releaseRequest = () => {};
334
368
  let prepared = null;
369
+ let imagePressureResult = null;
370
+ let historySnapshot = null;
335
371
  let upstreamPayloadReleased = false;
372
+ let activeDeadline = Number.POSITIVE_INFINITY;
373
+ let activeTimeoutReason = "responses_prepare_timeout";
374
+ const releaseHistorySnapshot = () => {
375
+ historySnapshot?.release();
376
+ historySnapshot = null;
377
+ };
378
+ const assertPrepareActive = () => {
379
+ if (abort.signal.aborted) throw abort.signal.reason;
380
+ if (now() < activeDeadline) return;
381
+ abort.abort(activeTimeoutReason);
382
+ throw abort.signal.reason;
383
+ };
384
+ const startUpstreamTimeout = () => {
385
+ assertPrepareActive();
386
+ activeDeadline = now() + upstreamTimeoutMs;
387
+ activeTimeoutReason = "upstream_timeout";
388
+ abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
389
+ };
390
+ const pressureRootId = () => responseHistoryPressureRootId(prepared);
391
+ const markCompactTimeout = () => {
392
+ const eligible = imagePressure?.isEligible?.(prepared) === true;
393
+ return imagePressure?.markTimeout?.(pressureRootId(), { eligible }) || false;
394
+ };
395
+ const applyAdaptiveTimeout = (error) => {
396
+ if (!["responses_prepare_timeout", "upstream_timeout"].includes(abort.reason)) return error;
397
+ if (!markCompactTimeout()) return error;
398
+ const message = "Responses compact timed out while processing a large visual history. Retry the request; ccdx will keep fewer earlier images automatically. Restart is not required.";
399
+ error.statusCode = 504;
400
+ error.jsonBody = {
401
+ error: {
402
+ message,
403
+ type: "timeout_error",
404
+ code: "ccdx_visual_history_timeout",
405
+ retryable: true,
406
+ },
407
+ };
408
+ return error;
409
+ };
336
410
  const releaseUpstreamPayload = (finalContext) => {
337
411
  if (upstreamPayloadReleased) return;
338
412
  upstreamPayloadReleased = true;
@@ -348,39 +422,43 @@ export function createResponsesCompactHandler(options) {
348
422
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
349
423
  abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
350
424
  const parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
351
- let activeDeadline = now() + upstreamTimeoutMs;
352
- let activeTimeoutReason = "responses_prepare_timeout";
353
- const assertPrepareActive = () => {
354
- if (abort.signal.aborted) throw abort.signal.reason;
355
- if (now() < activeDeadline) return;
356
- abort.abort(activeTimeoutReason);
357
- throw abort.signal.reason;
358
- };
359
- const startUpstreamTimeout = () => {
360
- assertPrepareActive();
361
- activeDeadline = now() + upstreamTimeoutMs;
362
- activeTimeoutReason = "upstream_timeout";
363
- abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
364
- };
425
+ activeDeadline = now() + upstreamTimeoutMs;
426
+ activeTimeoutReason = "responses_prepare_timeout";
365
427
  abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
366
428
  if (parsed.previous_response_id !== undefined && parsed.previous_response_id !== null) {
367
- const historyBytes = responseHistoryMaterializedBytes(parsed.previous_response_id);
368
- if (historyBytes > 0) {
369
- await releaseRequest.reserveResponseHistory?.(historyBytes, { signal: abort.signal });
429
+ historySnapshot = acquireResponseHistorySnapshot(parsed.previous_response_id, {
430
+ assertActive: assertPrepareActive,
431
+ signal: abort.signal,
432
+ });
433
+ if (historySnapshot.bytes > 0) {
434
+ await releaseRequest.reserveResponseHistory?.(historySnapshot.bytes, { signal: abort.signal });
370
435
  }
371
436
  assertPrepareActive();
372
437
  }
373
- prepared = prepareResponsesCompactionRequest(
374
- prepareResponsesRequest(parsed, {
375
- assertActive: assertPrepareActive,
376
- copilotBoundary: false,
377
- mutate: true,
378
- }),
379
- );
438
+ prepared = prepareResponsesRequest(parsed, {
439
+ assertActive: assertPrepareActive,
440
+ copilotBoundary: false,
441
+ historySnapshot,
442
+ mutate: true,
443
+ });
444
+ imagePressureResult = imagePressure?.applyRecovery?.(prepared, { assertActive: assertPrepareActive }) || null;
445
+ if (imagePressureResult?.adapted) {
446
+ console.warn(status("warn", `responses compact visual history mode=recovery historical_images=${imagePressureResult.initialHistoricalImages}->${imagePressureResult.historicalImages} omitted=${imagePressureResult.imagesOmitted} bytes=${imagePressureResult.initialBodyBytes}->${imagePressureResult.bodyBytes}`));
447
+ }
448
+ prepared = prepareResponsesCompactionRequest(prepared);
380
449
  assertPrepareActive();
381
450
  prepared.surface = "responses_compact";
382
451
  const model = parsed.model || "unknown";
383
- const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
452
+ const resolvedModel = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
453
+ const { requestedModel } = resolvedModel;
454
+ let { upstreamModel } = resolvedModel;
455
+ const priorityTierModel = requestedModel === upstreamModel
456
+ ? resolveCopilotPriorityTierModel(upstreamModel, prepared.body.service_tier, modelRegistry?.models)
457
+ : null;
458
+ if (priorityTierModel) {
459
+ upstreamModel = priorityTierModel;
460
+ delete prepared.body.service_tier;
461
+ }
384
462
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
385
463
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
386
464
  console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
@@ -392,6 +470,7 @@ export function createResponsesCompactHandler(options) {
392
470
  model: upstreamModel,
393
471
  surface: "responses-compact",
394
472
  }));
473
+ releaseHistorySnapshot();
395
474
  applyCopilotResponsesRequestPolicies(prepared.body);
396
475
  const result = await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
397
476
  assertPrepareActive,
@@ -401,11 +480,14 @@ export function createResponsesCompactHandler(options) {
401
480
  releaseRequest: releaseUpstreamPayload,
402
481
  });
403
482
  if (result?.compacted) imagePressure?.clear?.(responseHistoryPressureRootId(prepared));
483
+ else if (result?.upstreamStatus === 408) markCompactTimeout();
404
484
  } catch (error) {
405
- logRequestFailure("Responses compact", error, abort);
406
- sendJsonError(res, error, 502);
485
+ const responseError = applyAdaptiveTimeout(error);
486
+ logRequestFailure("Responses compact", responseError, abort);
487
+ sendJsonError(res, responseError, 502);
407
488
  } finally {
408
489
  releaseUpstreamPayload();
490
+ releaseHistorySnapshot();
409
491
  abort.cleanup();
410
492
  }
411
493
  };
@@ -25,6 +25,20 @@ function bodyPayload(body, assertActive) {
25
25
  return { bodyText, bodyBytes: Buffer.byteLength(bodyText) };
26
26
  }
27
27
 
28
+ export function responsesImagePressureEligible(reqContext, {
29
+ assertActive,
30
+ policy: policyOverrides,
31
+ } = {}) {
32
+ const policy = normalizedPolicy(policyOverrides);
33
+ const currentInputStart = Number.isFinite(reqContext?.currentInputStart)
34
+ ? reqContext.currentInputStart
35
+ : 0;
36
+ const images = responsesHistoricalImageStats(reqContext?.body?.input, currentInputStart, { assertActive });
37
+ if (images.historicalImages === 0) return false;
38
+ if (images.historicalImages > policy.triggerHistoricalImages) return true;
39
+ return bodyPayload(reqContext?.body, assertActive).bodyBytes > policy.triggerBodyBytes;
40
+ }
41
+
28
42
  export function applyResponsesImagePressure(reqContext, {
29
43
  assertActive,
30
44
  mode = "normal",
@@ -143,6 +157,22 @@ export function createResponsesImagePressureController({
143
157
  }
144
158
  return result;
145
159
  },
160
+ isEligible(reqContext, { assertActive } = {}) {
161
+ return responsesImagePressureEligible(reqContext, { assertActive, policy });
162
+ },
163
+ applyRecovery(reqContext, { assertActive } = {}) {
164
+ if (modeFor(reqContext?.historyRootId) !== "recovery") return null;
165
+ const result = applyResponsesImagePressure(reqContext, {
166
+ assertActive,
167
+ mode: "recovery",
168
+ policy,
169
+ });
170
+ if (result.adapted) {
171
+ counters.adaptedRequests += 1;
172
+ counters.imagesOmitted += result.imagesOmitted;
173
+ }
174
+ return result;
175
+ },
146
176
  markTimeout(rootId, { eligible = false } = {}) {
147
177
  if (!rootId || !eligible) return false;
148
178
  prune();
@@ -2,6 +2,8 @@ import { responses as copilotResponses } from "./copilot.mjs";
2
2
  import {
3
3
  httpError,
4
4
  logRequestFailure,
5
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
6
+ readBoundedResponseText,
5
7
  sendUpstreamError,
6
8
  writeOrDrain,
7
9
  } from "./http-transport.mjs";
@@ -262,7 +264,12 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
262
264
  reader.releaseLock();
263
265
  }
264
266
  } else {
265
- const data = await resp.text();
267
+ const data = resp.ok
268
+ ? await resp.text()
269
+ : await readBoundedResponseText(resp, {
270
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
271
+ label: "Copilot Responses error body",
272
+ });
266
273
  if (resp.ok && reqContext.surface === "responses_compact") {
267
274
  const response = parseResponsesCompactionResult(
268
275
  compactionInputWithoutTrigger(reqContext.body.input),
@@ -298,6 +298,7 @@ export function responseHistoryPressureRootId(reqContext) {
298
298
  export function prepareResponsesRequest(reqBody, {
299
299
  assertActive,
300
300
  copilotBoundary = true,
301
+ historySnapshot,
301
302
  mutate = false,
302
303
  } = {}) {
303
304
  assertActive?.();
@@ -313,11 +314,22 @@ export function prepareResponsesRequest(reqBody, {
313
314
  const historyRouteMetadata = [];
314
315
 
315
316
  if (previousId !== undefined && previousId !== null) {
316
- historyItems = materializeResponseHistory(previousId, {
317
- assertActive,
318
- routeMetadata: historyRouteMetadata,
319
- });
320
- historyRootId = responseHistoryRootId(previousId);
317
+ if (historySnapshot) {
318
+ if (historySnapshot.responseId !== previousId) {
319
+ throw new TypeError("Response history snapshot does not match previous_response_id");
320
+ }
321
+ historyItems = historySnapshot.materialize({
322
+ assertActive,
323
+ routeMetadata: historyRouteMetadata,
324
+ });
325
+ historyRootId = historySnapshot.rootId;
326
+ } else {
327
+ historyItems = materializeResponseHistory(previousId, {
328
+ assertActive,
329
+ routeMetadata: historyRouteMetadata,
330
+ });
331
+ historyRootId = responseHistoryRootId(previousId);
332
+ }
321
333
  body.input = [...historyItems, ...currentInputItems];
322
334
  } else {
323
335
  body.input = currentInputItems;
package/src/usage.mjs CHANGED
@@ -150,7 +150,7 @@ export async function flushUsageWrites() {
150
150
  await writeQueue;
151
151
  }
152
152
 
153
- async function* iterateUsageRecords(filePath) {
153
+ async function* iterateUsageRecords(filePath, { warn = console.error } = {}) {
154
154
  let input;
155
155
  try {
156
156
  input = fs.createReadStream(filePath, { encoding: "utf8" });
@@ -165,14 +165,31 @@ async function* iterateUsageRecords(filePath) {
165
165
  }
166
166
 
167
167
  const lines = readline.createInterface({ input, crlfDelay: Infinity });
168
+ let invalidRecords = 0;
168
169
  for await (const line of lines) {
169
- if (line) yield JSON.parse(line);
170
+ if (!line) continue;
171
+ try {
172
+ const record = JSON.parse(line);
173
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
174
+ invalidRecords += 1;
175
+ continue;
176
+ }
177
+ yield record;
178
+ } catch {
179
+ invalidRecords += 1;
180
+ }
181
+ }
182
+ if (invalidRecords > 0) {
183
+ try {
184
+ const safePath = terminalCell(JSON.stringify(filePath));
185
+ warn(`codex-copilot-dx usage log ignored ${invalidRecords} invalid record${invalidRecords === 1 ? "" : "s"} in ${safePath}`);
186
+ } catch {}
170
187
  }
171
188
  }
172
189
 
173
- export async function readUsageRecords(filePath = usageLogPath()) {
190
+ export async function readUsageRecords(filePath = usageLogPath(), { warn = console.error } = {}) {
174
191
  const records = [];
175
- for await (const record of iterateUsageRecords(filePath)) records.push(record);
192
+ for await (const record of iterateUsageRecords(filePath, { warn })) records.push(record);
176
193
  return records;
177
194
  }
178
195
 
@@ -200,10 +217,10 @@ export function summarizeUsage(records) {
200
217
  return summary;
201
218
  }
202
219
 
203
- export async function summarizeUsageLogs(filePath = usageLogPath()) {
220
+ export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error } = {}) {
204
221
  const summary = { requests: 0, totals: {}, byModel: {} };
205
222
  for (const candidate of [rotatedFilePath(filePath), filePath]) {
206
- for await (const record of iterateUsageRecords(candidate)) {
223
+ for await (const record of iterateUsageRecords(candidate, { warn })) {
207
224
  const one = summarizeUsage([record]);
208
225
  summary.requests += one.requests;
209
226
  addUsageTotals(summary.totals, one.totals);
@@ -324,7 +341,8 @@ export async function printUsageSummary({
324
341
  format = "auto",
325
342
  output = process.stdout,
326
343
  log = console.log,
344
+ warn = console.error,
327
345
  } = {}) {
328
- const summary = await summarizeUsageLogs(filePath);
346
+ const summary = await summarizeUsageLogs(filePath, { warn });
329
347
  log(formatUsageSummary(summary, { filePath, format, output }));
330
348
  }