codex-copilot-dx 0.6.14 → 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,5 +1,4 @@
1
- import { responses as copilotResponses } from "./copilot.mjs";
2
- import { httpError } from "./http-transport.mjs";
1
+ import { applyCopilotResponsesRequestPolicies } from "./copilot-responses-policy.mjs";
3
2
  import { debugLog } from "./log.mjs";
4
3
  import {
5
4
  materializeResponseHistory,
@@ -8,7 +7,20 @@ import {
8
7
  } from "./response-history.mjs";
9
8
  import { isResponsesToolOutputItem, readResponsesToolOutputParts } from "./responses-content.mjs";
10
9
  import { enforceResponsesImageLimit } from "./responses-image-limit.mjs";
11
- import { status } from "./status.mjs";
10
+ import { routePlanAffinity, sameRoutePlanAffinity } from "./route-plan.mjs";
11
+
12
+ export {
13
+ isEncryptedContentVerificationError,
14
+ isImageNamespaceCollisionError,
15
+ sanitizeImageNamespaceCollisionRequest,
16
+ } from "./copilot-responses-policy.mjs";
17
+
18
+ // Compatibility export for consumers that imported this helper before the
19
+ // Copilot retry boundary moved into its provider-owned module.
20
+ export async function openCopilotResponse(...args) {
21
+ const compatibility = await import("./copilot-responses-compat.mjs");
22
+ return compatibility.openCopilotResponse(...args);
23
+ }
12
24
 
13
25
  function cloneJson(value) {
14
26
  return value === undefined ? undefined : structuredClone(value);
@@ -71,6 +83,8 @@ function isEncryptedContentPart(value) {
71
83
 
72
84
  const OMIT_ENCRYPTED_CONTENT = Symbol("omit-encrypted-content");
73
85
  const ENCRYPTED_HISTORY_REBASE = Symbol("encrypted-history-rebase");
86
+ const RELEASE_HISTORY_INPUT = Symbol("release-history-input");
87
+ const HISTORY_PRESSURE_ROOT = Symbol("history-pressure-root");
74
88
  const ENCRYPTED_TOOL_OUTPUT_MARKER = "[CCDX: encrypted tool output omitted because upstream could not decrypt it.]";
75
89
 
76
90
  function stripEncryptedReasoningValue(value, state) {
@@ -159,7 +173,21 @@ function isEncryptedReasoningInputItem(item) {
159
173
  return keys.length === 1 && keys[0] === "encrypted_content";
160
174
  }
161
175
 
162
- export function sanitizeEncryptedReasoningRequest(reqContext) {
176
+ function responseValueHasOpaqueState(value) {
177
+ if (Array.isArray(value)) return value.some(responseValueHasOpaqueState);
178
+ if (!value || typeof value !== "object") return false;
179
+ if (isEncryptedReasoningInputItem(value)
180
+ || Object.prototype.hasOwnProperty.call(value, "encrypted_content")) {
181
+ return true;
182
+ }
183
+ if (isResponsesToolOutputItem(value) && typeof value.output === "string") {
184
+ const parsed = readResponsesToolOutputParts(value);
185
+ if (parsed?.parts.some(responseValueHasOpaqueState)) return true;
186
+ }
187
+ return Object.values(value).some(responseValueHasOpaqueState);
188
+ }
189
+
190
+ export function sanitizeEncryptedReasoningRequest(reqContext, { historicalOnly = false } = {}) {
163
191
  const state = { changed: false };
164
192
  let body = cloneJson(reqContext.body);
165
193
  let currentInputStart = reqContext.currentInputStart;
@@ -170,6 +198,10 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
170
198
  for (let index = 0; index < body.input.length; index += 1) {
171
199
  const item = body.input[index];
172
200
  const historical = Number.isFinite(currentInputStart) && index < currentInputStart;
201
+ if (historicalOnly && !historical) {
202
+ input.push(item);
203
+ continue;
204
+ }
173
205
  if (isEncryptedReasoningInputItem(item)) {
174
206
  state.changed = true;
175
207
  if (historical) historicalInputChanged = true;
@@ -188,11 +220,13 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
188
220
  }
189
221
  body.input = input;
190
222
  if (Number.isFinite(currentInputStart)) currentInputStart = retainedBeforeCurrent;
191
- } else {
223
+ } else if (!historicalOnly) {
192
224
  body = stripEncryptedReasoningValue(body, state);
193
225
  }
194
226
  if (!state.changed) return null;
195
- const historyInputItems = Array.isArray(reqContext.historyInputItems)
227
+ const historyInputItems = historicalOnly
228
+ ? reqContext.historyInputItems
229
+ : Array.isArray(reqContext.historyInputItems)
196
230
  ? reqContext.historyInputItems.flatMap((item) => {
197
231
  if (isEncryptedReasoningInputItem(item)) return [];
198
232
  const stripped = stripEncryptedReasoningInputValue(item, { changed: false });
@@ -210,10 +244,13 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
210
244
  currentInputStart,
211
245
  inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
212
246
  historyInputItems,
247
+ [RELEASE_HISTORY_INPUT]: historicalOnly
248
+ ? reqContext[RELEASE_HISTORY_INPUT]
249
+ : historyInputItems,
213
250
  };
214
251
  }
215
252
 
216
- function finalizeEncryptedHistoryRebase(reqContext) {
253
+ export function finalizeEncryptedHistoryRebase(reqContext) {
217
254
  if (!reqContext[ENCRYPTED_HISTORY_REBASE]) return reqContext;
218
255
  const finalized = {
219
256
  ...reqContext,
@@ -226,98 +263,44 @@ function finalizeEncryptedHistoryRebase(reqContext) {
226
263
  return finalized;
227
264
  }
228
265
 
229
- export function isEncryptedContentVerificationError(statusCode, text) {
230
- if (statusCode < 400 || !text) return false;
231
- const lower = String(text).toLowerCase();
232
- const reasoningFailure = lower.includes("encrypted content")
233
- && lower.includes("could not be verified")
234
- && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
235
- const functionOutputFailure = lower.includes("encrypted function output content")
236
- && lower.includes("could not be decrypted or decoded");
237
- const missingEncryptedContent = statusCode < 500
238
- && /missing required parameter:\s*(['"]?)input\[\d+\](?:\.[a-z0-9_]+|\[\d+\])*\.encrypted_content\1(?=\.?(?:\s|$|["},\]]))/.test(lower);
239
- return reasoningFailure || functionOutputFailure || missingEncryptedContent;
240
- }
241
-
242
- export function isImageNamespaceCollisionError(statusCode, text) {
243
- if (statusCode < 400 || !text) return false;
244
- const lower = String(text).toLowerCase();
245
- return lower.includes("namespace")
246
- && lower.includes("image_gen")
247
- && lower.includes("collid");
248
- }
249
-
250
- function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
251
- if (!tool || typeof tool !== "object") return false;
252
- const type = String(tool.type || "").toLowerCase();
253
- const name = String(tool.name || tool.function?.name || "").toLowerCase();
254
- const namespace = String(tool.namespace || "").toLowerCase();
255
- if (["image_gen", "image_generation"].includes(type)) return true;
256
- if (["image_gen", "image_generation"].includes(name)) return true;
257
- if (namespace === "image_gen" || namespace === "image_generation") return true;
258
- return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
259
- }
260
-
261
- export function sanitizeImageNamespaceCollisionRequest(reqContext) {
262
- if (!Array.isArray(reqContext?.body?.tools)) return null;
263
- const body = cloneJson(reqContext.body);
264
- const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
265
- if (filtered.length === body.tools.length) return null;
266
- if (filtered.length) body.tools = filtered;
267
- else delete body.tools;
268
- return { ...reqContext, body };
269
- }
270
-
271
- export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
272
- let encryptedRetried = false;
273
- let imageNamespaceRetried = false;
274
- let payloadPrepared = false;
275
- for (let attempt = 0; attempt < 3; attempt += 1) {
276
- options.assertPrepareActive?.();
277
- const resp = await upstream(reqContext.body, {
278
- assertActive: options.assertPrepareActive,
279
- signal: options.signal,
280
- currentInputStart: reqContext.currentInputStart,
281
- onUpstreamStart: options.onUpstreamStart,
282
- payloadPrepared,
283
- });
284
- options.assertPrepareActive?.();
285
- payloadPrepared = true;
286
- if (resp.ok) {
287
- reqContext = finalizeEncryptedHistoryRebase(reqContext);
288
- return { resp, reqContext };
289
- }
290
-
291
- const errorText = await resp.text();
292
- options.assertPrepareActive?.();
293
- if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
294
- const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
295
- if (retryContext) {
296
- imageNamespaceRetried = true;
297
- reqContext = retryContext;
298
- console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
299
- continue;
300
- }
301
- }
302
- if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
303
- const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
304
- if (retryContext) {
305
- encryptedRetried = true;
306
- reqContext = retryContext;
307
- console.warn(status("warn", "encrypted replay content rejected by upstream; retrying without unavailable encrypted content"));
308
- continue;
309
- }
310
- }
311
- return { resp, reqContext, errorText };
266
+ export function applyResponseHistoryRoutePlan(reqContext, routePlan) {
267
+ const next = reqContext;
268
+ next.routePlan = routePlan;
269
+ if (!next.historyParentId) return next;
270
+ const currentAffinity = routePlanAffinity(routePlan);
271
+ const historyMetadata = next.historyRouteMetadata;
272
+ if (!currentAffinity
273
+ || !Array.isArray(historyMetadata)
274
+ || historyMetadata.length === 0
275
+ || historyMetadata.some((metadata) => metadata.affinity === null)) {
276
+ return next;
312
277
  }
313
- throw httpError("Responses compatibility retry limit exceeded", 502);
278
+ const hasMismatchedOpaqueState = historyMetadata.some((metadata) => (
279
+ metadata.hasOpaque && !sameRoutePlanAffinity(metadata.affinity, currentAffinity)
280
+ ));
281
+ if (!hasMismatchedOpaqueState) {
282
+ return next;
283
+ }
284
+
285
+ const sanitized = sanitizeEncryptedReasoningRequest(next, { historicalOnly: true });
286
+ if (!sanitized) return next;
287
+ sanitized.routePlan = routePlan;
288
+ const historyRootId = next.historyRootId;
289
+ const rebased = finalizeEncryptedHistoryRebase(sanitized);
290
+ if (historyRootId) rebased[HISTORY_PRESSURE_ROOT] = historyRootId;
291
+ return rebased;
314
292
  }
315
293
 
316
- function isBuiltinImageTool(tool) {
317
- return isImageNamespaceTool(tool);
294
+ export function responseHistoryPressureRootId(reqContext) {
295
+ return reqContext?.[HISTORY_PRESSURE_ROOT] || reqContext?.historyRootId || null;
318
296
  }
319
297
 
320
- export function prepareResponsesRequest(reqBody, { assertActive, mutate = false } = {}) {
298
+ export function prepareResponsesRequest(reqBody, {
299
+ assertActive,
300
+ copilotBoundary = true,
301
+ historySnapshot,
302
+ mutate = false,
303
+ } = {}) {
321
304
  assertActive?.();
322
305
  const body = mutate ? reqBody : cloneJson(reqBody);
323
306
  assertActive?.();
@@ -328,10 +311,25 @@ export function prepareResponsesRequest(reqBody, { assertActive, mutate = false
328
311
  const previousId = body.previous_response_id;
329
312
  let historyItems = [];
330
313
  let historyRootId = null;
314
+ const historyRouteMetadata = [];
331
315
 
332
316
  if (previousId !== undefined && previousId !== null) {
333
- historyItems = materializeResponseHistory(previousId, { assertActive });
334
- 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
+ }
335
333
  body.input = [...historyItems, ...currentInputItems];
336
334
  } else {
337
335
  body.input = currentInputItems;
@@ -351,10 +349,10 @@ export function prepareResponsesRequest(reqBody, { assertActive, mutate = false
351
349
 
352
350
  delete body.previous_response_id;
353
351
  delete body.store;
354
- if (Array.isArray(body.tools)) {
355
- body.tools = body.tools.filter((tool) => !isBuiltinImageTool(tool));
356
- if (!body.tools.length) delete body.tools;
357
- }
352
+ // Preserve the historical helper contract for embedders. Runtime handlers
353
+ // pass copilotBoundary:false and apply the policy only after selecting a
354
+ // concrete Copilot RoutePlan.
355
+ if (copilotBoundary) applyCopilotResponsesRequestPolicies(body);
358
356
  stripInternalResponsesInputFields(body.input, { assertActive });
359
357
  stripInternalResponsesInputFields(historyInputItems, { assertActive });
360
358
  assertActive?.();
@@ -366,15 +364,21 @@ export function prepareResponsesRequest(reqBody, { assertActive, mutate = false
366
364
  currentInputStart: retainedCurrentInputStart < 0 ? body.input.length : retainedCurrentInputStart,
367
365
  historyParentId: previousId ?? null,
368
366
  historyRootId,
367
+ historyRouteMetadata,
369
368
  historyInputItems,
369
+ ...(previousId !== undefined && previousId !== null
370
+ ? { [RELEASE_HISTORY_INPUT]: historyInputItems }
371
+ : {}),
370
372
  takeHistoryOwnership: mutate,
371
373
  };
372
374
  }
373
375
 
374
376
  export function dropMaterializedResponseHistory(reqContext) {
375
- if (!reqContext?.historyParentId || !Array.isArray(reqContext.historyInputItems)) return false;
376
- reqContext.body = { ...reqContext.body, input: reqContext.historyInputItems };
377
- reqContext.inputItems = reqContext.historyInputItems;
377
+ const releaseInput = reqContext?.[RELEASE_HISTORY_INPUT];
378
+ if (!Array.isArray(releaseInput)) return false;
379
+ reqContext.body = { ...reqContext.body, input: releaseInput };
380
+ reqContext.inputItems = releaseInput;
381
+ delete reqContext[RELEASE_HISTORY_INPUT];
378
382
  reqContext.currentInputStart = 0;
379
383
  return true;
380
384
  }
@@ -391,6 +395,8 @@ export function rememberResponseHistory(reqContext, responseJson) {
391
395
  parentId: compacted ? null : reqContext.historyParentId,
392
396
  inputItems: sourceInputItems,
393
397
  outputItems: sourceOutputItems,
398
+ hasOpaque: responseValueHasOpaqueState([sourceInputItems, sourceOutputItems]),
399
+ routeAffinity: routePlanAffinity(reqContext.routePlan),
394
400
  takeOwnership: reqContext.takeHistoryOwnership,
395
401
  });
396
402
  }
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const DISPOSITIONS = new Set(["native", "relay", "reject"]);
4
+ const MAX_AFFINITY_MODEL_BYTES = 512;
5
+
6
+ function requiredString(value, name) {
7
+ const normalized = String(value || "").trim();
8
+ if (!normalized) throw new Error(`Route plan ${name} is required`);
9
+ return normalized;
10
+ }
11
+
12
+ export function createRoutePlan({
13
+ disposition = "relay",
14
+ model,
15
+ origin,
16
+ profile,
17
+ protocol,
18
+ surface,
19
+ } = {}) {
20
+ if (!DISPOSITIONS.has(disposition)) {
21
+ throw new Error(`Unsupported route disposition: ${disposition}`);
22
+ }
23
+ return Object.freeze({
24
+ disposition,
25
+ origin: requiredString(origin, "origin"),
26
+ profile: requiredString(profile, "profile"),
27
+ protocol: requiredString(protocol, "protocol"),
28
+ model: requiredString(model, "model"),
29
+ surface: requiredString(surface, "surface"),
30
+ });
31
+ }
32
+
33
+ export function routePlanAffinity(plan) {
34
+ if (!plan || typeof plan !== "object") return null;
35
+ const { origin, profile, protocol, model } = plan;
36
+ if (![origin, profile, protocol, model].every((value) => typeof value === "string" && value.length > 0)) {
37
+ return null;
38
+ }
39
+ const boundedModel = Buffer.byteLength(model) <= MAX_AFFINITY_MODEL_BYTES
40
+ ? model
41
+ : `sha256:${createHash("sha256").update(model).digest("hex")}`;
42
+ return Object.freeze({ origin, profile, protocol, model: boundedModel });
43
+ }
44
+
45
+ export function sameRoutePlanAffinity(left, right) {
46
+ return Boolean(left && right)
47
+ && left.origin === right.origin
48
+ && left.profile === right.profile
49
+ && left.protocol === right.protocol
50
+ && left.model === right.model;
51
+ }
package/src/startup.mjs CHANGED
@@ -2,9 +2,15 @@ export async function initializeModelRegistry({
2
2
  loadCached,
3
3
  currentModelDefs,
4
4
  refresh,
5
+ onBackgroundError = () => {},
5
6
  }) {
6
- if (loadCached()) {
7
- return { modelDefs: currentModelDefs(), source: "cache", backgroundRefresh: null };
7
+ const cached = loadCached();
8
+ const loaded = cached === true || cached?.loaded === true;
9
+ if (loaded) {
10
+ const backgroundRefresh = cached?.stale === true
11
+ ? runInBackground(refresh, onBackgroundError)
12
+ : null;
13
+ return { modelDefs: currentModelDefs(), source: "cache", backgroundRefresh };
8
14
  }
9
15
 
10
16
  return { modelDefs: await refresh(), source: "live", backgroundRefresh: null };
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
  }