codex-copilot-dx 0.8.3 → 0.8.5

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.
@@ -203,24 +203,51 @@ function makeRoomFor({ additionalBytes, additionalEntries, protectedRoots }) {
203
203
  return true;
204
204
  }
205
205
 
206
- export function clearResponseHistoryForTests() {
206
+ export function clearResponseHistory() {
207
+ if (pinnedTrees.size > 0) {
208
+ const error = new Error("Response history is active; retry cleanup after current requests finish");
209
+ error.code = "ccdx_cache_busy";
210
+ throw error;
211
+ }
212
+ const removed = { entries: histories.size, bytes: totalBytes };
207
213
  histories.clear();
208
214
  childrenById.clear();
209
215
  treeLru.clear();
210
216
  pinnedTrees.clear();
211
217
  evictedIds.clear();
212
218
  totalBytes = 0;
219
+ return removed;
220
+ }
221
+
222
+ export function clearResponseHistoryForTests() {
223
+ clearResponseHistory();
213
224
  maxBytes = DEFAULT_MAX_BYTES;
214
225
  maxEntries = DEFAULT_MAX_ENTRIES;
215
226
  }
216
227
 
228
+ export function setResponseHistoryMaxBytes(nextMaxBytes) {
229
+ const parsed = Number(nextMaxBytes);
230
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
231
+ throw new TypeError("Response history cache limit must be a positive safe integer");
232
+ }
233
+ if (parsed < totalBytes) {
234
+ const error = new Error(`Response history currently uses ${totalBytes} bytes, above the requested ${parsed}-byte limit`);
235
+ error.code = "ccdx_cache_limit_below_usage";
236
+ throw error;
237
+ }
238
+ const previousMaxBytes = maxBytes;
239
+ maxBytes = parsed;
240
+ return { previousMaxBytes, maxBytes };
241
+ }
242
+
217
243
  export function configureResponseHistoryForTests({ maxBytes: nextMaxBytes, maxEntries: nextMaxEntries } = {}) {
218
244
  if (nextMaxBytes !== undefined) maxBytes = parsePositiveInteger(nextMaxBytes, DEFAULT_MAX_BYTES);
219
245
  if (nextMaxEntries !== undefined) maxEntries = parsePositiveInteger(nextMaxEntries, DEFAULT_MAX_ENTRIES);
220
246
  }
221
247
 
222
248
  export function responseHistoryStats() {
223
- return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size };
249
+ return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size,
250
+ pinnedTrees: pinnedTrees.size, maxBytes, maxEntries };
224
251
  }
225
252
 
226
253
  export function responseHistoryRootId(responseId) {
@@ -89,9 +89,10 @@ function resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver) {
89
89
  return resolveOpenAIModel(model, openAIModelEnv, options);
90
90
  }
91
91
 
92
- function stripUnsupportedGpt6ServiceTier(body, requestedModel, upstreamModel) {
93
- if (requestedModel === CODEX_GPT6_MODEL
92
+ function stripUnsupportedGpt6ServiceTier(body, requestedModel, upstreamModel, priorityTierModel) {
93
+ if (requestedModel.startsWith("gpt-6-")
94
94
  && upstreamModel === requestedModel
95
+ && !priorityTierModel
95
96
  && body.service_tier === "priority") {
96
97
  delete body.service_tier;
97
98
  }
@@ -263,7 +264,7 @@ export function createResponsesHandler(options) {
263
264
  upstreamModel = priorityTierModel;
264
265
  delete prepared.body.service_tier;
265
266
  }
266
- stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel);
267
+ stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel, priorityTierModel);
267
268
  if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
268
269
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
269
270
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
@@ -319,7 +320,7 @@ export function createResponsesHandler(options) {
319
320
  "Cache-Control": "no-cache",
320
321
  Connection: "keep-alive",
321
322
  });
322
- const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
323
+ const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, { signal: abort.signal });
323
324
  if (!written) return false;
324
325
  if (event === "response.completed") {
325
326
  rememberResponseHistory(prepared, data.response);
@@ -516,7 +517,7 @@ export function createResponsesCompactHandler(options) {
516
517
  upstreamModel = priorityTierModel;
517
518
  delete prepared.body.service_tier;
518
519
  }
519
- stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel);
520
+ stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel, priorityTierModel);
520
521
  if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
521
522
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
522
523
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
@@ -403,13 +403,13 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
403
403
  const body = prelude.length === 1 ? prelude[0] : Buffer.concat(prelude, preludeBytes);
404
404
  prelude.length = 0;
405
405
  preludeBytes = 0;
406
- return writeOrDrain(res, body);
406
+ return writeOrDrain(res, body, { signal: options.signal });
407
407
  };
408
408
  const forwardFrame = async (frame) => {
409
409
  if (!frame) return true;
410
410
  if (!holdPrelude) {
411
411
  writeHeaders();
412
- return writeOrDrain(res, frame);
412
+ return writeOrDrain(res, frame, { signal: options.signal });
413
413
  }
414
414
  prelude.push(frame);
415
415
  preludeBytes += frame.byteLength;
@@ -3,9 +3,9 @@ import { localPackageVersion } from "./version.mjs";
3
3
 
4
4
  export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
5
5
  export const ADAPTER_STATUS_PATH = "/_ccdx/status";
6
- export const ADAPTER_PROTOCOL_VERSION = 3;
6
+ export const ADAPTER_PROTOCOL_VERSION = 4;
7
7
  export const ADAPTER_VERSION = localPackageVersion();
8
- export const ADAPTER_CAPABILITIES = Object.freeze([]);
8
+ export const ADAPTER_CAPABILITIES = Object.freeze(["cache-control-v1"]);
9
9
  const ADAPTER_INSTANCE_ID = randomUUID();
10
10
 
11
11
  export function adapterHealthPayload() {
@@ -11,6 +11,10 @@ export async function endStreamWithError(res, error, abort) {
11
11
  markStreamFailure();
12
12
  if (res.destroyed || res.writableEnded) return;
13
13
  const data = streamErrorData(error, abort);
14
- await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
14
+ // Allow a final error frame after an upstream timeout, but never wait forever
15
+ // for a client that has stopped reading (including protocol-error paths).
16
+ await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`, {
17
+ signal: AbortSignal.timeout(1000),
18
+ }).catch(() => false);
15
19
  if (!res.destroyed && !res.writableEnded) res.end();
16
20
  }
package/src/usage.mjs CHANGED
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell } from "./cli-table.mjs";
6
- import { parseByteLimit, rotateFileIfNeededSync, rotatedFilePath } from "./file-rotation.mjs";
6
+ import { appendRotatingLines, parseByteLimit, rotatedFilePath } from "./file-rotation.mjs";
7
7
  import { withFileLock } from "./lock.mjs";
8
8
 
9
9
  const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
@@ -15,6 +15,17 @@ const USAGE_FLUSH_TIMEOUT_MS = 1_500;
15
15
 
16
16
  const pendingWrites = [];
17
17
  let writeDrain = null;
18
+ const USAGE_QUEUE_MAX_RECORDS = 4096;
19
+ const USAGE_QUEUE_MAX_BYTES = 4 * 1024 * 1024;
20
+ let pendingRecords = 0;
21
+ let pendingBytes = 0;
22
+ let droppedRecords = 0;
23
+ let writeFailures = 0;
24
+
25
+ export function usageLoggingStats() {
26
+ return { pending_records: pendingRecords, pending_bytes: pendingBytes, dropped_records: droppedRecords,
27
+ write_failures: writeFailures, max_records: USAGE_QUEUE_MAX_RECORDS, max_bytes: USAGE_QUEUE_MAX_BYTES };
28
+ }
18
29
 
19
30
  export function usageLogPath() {
20
31
  return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
@@ -114,10 +125,7 @@ async function writeUsageBatch(batch) {
114
125
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
115
126
  await withFileLock(`${path.resolve(filePath)}.lock`, async () => {
116
127
  batch.push(...takePendingWrites(filePath, maxBytes));
117
- for (const { line } of batch) {
118
- rotateFileIfNeededSync(filePath, Buffer.byteLength(line), maxBytes);
119
- await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
120
- }
128
+ await appendRotatingLines(filePath, batch.map(({ line }) => line), maxBytes);
121
129
  }, {
122
130
  timeoutMs: USAGE_WRITE_LOCK_TIMEOUT_MS,
123
131
  staleMs: USAGE_WRITE_LOCK_STALE_MS,
@@ -134,10 +142,15 @@ function scheduleUsageWriteDrain() {
134
142
  try {
135
143
  await writeUsageBatch(batch);
136
144
  } catch (error) {
145
+ writeFailures += 1;
137
146
  batch.push(...takePendingWrites(first.filePath, first.maxBytes));
138
147
  console.error(`codex-copilot-dx usage log write failed: ${error.message}`);
139
148
  } finally {
140
- for (const entry of batch) entry.resolve();
149
+ for (const entry of batch) {
150
+ pendingRecords -= 1;
151
+ pendingBytes -= entry.bytes;
152
+ entry.resolve();
153
+ }
141
154
  }
142
155
  }
143
156
  }).finally(() => {
@@ -150,8 +163,16 @@ export function recordUsage(record) {
150
163
  if (!record || process.env.CCDX_DISABLE_USAGE === "1") return Promise.resolve();
151
164
  const filePath = usageLogPath();
152
165
  const line = `${JSON.stringify(record)}\n`;
166
+ const bytes = Buffer.byteLength(line);
167
+ if (pendingRecords >= USAGE_QUEUE_MAX_RECORDS || pendingBytes + bytes > USAGE_QUEUE_MAX_BYTES) {
168
+ droppedRecords += 1;
169
+ if (droppedRecords === 1) console.error("codex-copilot-dx usage queue is full; excess usage records are dropped, inference is unaffected (see ccdx status)");
170
+ return Promise.resolve();
171
+ }
172
+ pendingRecords += 1;
173
+ pendingBytes += bytes;
153
174
  const completed = new Promise((resolve) => {
154
- pendingWrites.push({ filePath, line, maxBytes: usageLogMaxBytes(), resolve });
175
+ pendingWrites.push({ filePath, line, bytes, maxBytes: usageLogMaxBytes(), resolve });
155
176
  });
156
177
  scheduleUsageWriteDrain();
157
178
  return completed;
@@ -3,6 +3,13 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
5
  import { DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
6
+ import { loadRuntimeConfig } from "./runtime-config.mjs";
7
+ import {
8
+ MIB,
9
+ RESPONSE_HISTORY_DEFAULT_MIB,
10
+ RESPONSE_HISTORY_MAX_MIB,
11
+ RESPONSE_HISTORY_MIN_MIB,
12
+ } from "./cache-limits.mjs";
6
13
  import {
7
14
  DEFAULT_TERMINAL_ANIMATION_THEME,
8
15
  isTerminalAnimationTheme,
@@ -11,6 +18,8 @@ import {
11
18
 
12
19
  const AUTO_REVIEW_MODEL_KEY = "auto_review_model";
13
20
  const TERMINAL_ANIMATION_KEY = "terminal_animation";
21
+ const RESPONSE_HISTORY_MAX_MIB_KEY = "response_history_max_mib";
22
+ export { RESPONSE_HISTORY_MAX_MIB, RESPONSE_HISTORY_MIN_MIB };
14
23
 
15
24
  export function userSettingsPath({ env = process.env, home = os.homedir() } = {}) {
16
25
  const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
@@ -46,6 +55,17 @@ export function readUserSettings({ env = process.env, home = os.homedir(), stric
46
55
  delete parsed[TERMINAL_ANIMATION_KEY];
47
56
  }
48
57
  }
58
+ if (Object.hasOwn(parsed, RESPONSE_HISTORY_MAX_MIB_KEY)) {
59
+ const value = parsed[RESPONSE_HISTORY_MAX_MIB_KEY];
60
+ if (!Number.isSafeInteger(value)
61
+ || value < RESPONSE_HISTORY_MIN_MIB
62
+ || value > RESPONSE_HISTORY_MAX_MIB) {
63
+ if (strict) {
64
+ throw invalidSettings(filePath, `${RESPONSE_HISTORY_MAX_MIB_KEY} must be an integer from ${RESPONSE_HISTORY_MIN_MIB} to ${RESPONSE_HISTORY_MAX_MIB}`);
65
+ }
66
+ delete parsed[RESPONSE_HISTORY_MAX_MIB_KEY];
67
+ }
68
+ }
49
69
  return parsed;
50
70
  } catch (error) {
51
71
  if (error?.code === "ENOENT") return {};
@@ -83,6 +103,39 @@ export function terminalAnimationPreference({ env = process.env, home = os.homed
83
103
  return { theme: DEFAULT_TERMINAL_ANIMATION_THEME, source: "default" };
84
104
  }
85
105
 
106
+ export function responseHistoryLimitPreference({ env = process.env, home = os.homedir() } = {}) {
107
+ const environment = String(env.CCDX_RESPONSE_HISTORY_MAX_BYTES || "").trim();
108
+ if (environment) {
109
+ return { bytes: loadRuntimeConfig(env).responseHistoryMaxBytes, source: "environment" };
110
+ }
111
+ const settings = readUserSettings({ env, home });
112
+ const mib = settings[RESPONSE_HISTORY_MAX_MIB_KEY];
113
+ if (Number.isSafeInteger(mib)) return { bytes: mib * MIB, mib, source: "settings" };
114
+ return { bytes: RESPONSE_HISTORY_DEFAULT_MIB * MIB, mib: RESPONSE_HISTORY_DEFAULT_MIB, source: "default" };
115
+ }
116
+
117
+ export function writeResponseHistoryLimitMib(mib, { env = process.env, home = os.homedir() } = {}) {
118
+ const filePath = userSettingsPath({ env, home });
119
+ const settings = readUserSettings({ env, home, strict: true });
120
+ const next = { ...settings };
121
+ if (mib === null || mib === undefined || mib === "") {
122
+ if (!Object.hasOwn(next, RESPONSE_HISTORY_MAX_MIB_KEY)) {
123
+ return { changed: false, filePath, ...responseHistoryLimitPreference({ env, home }) };
124
+ }
125
+ delete next[RESPONSE_HISTORY_MAX_MIB_KEY];
126
+ } else {
127
+ const parsed = Number(mib);
128
+ if (!Number.isSafeInteger(parsed)
129
+ || parsed < RESPONSE_HISTORY_MIN_MIB
130
+ || parsed > RESPONSE_HISTORY_MAX_MIB) {
131
+ throw new Error(`History cache limit must be an integer from ${RESPONSE_HISTORY_MIN_MIB} to ${RESPONSE_HISTORY_MAX_MIB} MiB`);
132
+ }
133
+ next[RESPONSE_HISTORY_MAX_MIB_KEY] = parsed;
134
+ }
135
+ const changed = atomicWriteFileIfChangedSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
136
+ return { changed, filePath, ...responseHistoryLimitPreference({ env, home }) };
137
+ }
138
+
86
139
  export function writeAutoReviewModel(model, { env = process.env, home = os.homedir() } = {}) {
87
140
  const filePath = userSettingsPath({ env, home });
88
141
  const settings = readUserSettings({ env, home, strict: true });