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.
@@ -20,6 +20,7 @@ const HELP_TOPICS = new Set([
20
20
  "status",
21
21
  "models",
22
22
  "usage",
23
+ "cache",
23
24
  "animation",
24
25
  "enable-image",
25
26
  "disable-image",
@@ -155,6 +156,37 @@ export function parseCliArgs(args = []) {
155
156
  const options = parseOptions(rest, new Map([["--format", "value"]]));
156
157
  return baseCommand("usage", outputFormatOption(options["--format"]));
157
158
  }
159
+ if (command === "cache") {
160
+ let cacheArgs = rest;
161
+ if (rest.length === 1 && /^-\d+$/.test(rest[0])) {
162
+ cacheArgs = ["--limit", rest[0].slice(1)];
163
+ } else if (rest[0] === "-clean") {
164
+ cacheArgs = ["--clean", ...rest.slice(1)];
165
+ }
166
+ const options = parseOptions(cacheArgs, new Map([
167
+ ["--limit", "value"],
168
+ ["--clean", "flag"],
169
+ ["--history", "flag"],
170
+ ["--yes", "flag"],
171
+ ]));
172
+ if (options["--limit"] && options["--clean"]) {
173
+ throw new Error("Use --limit or --clean in one cache command, not both");
174
+ }
175
+ if ((options["--history"] || options["--yes"]) && !options["--clean"]) {
176
+ throw new Error("--history and --yes require --clean");
177
+ }
178
+ const rawLimit = options["--limit"];
179
+ if (rawLimit && rawLimit !== "default" && !/^\d+$/.test(rawLimit)) {
180
+ throw new Error(`Cache limit must be an integer MiB value or default: ${rawLimit}`);
181
+ }
182
+ return baseCommand("cache", {
183
+ action: rawLimit ? "limit" : options["--clean"] ? "clean" : "status",
184
+ limitMib: rawLimit && rawLimit !== "default" ? Number(rawLimit) : undefined,
185
+ resetLimit: rawLimit === "default",
186
+ history: Boolean(options["--history"]),
187
+ yes: Boolean(options["--yes"]),
188
+ });
189
+ }
158
190
  if (PM_COMMANDS.has(command)) {
159
191
  return baseCommand("retired", { integration: "PM Studio" });
160
192
  }
@@ -301,8 +333,9 @@ function topicHelp(name, topic) {
301
333
  status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
302
334
  models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
303
335
  usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
336
+ cache: `Usage:\n ${name} cache\n ${name} cache --limit <MiB|default>\n ${name} cache --clean [--history] [--yes]\n\nShows runtime cache use, saves and applies the shared response-history limit, or clears the rebuildable image-transform cache. --history also clears in-memory response history for all tasks and can make their next continuation fail, so interactive confirmation or --yes is required. Generated image files and saved Codex transcripts are never deleted. Short aliases: -128 sets 128 MiB and -clean equals --clean.`,
304
337
  animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
305
- "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image.`,
338
+ "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image. A blank key preserves the saved key only for the same origin; a new origin requires explicit key entry. Concurrent unrelated config edits are preserved.`,
306
339
  "disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
307
340
  "image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and probes local image-tool readiness without generating an image or exposing its API key.`,
308
341
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
@@ -324,6 +357,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
324
357
  ${name} status
325
358
  ${name} models [--format table|plain]
326
359
  ${name} usage [--format table|plain]
360
+ ${name} cache [--limit <MiB|default> | --clean [--history] [--yes]]
327
361
  ${name} animation
328
362
  ${name} enable-image
329
363
  ${name} disable-image
@@ -340,6 +374,7 @@ Commands:
340
374
  status Show runtime routing, performance, queue, and cache health
341
375
  models Query a saved account's live Copilot model catalog
342
376
  usage Summarize locally recorded token usage
377
+ cache Inspect, size, or clear bounded runtime caches
343
378
  animation Select the terminal activity animation
344
379
  enable-image Configure and enable the optional image provider
345
380
  disable-image Disable the image provider and remove its credential
@@ -158,6 +158,17 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
158
158
  if (imageHistory && typeof imageHistory === "object") {
159
159
  lines.push(status("info", `Visual history: ${count(imageHistory.active_recovery_trees)} recovery trees, ${count(imageHistory.adapted_requests)} adapted requests, ${count(imageHistory.historical_images_omitted)} older images omitted, ${count(imageHistory.timeouts_recorded)} timeouts`));
160
160
  }
161
+ if (data.image_generation) {
162
+ const generation = data.image_generation;
163
+ lines.push(status("info", `Image generation: ${count(generation.active)} active, ${count(generation.succeeded)} succeeded, ${count(generation.failed)} failed (${count(generation.busy)} busy), ${count(generation.cancelled)} cancelled, ${count(generation.delivery_failures)} delivery failures`));
164
+ }
165
+ if (data.logging) {
166
+ for (const [name, logging] of Object.entries(data.logging)) {
167
+ if (logging?.enabled === false) continue;
168
+ lines.push(status(logging.dropped_records || logging.write_failures ? "warn" : "info",
169
+ `${name === "usage" ? "Usage" : "Debug"} logging: ${count(logging.pending_records)} pending (${mebibytes(logging.pending_bytes)}), ${count(logging.dropped_records)} dropped, ${count(logging.write_failures)} write failures`));
170
+ }
171
+ }
161
172
  if (responseFailures && typeof responseFailures === "object") {
162
173
  lines.push(status("info", `Response failures: ${count(responseFailures.total)} observed, ${count(responseFailures.retried)} compatibility retries`));
163
174
  for (const failure of Array.isArray(responseFailures.recent) ? responseFailures.recent.slice(-3).reverse() : []) {
@@ -4,6 +4,30 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { CODEX_GPT6_MODEL } from "./models.mjs";
6
6
 
7
+ // Compatibility metadata is derived from OpenAI Codex's public models catalog
8
+ // at commit 0a2eb4696c26ac33204bcd255721ab30220a4774. The installed client's
9
+ // GPT-6 Astra entry supplies its own schema and instructions; these overrides
10
+ // describe only the two new model identities and capability differences.
11
+ const GPT6_COMPATIBILITY_SPECS = new Map([
12
+ ["gpt-6-sol", Object.freeze({
13
+ display_name: "GPT-6-Sol",
14
+ description: "Workhorse model for coding and everyday work.",
15
+ default_reasoning_level: "medium",
16
+ shell_type: "shell_command",
17
+ priority: 2,
18
+ node_repl_auto_review_required: true,
19
+ })],
20
+ ["gpt-6-luna", Object.freeze({
21
+ display_name: "GPT-6-Luna",
22
+ description: "Fast and affordable model for easier tasks.",
23
+ default_reasoning_level: "medium",
24
+ shell_type: "shell_command",
25
+ priority: 3,
26
+ node_repl_auto_review_required: false,
27
+ })],
28
+ ]);
29
+ const MANAGED_GPT6_MODELS = new Set([CODEX_GPT6_MODEL, ...GPT6_COMPATIBILITY_SPECS.keys()]);
30
+
7
31
  export { CODEX_GPT6_MODEL };
8
32
 
9
33
  export const CODEX_APP_BINARY_PATHS = [
@@ -50,29 +74,81 @@ function isEligibleOpenAIModel(model, id) {
50
74
  && supportsResponses(model);
51
75
  }
52
76
 
53
- export function isEligibleCopilotGpt6(copilotModels) {
77
+ export function isEligibleCopilotGpt6(copilotModels, id = CODEX_GPT6_MODEL) {
54
78
  const data = copilotModelData(copilotModels);
55
79
  if (!data) return false;
56
- const matches = data.filter((model) => String(model?.id || "").trim() === CODEX_GPT6_MODEL);
57
- return matches.length === 1 && isEligibleOpenAIModel(matches[0], CODEX_GPT6_MODEL);
80
+ const matches = data.filter((model) => String(model?.id || "").trim() === id);
81
+ return matches.length === 1 && isEligibleOpenAIModel(matches[0], id);
82
+ }
83
+
84
+ function copilotModel(copilotModels, id) {
85
+ return copilotModelData(copilotModels)?.find((model) => String(model?.id || "").trim() === id);
86
+ }
87
+
88
+ function advertisedReasoningEfforts(model) {
89
+ const values = model?.capabilities?.supports?.reasoning_effort;
90
+ return new Set(Array.isArray(values) ? values.map((value) => String(value)) : []);
91
+ }
92
+
93
+ function catalogModelForCopilot(model, copilotModels, { filterReasoning = false } = {}) {
94
+ const upstream = copilotModel(copilotModels, model.slug);
95
+ const reasoning = advertisedReasoningEfforts(upstream);
96
+ const patched = {
97
+ ...structuredClone(model),
98
+ visibility: "list",
99
+ supported_reasoning_levels: filterReasoning && reasoning.size
100
+ ? model.supported_reasoning_levels.filter(({ effort }) => reasoning.has(effort))
101
+ : model.supported_reasoning_levels,
102
+ };
103
+ // The Codex catalog describes ChatGPT service tiers. Copilot must advertise
104
+ // an exact fast model before CCDX exposes the corresponding selector.
105
+ if (!isEligibleCopilotGpt6(copilotModels, `${model.slug}-fast`)) {
106
+ patched.additional_speed_tiers = [];
107
+ patched.service_tiers = [];
108
+ delete patched.default_service_tier;
109
+ }
110
+ return patched;
111
+ }
112
+
113
+ function compatibilityModel(codexCatalog, slug) {
114
+ const astra = codexCatalog.models.find((model) => model.slug === CODEX_GPT6_MODEL);
115
+ const spec = GPT6_COMPATIBILITY_SPECS.get(slug);
116
+ if (!astra || !spec) return null;
117
+ return {
118
+ ...structuredClone(astra),
119
+ ...spec,
120
+ slug,
121
+ supports_parallel_tool_calls: true,
122
+ multi_agent_reasoning_effort: null,
123
+ supports_experimental_context: false,
124
+ minimal_client_version: "0.155.0",
125
+ };
126
+ }
127
+
128
+ function insertByPriority(models, model) {
129
+ const priority = Number(model.priority);
130
+ const index = models.findIndex((candidate) => Number.isFinite(priority)
131
+ && Number.isFinite(Number(candidate.priority))
132
+ && Number(candidate.priority) > priority);
133
+ if (index < 0) models.push(model);
134
+ else models.splice(index, 0, model);
58
135
  }
59
136
 
60
137
  export function buildCodexModelResponse({ copilotModels, codexCatalog } = {}) {
61
138
  if (!copilotModelData(copilotModels) || !codexCatalogIsValid(codexCatalog)) return null;
62
139
 
63
- const exposeGpt6 = isEligibleCopilotGpt6(copilotModels);
140
+ const bundledSlugs = new Set(codexCatalog.models.map(({ slug }) => slug));
64
141
  const models = codexCatalog.models.map((model) => {
65
- if (model.slug !== CODEX_GPT6_MODEL) return model;
66
- if (!exposeGpt6) return { ...model, visibility: "hide" };
67
- const patched = {
68
- ...model,
69
- visibility: "list",
70
- additional_speed_tiers: [],
71
- service_tiers: [],
72
- };
73
- delete patched.default_service_tier;
74
- return patched;
142
+ if (!MANAGED_GPT6_MODELS.has(model.slug)) return model;
143
+ if (!isEligibleCopilotGpt6(copilotModels, model.slug)) return { ...model, visibility: "hide" };
144
+ return catalogModelForCopilot(model, copilotModels);
75
145
  });
146
+ for (const slug of GPT6_COMPATIBILITY_SPECS.keys()) {
147
+ if (bundledSlugs.has(slug) || !isEligibleCopilotGpt6(copilotModels, slug)) continue;
148
+ const model = compatibilityModel(codexCatalog, slug);
149
+ if (!model) continue;
150
+ insertByPriority(models, catalogModelForCopilot(model, copilotModels, { filterReasoning: true }));
151
+ }
76
152
 
77
153
  return { ...copilotModels, object: copilotModels.object || "list", models };
78
154
  }
@@ -0,0 +1,11 @@
1
+ // Diagnostics are not a copy of the upstream payload. Keep useful error text,
2
+ // but remove common credential forms and explicitly labelled content echoes.
3
+ export function redactDiagnosticText(value) {
4
+ return String(value ?? "")
5
+ .replace(/\bBearer\s+[^\s,;"'<>]+/gi, "Bearer [redacted]")
6
+ .replace(/\b(?:github_pat_|gh[pousr]_|sk-|copilot_)[A-Za-z0-9._-]+/gi, "[redacted]")
7
+ .replace(/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie)["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi, "$1[redacted]")
8
+ .replace(/((?:prompt|input|completion|tool_arguments|arguments|content)["']?\s*[:=]\s*)[^\n]+/gi, "$1[redacted]")
9
+ .replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
10
+ .replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
11
+ }
@@ -28,3 +28,37 @@ export function rotateFileIfNeededSync(filePath, incomingBytes, maxBytes) {
28
28
  fs.renameSync(filePath, backupPath);
29
29
  return true;
30
30
  }
31
+
32
+ // Callers serialize writers (usage also holds its cross-process file lock).
33
+ // Group adjacent lines without changing the existing per-record rotation rule.
34
+ export async function appendRotatingLines(filePath, lines, maxBytes) {
35
+ let size = 0;
36
+ try {
37
+ const stat = await fs.promises.stat(filePath);
38
+ if (!stat.isFile()) throw new Error("Log destination is not a regular file");
39
+ size = stat.size;
40
+ }
41
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
42
+ let group = [];
43
+ let bytes = 0;
44
+ const flush = async () => {
45
+ if (!group.length) return;
46
+ await fs.promises.appendFile(filePath, group.join(""), { encoding: "utf8", mode: 0o600 });
47
+ group = [];
48
+ bytes = 0;
49
+ };
50
+ for (const line of lines) {
51
+ const length = Buffer.byteLength(line);
52
+ if (maxBytes > 0 && size > 0 && size + length > maxBytes) {
53
+ await flush();
54
+ await fs.promises.rm(rotatedFilePath(filePath), { force: true });
55
+ await fs.promises.rename(filePath, rotatedFilePath(filePath));
56
+ size = 0;
57
+ }
58
+ group.push(line);
59
+ size += length;
60
+ bytes += length;
61
+ if (bytes >= 256 * 1024) await flush();
62
+ }
63
+ await flush();
64
+ }
@@ -3,6 +3,7 @@ import * as zlib from "node:zlib";
3
3
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
4
4
  import { status } from "./status.mjs";
5
5
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
6
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
6
7
 
7
8
  const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
8
9
  const gunzipAsync = promisify(zlib.gunzip);
@@ -730,21 +731,29 @@ export function sendUpstreamError(res, response, text) {
730
731
  res.end(text || JSON.stringify({ error: "Upstream request failed" }));
731
732
  }
732
733
 
733
- export function writeOrDrain(res, chunk) {
734
+ export function writeOrDrain(res, chunk, { signal } = {}) {
734
735
  if (res.destroyed || res.writableEnded) return Promise.resolve(false);
736
+ if (signal?.aborted) {
737
+ res.destroy?.();
738
+ return Promise.resolve(false);
739
+ }
735
740
  if (res.write(chunk)) return Promise.resolve(true);
736
741
  return new Promise((resolve, reject) => {
737
742
  const cleanup = () => {
738
743
  res.off("drain", onDrain);
739
744
  res.off("error", onError);
740
745
  res.off("close", onClose);
746
+ signal?.removeEventListener("abort", onAbort);
741
747
  };
742
748
  const onDrain = () => { cleanup(); resolve(true); };
743
749
  const onClose = () => { cleanup(); resolve(false); };
744
750
  const onError = (err) => { cleanup(); reject(err); };
751
+ const onAbort = () => { cleanup(); res.destroy?.(); resolve(false); };
745
752
  res.once("drain", onDrain);
746
753
  res.once("close", onClose);
747
754
  res.once("error", onError);
755
+ signal?.addEventListener("abort", onAbort, { once: true });
756
+ if (signal?.aborted) onAbort();
748
757
  });
749
758
  }
750
759
 
@@ -800,7 +809,7 @@ export function createRequestAbort(req, res) {
800
809
 
801
810
  export function logRequestFailure(label, err, abort) {
802
811
  if (!isAbortLikeError(err)) {
803
- console.error(status("err", `${label} request failed: ${err.message}`));
812
+ console.error(status("err", `${label} request failed: ${redactDiagnosticText(err.message)}`));
804
813
  return;
805
814
  }
806
815
 
package/src/image-mcp.mjs CHANGED
@@ -138,7 +138,8 @@ export function createImageMcpHandler({
138
138
  imageDirectory = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "images"),
139
139
  } = {}) {
140
140
  let active = 0;
141
- return async function imageMcpHandler(req, res) {
141
+ const metrics = { total: 0, succeeded: 0, failed: 0, busy: 0, cancelled: 0, delivery_failures: 0 };
142
+ const handler = async function imageMcpHandler(req, res) {
142
143
  if (!isLoopbackAddress(req.socket?.remoteAddress) && !sameHostSocket(req.socket)) {
143
144
  writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
144
145
  return;
@@ -207,18 +208,23 @@ export function createImageMcpHandler({
207
208
  writeJson(res, 200, jsonRpcError(request.id, -32602, "Unknown image tool"));
208
209
  return;
209
210
  }
211
+ metrics.total += 1;
210
212
  const config = configLoader();
211
213
  if (!config) {
214
+ metrics.failed += 1;
212
215
  imageReferences.clear();
213
216
  reply({ content: [{ type: "text", text: "Image generation is disabled. Run ccdx enable-image first." }], isError: true });
214
217
  return;
215
218
  }
216
219
  const editing = request.params.name === EDIT_TOOL_NAME;
217
220
  if (editing && !supportsImageEditing(config)) {
221
+ metrics.failed += 1;
218
222
  reply({ content: [{ type: "text", text: "The configured image provider does not support CCDX editing. No edit was submitted." }], isError: true });
219
223
  return;
220
224
  }
221
225
  if (active >= maxConcurrent) {
226
+ metrics.busy += 1;
227
+ metrics.failed += 1;
222
228
  reply({ content: [{ type: "text", text: "Image generation is busy. Try again after the current request finishes." }], isError: true });
223
229
  return;
224
230
  }
@@ -234,6 +240,7 @@ export function createImageMcpHandler({
234
240
  const parameters = { prompt: args.prompt, size: args.size ?? source?.size ?? "1024x1024" };
235
241
  if (source) parameters.image = source.image;
236
242
  const image = await generateImageFn(config, parameters, { signal: abort.signal });
243
+ abort.signal.throwIfAborted();
237
244
  let imageId = null;
238
245
  if (supportsImageEditing(config)) {
239
246
  try { imageId = imageReferences.remember(config, { ...image, size: parameters.size }); } catch {}
@@ -250,9 +257,12 @@ export function createImageMcpHandler({
250
257
  saved = await saveChatImage(image, imageDirectory, abort.signal);
251
258
  delivery = `\nInclude this image Markdown in your final chat reply to show its thumbnail:\n${saved.markdown}\nTo display this same image again, reuse this Markdown; do not call generate_image or edit_image.`;
252
259
  } catch {
260
+ if (abort.signal.aborted) throw abort.signal.reason;
261
+ metrics.delivery_failures += 1;
253
262
  delivery = "\nThe image was generated, but its chat-preview file could not be saved. Image content is still returned. Do not claim it was displayed, invent a file path, or generate/edit again to retry delivery.";
254
263
  }
255
264
  }
265
+ abort.signal.throwIfAborted();
256
266
  reply({
257
267
  // Codex prioritizes structuredContent over image blocks. Keep IDs in metadata/text.
258
268
  ...(imageId || saved ? { _meta: { ...(imageId ? { "ccdx/image_id": imageId } : {}), ...(saved ? { "ccdx/image_path": saved.filePath } : {}) } } : {}),
@@ -261,7 +271,10 @@ export function createImageMcpHandler({
261
271
  { type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}${delivery}` },
262
272
  ],
263
273
  });
274
+ metrics.succeeded += 1;
264
275
  } catch (error) {
276
+ if (abort.signal.aborted) metrics.cancelled += 1;
277
+ else metrics.failed += 1;
265
278
  if (!res.destroyed && !res.writableEnded) {
266
279
  reply({ content: [{ type: "text", text: safeToolError(error, config.api_key) }], isError: true });
267
280
  }
@@ -271,4 +284,7 @@ export function createImageMcpHandler({
271
284
  res.off?.("close", cancel);
272
285
  }
273
286
  };
287
+ // Status never reads credentials, loads image guidance, or probes a provider.
288
+ handler.stats = () => ({ ...metrics, active, max_concurrent: maxConcurrent });
289
+ return handler;
274
290
  }
@@ -159,6 +159,18 @@ export function imageOptimizationStats() {
159
159
  };
160
160
  }
161
161
 
162
+ export function clearImageOptimizationCache() {
163
+ const stats = imageOptimizationStats();
164
+ if (stats.active > 0 || stats.queued > 0 || stats.cache_inflight > 0) {
165
+ const error = new Error("Image optimization is active; retry cache cleanup after current requests finish");
166
+ error.code = "ccdx_cache_busy";
167
+ throw error;
168
+ }
169
+ const removed = { entries: stats.cache_entries, bytes: stats.cache_bytes };
170
+ imageResultCache.clear();
171
+ return removed;
172
+ }
173
+
162
174
  export function resetImageOptimizationCacheForTests() {
163
175
  imageResultCache.clear();
164
176
  }
@@ -179,11 +179,16 @@ function publicAddress(address) {
179
179
  const family = net.isIP(address);
180
180
  if (family === 4) return ipv4Public(address);
181
181
  if (family !== 6) return false;
182
- const lower = address.toLowerCase();
182
+ let lower;
183
+ try { lower = new URL(`https://[${address}]/`).hostname.slice(1, -1); }
184
+ catch { return false; }
183
185
  if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
184
186
  || /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
185
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
186
- return mapped ? ipv4Public(mapped[1]) : true;
187
+ const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower);
188
+ if (!mapped) return true;
189
+ const high = Number.parseInt(mapped[1], 16);
190
+ const low = Number.parseInt(mapped[2], 16);
191
+ return ipv4Public([high >> 8, high & 255, low >> 8, low & 255].join("."));
187
192
  }
188
193
 
189
194
  export async function downloadPublicImage(urlValue, {
@@ -217,7 +222,8 @@ export async function downloadPublicImage(urlValue, {
217
222
  if (signal?.aborted) { onAbort(); return; }
218
223
  signal?.addEventListener("abort", onAbort, { once: true });
219
224
  // DNS and transfer share one deadline; a late lookup cannot start a cancelled download.
220
- Promise.resolve().then(() => settled ? undefined : lookup(url.hostname, { all: true, verbatim: true })).then((addresses) => {
225
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
226
+ Promise.resolve().then(() => settled ? undefined : lookup(hostname, { all: true, verbatim: true })).then((addresses) => {
221
227
  if (settled) return;
222
228
  if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
223
229
  finish(providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe"));
package/src/log.mjs CHANGED
@@ -3,10 +3,17 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { format } from "node:util";
5
5
  import { status } from "./status.mjs";
6
- import { parseByteLimit, rotateFileIfNeededSync } from "./file-rotation.mjs";
6
+ import { appendRotatingLines, parseByteLimit } from "./file-rotation.mjs";
7
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
7
8
 
8
9
  let globalInstall = null;
9
10
  const DEFAULT_LOG_MAX_BYTES = 16 * 1024 * 1024;
11
+ const LOG_QUEUE_MAX_BYTES = 2 * 1024 * 1024;
12
+ const LOG_QUEUE_MAX_RECORDS = 4096;
13
+
14
+ export function debugLoggingStats() {
15
+ return globalInstall?.stats() || { enabled: false };
16
+ }
10
17
 
11
18
  export function normalizeLogLevel(value) {
12
19
  return String(value || "info").trim().toLowerCase() === "debug" ? "debug" : "info";
@@ -35,7 +42,7 @@ export function debugLogMaxBytes(env = process.env) {
35
42
 
36
43
  function renderLogPayload(args) {
37
44
  const stamp = new Date().toISOString();
38
- const text = format(...args);
45
+ const text = redactDiagnosticText(format(...args));
39
46
  return `${text.split(/\r?\n/).map((line) => `${stamp} ${line}`).join("\n")}\n`;
40
47
  }
41
48
 
@@ -43,12 +50,13 @@ export function configureLogging({
43
50
  env = process.env,
44
51
  consoleObj = console,
45
52
  home = os.homedir(),
53
+ appendLines = appendRotatingLines,
46
54
  } = {}) {
47
55
  const level = normalizeLogLevel(env.CCDX_LOG_LEVEL);
48
56
  const filePath = resolveLogPath(env.CCDX_LOG_PATH, { home });
49
57
  if (!filePath) return { level, filePath: null, cleanup: () => {} };
50
58
  if (consoleObj === console && globalInstall) {
51
- return { level, filePath: globalInstall.filePath, cleanup: globalInstall.cleanup };
59
+ return { level, ...globalInstall };
52
60
  }
53
61
 
54
62
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -59,16 +67,52 @@ export function configureLogging({
59
67
  typeof consoleObj[method] === "function" ? consoleObj[method].bind(consoleObj) : () => {},
60
68
  ]));
61
69
  let writeFailed = false;
70
+ const pending = [];
71
+ let drain = null;
72
+ let pendingBytes = 0;
73
+ let pendingRecords = 0;
74
+ let droppedRecords = 0;
75
+ let writeFailures = 0;
76
+ const stats = () => ({ enabled: true, pending_bytes: pendingBytes, pending_records: pendingRecords,
77
+ dropped_records: droppedRecords, write_failures: writeFailures,
78
+ max_bytes: LOG_QUEUE_MAX_BYTES, max_records: LOG_QUEUE_MAX_RECORDS });
79
+
80
+ function scheduleDrain() {
81
+ if (drain) return;
82
+ drain = Promise.resolve().then(async () => {
83
+ while (pending.length) {
84
+ const batch = pending.splice(0);
85
+ try {
86
+ await appendLines(filePath, batch.map(({ payload }) => payload), debugLogMaxBytes(env));
87
+ } catch (error) {
88
+ writeFailures += 1;
89
+ if (!writeFailed) originals.error(`codex-copilot-dx log write failed: ${redactDiagnosticText(error.message)}`);
90
+ writeFailed = true;
91
+ } finally {
92
+ pendingRecords -= batch.length;
93
+ pendingBytes -= batch.reduce((sum, entry) => sum + entry.bytes, 0);
94
+ }
95
+ }
96
+ }).finally(() => { drain = null; if (pending.length) scheduleDrain(); });
97
+ }
62
98
 
63
99
  function append(args) {
64
100
  try {
65
101
  const payload = renderLogPayload(args);
66
- rotateFileIfNeededSync(filePath, Buffer.byteLength(payload), debugLogMaxBytes(env));
67
- fs.appendFileSync(filePath, payload, { encoding: "utf8", mode: 0o600 });
102
+ const bytes = Buffer.byteLength(payload);
103
+ if (pendingRecords >= LOG_QUEUE_MAX_RECORDS || pendingBytes + bytes > LOG_QUEUE_MAX_BYTES) {
104
+ droppedRecords += 1;
105
+ if (droppedRecords === 1) originals.error("codex-copilot-dx log queue is full; excess file records are dropped, terminal output is unchanged (see ccdx status)");
106
+ return;
107
+ }
108
+ pending.push({ payload, bytes });
109
+ pendingRecords += 1;
110
+ pendingBytes += bytes;
111
+ scheduleDrain();
68
112
  } catch (e) {
69
113
  if (writeFailed) return;
70
114
  writeFailed = true;
71
- originals.error(`codex-copilot-dx log write failed: ${e.message}`);
115
+ originals.error(`codex-copilot-dx log write failed: ${redactDiagnosticText(e.message)}`);
72
116
  }
73
117
  }
74
118
 
@@ -79,13 +123,23 @@ export function configureLogging({
79
123
  };
80
124
  }
81
125
 
126
+ const flush = async ({ timeoutMs = 1500 } = {}) => {
127
+ let timer;
128
+ const completed = await Promise.race([
129
+ (async () => { while (drain) await drain; return true; })(),
130
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
131
+ ]);
132
+ clearTimeout(timer);
133
+ if (!completed) originals.error(`codex-copilot-dx log flush timed out after ${timeoutMs}ms`);
134
+ };
82
135
  const cleanup = () => {
83
136
  for (const method of methods) consoleObj[method] = originals[method];
84
137
  if (consoleObj === console && globalInstall?.cleanup === cleanup) globalInstall = null;
138
+ return flush();
85
139
  };
86
140
 
87
- if (consoleObj === console) globalInstall = { filePath, cleanup };
88
- return { level, filePath, cleanup };
141
+ if (consoleObj === console) globalInstall = { filePath, cleanup, flush, stats };
142
+ return { level, filePath, cleanup, flush, stats };
89
143
  }
90
144
 
91
145
  export function debugLog(message, env = process.env) {
package/src/models.mjs CHANGED
@@ -26,8 +26,8 @@ export function modelIsResponsesOnly(model) {
26
26
 
27
27
  export function resolveCopilotPriorityTierModel(model, serviceTier, models) {
28
28
  const baseModel = String(model || "").trim();
29
- if (baseModel !== "gpt-5.6-sol" || serviceTier !== "priority") return null;
30
- const fastModelId = "gpt-5.6-sol-fast";
29
+ if (!baseModel.startsWith("gpt-") || serviceTier !== "priority") return null;
30
+ const fastModelId = `${baseModel}-fast`;
31
31
  const fastModel = copilotModelData(models)
32
32
  .find((entry) => String(entry?.id || "").trim() === fastModelId);
33
33
  const vendor = String(fastModel?.vendor || fastModel?.owned_by || "").trim().toLowerCase();
@@ -5,6 +5,8 @@ import { imageOptimizationStats } from "./image-optimization.mjs";
5
5
  import { responseHistoryStats } from "./response-history.mjs";
6
6
  import { loadRuntimeConfig } from "./runtime-config.mjs";
7
7
  import { profileRouting } from "./profile-routing.mjs";
8
+ import { usageLoggingStats } from "./usage.mjs";
9
+ import { debugLoggingStats } from "./log.mjs";
8
10
 
9
11
  export { ADAPTER_STATUS_PATH };
10
12
  const OBSERVABILITY_RUNTIME_CONFIG = loadRuntimeConfig();
@@ -13,6 +15,7 @@ const ROUTE_NAMES = Object.freeze([
13
15
  "responses",
14
16
  "responses_compact",
15
17
  "models",
18
+ "image_mcp",
16
19
  "not_found",
17
20
  ]);
18
21
 
@@ -94,6 +97,7 @@ export function classifyAdapterRoute(method, pathname) {
94
97
  if (method === "POST" && pathname === "/v1/responses") return "responses";
95
98
  if (method === "POST" && pathname === "/v1/responses/compact") return "responses_compact";
96
99
  if (method === "GET" && pathname === "/v1/models") return "models";
100
+ if (pathname === "/mcp/image") return "image_mcp";
97
101
  return "not_found";
98
102
  }
99
103
 
@@ -155,6 +159,7 @@ export function runtimeStatusPayload({
155
159
  admission,
156
160
  imagePressure,
157
161
  responseFailures,
162
+ imageGeneration,
158
163
  modelRegistry,
159
164
  codexClient,
160
165
  codexModelRegistry,
@@ -162,6 +167,7 @@ export function runtimeStatusPayload({
162
167
  const memory = process.memoryUsage();
163
168
  const codexRuntime = safeClientRuntimeStatus(codexClient);
164
169
  const codexModels = modelRegistryStatus(codexModelRegistry || modelRegistry);
170
+ const history = responseHistoryStats();
165
171
  return {
166
172
  ...adapterHealthPayload(),
167
173
  uptime_ms: Math.round(process.uptime() * 1000),
@@ -175,8 +181,10 @@ export function runtimeStatusPayload({
175
181
  requests: metrics?.snapshot?.() || createRequestMetrics().snapshot(),
176
182
  stream_performance: streamPerformance?.snapshot?.() || null,
177
183
  admission: admission?.diagnostics?.() || admission?.stats?.() || null,
178
- response_history: responseHistoryStats(),
184
+ response_history: history,
179
185
  image_optimization: imageOptimizationStats(),
186
+ image_generation: imageGeneration || null,
187
+ logging: { usage: usageLoggingStats(), debug: debugLoggingStats() },
180
188
  image_history_pressure: imagePressure?.snapshot?.() || null,
181
189
  response_failures: responseFailures?.snapshot?.() || null,
182
190
  copilot: codexRuntime,
@@ -189,8 +197,8 @@ export function runtimeStatusPayload({
189
197
  max_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxBodyBytes,
190
198
  max_decoded_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxDecodedBodyBytes,
191
199
  max_sse_buffer_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxSseBufferBytes,
192
- response_history_max_bytes: OBSERVABILITY_RUNTIME_CONFIG.responseHistoryMaxBytes,
193
- response_history_max_entries: OBSERVABILITY_RUNTIME_CONFIG.responseHistoryMaxEntries,
200
+ response_history_max_bytes: history.maxBytes,
201
+ response_history_max_entries: history.maxEntries,
194
202
  },
195
203
  };
196
204
  }
@@ -1,17 +1,16 @@
1
1
  import { terminalCell } from "./cli-table.mjs";
2
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
2
3
 
3
4
  const DEFAULT_RECENT_FAILURES = 10;
4
5
 
5
6
  function safeIdentifier(value, fallback = "unknown") {
6
- const text = terminalCell(value, { fallback });
7
+ const text = redactDiagnosticText(terminalCell(value, { fallback }));
7
8
  return text.slice(0, 200);
8
9
  }
9
10
 
10
11
  function safeFailureMessage(value) {
11
12
  let text = terminalCell(value, { fallback: "Response failed without an error message" });
12
- text = text
13
- .replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
14
- .replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
13
+ text = redactDiagnosticText(text);
15
14
  return text.slice(0, 500);
16
15
  }
17
16