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.
- package/README.md +35 -7
- package/bin/cli.mjs +30 -4
- package/package.json +1 -1
- package/src/adapter.mjs +68 -3
- package/src/cache-control.mjs +59 -0
- package/src/cache-limits.mjs +4 -0
- package/src/cli-cache.mjs +147 -0
- package/src/cli-image.mjs +20 -8
- package/src/cli-options.mjs +36 -1
- package/src/cli-status.mjs +11 -0
- package/src/codex-model-catalog.mjs +90 -14
- package/src/diagnostic-text.mjs +11 -0
- package/src/file-rotation.mjs +34 -0
- package/src/http-transport.mjs +11 -2
- package/src/image-mcp.mjs +17 -1
- package/src/image-optimization.mjs +12 -0
- package/src/image-provider.mjs +10 -4
- package/src/log.mjs +62 -8
- package/src/models.mjs +2 -2
- package/src/observability.mjs +11 -3
- package/src/response-failures.mjs +3 -4
- package/src/response-history.mjs +29 -2
- package/src/responses-handler.mjs +6 -5
- package/src/responses-proxy.mjs +2 -2
- package/src/running-adapter.mjs +2 -2
- package/src/stream-errors.mjs +5 -1
- package/src/usage.mjs +28 -7
- package/src/user-settings.mjs +53 -0
package/src/cli-options.mjs
CHANGED
|
@@ -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
|
package/src/cli-status.mjs
CHANGED
|
@@ -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() ===
|
|
57
|
-
return matches.length === 1 && isEligibleOpenAIModel(matches[0],
|
|
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
|
|
140
|
+
const bundledSlugs = new Set(codexCatalog.models.map(({ slug }) => slug));
|
|
64
141
|
const models = codexCatalog.models.map((model) => {
|
|
65
|
-
if (model.slug
|
|
66
|
-
if (!
|
|
67
|
-
|
|
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
|
+
}
|
package/src/file-rotation.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/http-transport.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/image-provider.mjs
CHANGED
|
@@ -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
|
-
|
|
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:(
|
|
186
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
30
|
-
const fastModelId =
|
|
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();
|
package/src/observability.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
193
|
-
response_history_max_entries:
|
|
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
|
|