codex-copilot-dx 0.8.2 → 0.8.4
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 +17 -3
- package/bin/cli.mjs +9 -5
- package/package.json +1 -1
- package/src/adapter.mjs +7 -1
- package/src/cli-image.mjs +20 -8
- package/src/cli-options.mjs +1 -1
- package/src/cli-status.mjs +11 -0
- package/src/config.mjs +131 -48
- 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-provider.mjs +10 -4
- package/src/log.mjs +62 -8
- package/src/observability.mjs +7 -0
- package/src/response-failures.mjs +3 -4
- package/src/responses-handler.mjs +1 -1
- package/src/responses-proxy.mjs +2 -2
- package/src/stream-errors.mjs +5 -1
- package/src/usage.mjs +28 -7
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ Codex App requests a versioned, capability-rich model catalog that is different
|
|
|
28
28
|
|
|
29
29
|
CCDX exposes the GPT-6 Standard path and removes its unverified speed-tier metadata. It leaves the bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. GPT-5.6 Sol Fast follows the separate rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
|
|
30
30
|
|
|
31
|
+
`/v1/models` still attempts live discovery first. With a usable in-process catalog, it limits that upstream wait to 1.5 seconds (or a shorter configured upstream timeout) before falling back to last-known-good data. Without a catalog it retains the regular upstream timeout. Received HTTP `401`/`403` responses remain errors, not cached successes; fallback does not grant inference access. This does not change the explicitly live `ccdx models` command.
|
|
32
|
+
|
|
31
33
|
## Prerequisites
|
|
32
34
|
|
|
33
35
|
- A GitHub account with Copilot access to the desired models
|
|
@@ -78,6 +80,8 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
|
|
|
78
80
|
|
|
79
81
|
The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with format-preserving edits and atomic replacement; this is not a general TOML formatter. Quoted keys, quoted table headers, and explicit dotted shell-environment declarations are recognized; inline shell-environment tables are left intact. Startup and image configuration commands validate UTF-8/TOML and verify that unrelated settings retain their values before writing. An unsafe edit leaves the original file unchanged. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
|
|
80
82
|
|
|
83
|
+
Interactive image setup rereads the shared config after prompts and network validation, so unrelated changes made during setup are preserved. It checks the snapshot again before committing and rejects conflicting image declarations. Identical configuration bytes are not replaced; private file permissions may still be corrected. These checks reduce lost-update risks but are not a filesystem-wide transaction with other applications that do not share a lock.
|
|
84
|
+
|
|
81
85
|
## Version 0.7.0 migration
|
|
82
86
|
|
|
83
87
|
CCDX 0.7.0 retires the former Claude App, Claude Code, Anthropic Messages API, isolated Claude account, and PM Studio patch integrations. Their setup, status, restore, profile, and routing commands are no longer supported.
|
|
@@ -147,10 +151,14 @@ ccdx enable-image
|
|
|
147
151
|
|
|
148
152
|
The command accepts an HTTPS base URL or a full `/images/generations` endpoint, then asks for an API key without echoing the key. A bare origin such as `https://api.example` expands to `/v1/images/generations`; `https://api.example/v1` expands to the same endpoint. Custom base paths are retained, and existing full endpoints remain unchanged. CCDX uses GET for the corresponding `/models` catalog and POST for image requests; there is no endpoint-search or automatic fallback. It asks for a numeric model selection only when multiple image models are available, then detects either the standard OpenAI Images request format or the supported `input.messages` format for the selected model only. Its single validation request omits the prompt and does not generate an image.
|
|
149
153
|
|
|
154
|
+
An empty key answer retains the saved key only when the normalized endpoint has the same origin (scheme, host, and port). Changing origins requires explicitly entering a key before any provider request; the old key is never automatically sent to a new origin.
|
|
155
|
+
|
|
150
156
|
The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.json`, or under `XDG_CONFIG_HOME`, with mode `0600`. The API key is never written to `~/.codex/config.toml`; that file receives only a marked local MCP entry pointing to the running CCDX adapter with a 210-second tool timeout. Only after enabling, CCDX installs `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. The concise `SKILL.md` keeps operation selection, safety, and final-image delivery rules; editing details and helper instructions live in references read only for those operations. Codex uses the MCP tool directly, including through deferred tool search; an already-open task without that tool can use the helper to call the same local service and display the saved image. Neither route requires Python, an OpenAI SDK, or copying credentials. Built-in skills and unrelated tools are not modified.
|
|
151
157
|
|
|
152
158
|
The enable command verifies the local MCP handshake and generation-tool directory without generating an image. It reports provider configuration separately from local service readiness; it cannot assert that every running Codex task has refreshed its catalog. Codex normally detects changed skills; restart Codex App once if an open task has not refreshed them. Normal CCDX startup installs or updates this guidance only for an already-enabled provider, without repeating API setup. Without `enable-image`, it installs neither this skill nor the MCP registration. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
|
|
153
159
|
|
|
160
|
+
Existing compatible `ccdx_image` registrations are reused without requiring CCDX's comment markers or resetting user timeouts/tool policies. Missing or damaged markers alone do not block startup. Core GPT configuration is committed independently before optional image maintenance: image configuration conflicts, image-only write failures, or Skill maintenance failures warn without aborting the core service. No new image guidance is installed after failed image configuration. With images disabled or their provider configuration invalid/unreadable, normal startup performs no image configuration maintenance, Skill installation, or image-network probe; explicit `disable-image` owns cleanup. Automatic updates/removal are limited to recognizable CCDX defaults and isolated TOML declarations, never everything between two comments. Ambiguous shared-inline or customized registrations are left intact; `disable-image` still disables the CCDX provider and removes its owned guidance. Invalid TOML/UTF-8 in the shared Codex config or a failed essential core configuration write remains an explicit error rather than a silent success.
|
|
161
|
+
|
|
154
162
|
Direct MCP generations and edits retain their original inline image content and editing ID, and also save the exact image bytes privately under `~/.local/share/codex-copilot-dx/images`. The result includes ready-to-use image Markdown for Codex's final chat reply, so the image appears as a clickable thumbnail instead of only inside a tool result. Files use unique names and are not overwritten or removed on adapter restart. Asking to show the same image again reuses its existing Markdown, not a new generation or edit. If saving fails, CCDX still returns the generated image content, reports the delivery failure, and does not retry the provider or invent a file path. The bundled helper continues saving once at its existing output location, without a second adapter-side copy. Existing enabled users receive updated, unmodified CCDX-owned guidance on the next CCDX startup; no new setup command is needed.
|
|
155
163
|
|
|
156
164
|
Inspect or disable the optional provider with:
|
|
@@ -235,7 +243,9 @@ For the complete machine-readable status payload:
|
|
|
235
243
|
curl -s http://127.0.0.1:2026/_ccdx/status | jq
|
|
236
244
|
```
|
|
237
245
|
|
|
238
|
-
The status endpoint is restricted to the socket's real loopback address even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, model-cache counts, token expiry state, and the ten most recent upstream `response.failed` diagnostics retained by the running process. Failure diagnostics include the event type, model, error code/message, response/request IDs when supplied upstream, and retry outcome
|
|
246
|
+
The status endpoint is restricted to the socket's real loopback address even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, model-cache counts, token expiry state, and the ten most recent upstream `response.failed` diagnostics retained by the running process. Failure diagnostics include the event type, model, error code/message, response/request IDs when supplied upstream, and retry outcome. Common credential forms, long opaque encrypted values, and explicitly labelled input/prompt/tool-content echoes are redacted. They are cleared when the adapter restarts. No request/response bodies, credential fields, or account profiles are copied into status. Arbitrary upstream prose cannot be guaranteed free of sensitive content; review diagnostics before sharing them.
|
|
247
|
+
|
|
248
|
+
`image_mcp` has its own HTTP route counter. After the image handler has been used, `image_generation` separately reports active, succeeded, failed, busy, cancelled, and chat-file delivery-failure counts, including tool errors returned with HTTP 200. A delivery failure can accompany a successful generation; it never causes another generation. Before handler initialization this field is `null`, meaning not observed, not necessarily disabled. Status never initializes that handler, reads its provider credential, installs guidance, or probes the provider. Input-image optimization metrics remain separate. `logging` reports pending records/bytes, queue caps, overflow drops, and write failures for usage and enabled debug-file logging.
|
|
239
249
|
|
|
240
250
|
`stream_performance.by_route` also reports `request_ttft_ms`, measured from adapter entry to the first observed output delta, including local preparation. The existing `ttft_ms` continues to start at upstream dispatch. Neither timestamp means the client has drained or displayed the output. `preparation_ms` contains bounded histograms for admission (including history reservations), body reading/decompression/JSON parsing, history materialization/compact preparation, image optimization, and request serialization. Each sample measures one operation, including failed operations; retries can add samples. These are diagnostic intervals rather than a complete breakdown of request time: body reading includes internal decoded-body admission waits; authentication, routing, and other preparation are not individually timed. Non-streaming requests contribute preparation samples without inventing a first-token measurement.
|
|
241
251
|
|
|
@@ -267,7 +277,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
|
|
|
267
277
|
- The GitHub OAuth credential is stored locally at `~/.local/share/copilot-api/github_token`. Short-lived Copilot service tokens remain in process memory and are refreshed as needed.
|
|
268
278
|
- The optional image API credential is stored separately with mode `0600` only after `ccdx enable-image`; it is never placed in Codex configuration or diagnostic output.
|
|
269
279
|
- Runtime status and usage records retain metadata rather than request/response content. Diagnostic logging records status and error messages; it is not a general redaction layer for arbitrary error text. See Debug logging before sharing logs.
|
|
270
|
-
- Request
|
|
280
|
+
- Request/response bodies, request-admission queues, retained history, image work, SSE buffers, log queues, retries, and shutdown have explicit limits. These component limits are not a single process-wide memory ceiling; completed image files are persistent user artifacts, not an automatically evicted cache.
|
|
271
281
|
|
|
272
282
|
## Configuration
|
|
273
283
|
|
|
@@ -331,9 +341,11 @@ ccdx usage
|
|
|
331
341
|
|
|
332
342
|
The summary covers the current log and its single rotated backup, not an unbounded lifetime history. It reports recorded responses and per-model input, cache-read, output, and total token metadata.
|
|
333
343
|
|
|
344
|
+
Usage writes are asynchronous and batched under the existing cross-process rotation lock. Pending and in-flight records share a 4096-record / 4 MiB ceiling. If either ceiling is reached, new excess records are dropped with a warning and a status counter; inference does not wait or retry. Normal records and per-record rotation boundaries are unchanged. The bounded shutdown flush remains 1.5 seconds; disk failures and shutdown deadlines can prevent persistence, so usage is diagnostic rather than a billing ledger.
|
|
345
|
+
|
|
334
346
|
## Debug logging
|
|
335
347
|
|
|
336
|
-
Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers.
|
|
348
|
+
Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers. File diagnostics redact common credential forms and labelled content echoes, but this cannot identify every secret in arbitrary external prose; review logs before sharing them. File writes are asynchronous and batched with a 4096-record / 2 MiB pending-and-in-flight ceiling. Overflow drops excess file records with one warning and a status counter; terminal output continues. CLI exit paths flush for at most 1.5 seconds. `ccdx doctor config` bypasses file logging entirely.
|
|
337
349
|
|
|
338
350
|
## Large histories and images
|
|
339
351
|
|
|
@@ -357,6 +369,8 @@ Changing the model or transport of a continuation can require removing incompati
|
|
|
357
369
|
|
|
358
370
|
After the stream handshake succeeds, stream liveness uses the idle timeout rather than the expired handshake deadline. Eligible HTTP-200 failure recovery acquires a bounded preparation budget and history admission before rebuilding its payload, then releases those reservations when the retry opens upstream or fails. Normal streams still release preparation admission after their response headers arrive.
|
|
359
371
|
|
|
372
|
+
Downstream backpressure waits also observe cancellation and the stream deadline. A client that stops reading cannot retain the pending stream indefinitely after timeout; CCDX closes that connection without retrying its request. Final error-frame delivery has a separate one-second maximum drain wait.
|
|
373
|
+
|
|
360
374
|
Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Allowlisted rate-limit, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
|
|
361
375
|
|
|
362
376
|
CCDX keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, addressing this cause of duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged by this ID normalization. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
|
package/bin/cli.mjs
CHANGED
|
@@ -229,8 +229,8 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
229
229
|
|
|
230
230
|
async function configureCodexClient() {
|
|
231
231
|
const imageProviderEnabled = Boolean(readImageProviderConfig());
|
|
232
|
-
ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
|
|
233
|
-
if (imageProviderEnabled) {
|
|
232
|
+
const { imageMcpSkipped } = ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
|
|
233
|
+
if (imageProviderEnabled && !imageMcpSkipped) {
|
|
234
234
|
try {
|
|
235
235
|
const { syncEnabledImageSkill } = await import("../src/cli-image.mjs");
|
|
236
236
|
syncEnabledImageSkill({ adapterPort: ADAPTER_PORT, adapterHost: ADAPTER_HOST });
|
|
@@ -248,6 +248,7 @@ if (CLI.command === "doctor") {
|
|
|
248
248
|
online: CLI.online,
|
|
249
249
|
compat: CLI.compat,
|
|
250
250
|
});
|
|
251
|
+
await LOGGING.cleanup();
|
|
251
252
|
process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
|
|
252
253
|
}
|
|
253
254
|
|
|
@@ -270,7 +271,10 @@ async function printUpdateNotice() {
|
|
|
270
271
|
try {
|
|
271
272
|
assertSafeAdapterHost(ADAPTER_HOST, process.env);
|
|
272
273
|
void runInBackground(printUpdateNotice);
|
|
273
|
-
if (await reuseRunningAdapterIfAvailable())
|
|
274
|
+
if (await reuseRunningAdapterIfAvailable()) {
|
|
275
|
+
await LOGGING.cleanup();
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
274
278
|
|
|
275
279
|
// Ensure GitHub login, using device flow if no token exists.
|
|
276
280
|
await ensureAuth();
|
|
@@ -347,7 +351,7 @@ try {
|
|
|
347
351
|
exitCode = 1;
|
|
348
352
|
console.error(status("err", `Shutdown failed: ${e.message}`));
|
|
349
353
|
} finally {
|
|
350
|
-
LOGGING.cleanup();
|
|
354
|
+
await LOGGING.cleanup();
|
|
351
355
|
process.exit(exitCode);
|
|
352
356
|
}
|
|
353
357
|
};
|
|
@@ -357,6 +361,6 @@ try {
|
|
|
357
361
|
console.error(status("err", e.message));
|
|
358
362
|
await closeHttpServer(activeServer, { timeoutMs: RUNTIME.shutdownTimeoutMs }).catch(() => {});
|
|
359
363
|
await flushUsageWrites();
|
|
360
|
-
LOGGING.cleanup();
|
|
364
|
+
await LOGGING.cleanup();
|
|
361
365
|
process.exit(1);
|
|
362
366
|
}
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -213,6 +213,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
213
213
|
admission: acquireRequest,
|
|
214
214
|
imagePressure,
|
|
215
215
|
responseFailures,
|
|
216
|
+
imageGeneration: imageMcpHandler?.stats?.(),
|
|
216
217
|
modelRegistry: codexModelRegistry,
|
|
217
218
|
codexClient,
|
|
218
219
|
codexModelRegistry,
|
|
@@ -237,7 +238,12 @@ export function createAdapterHandler(options = {}) {
|
|
|
237
238
|
if (req.method === "GET" && pathname === "/v1/models") {
|
|
238
239
|
const clientVersion = modelClientVersion(req.url);
|
|
239
240
|
const abort = createRequestAbort(req, res);
|
|
240
|
-
|
|
241
|
+
// Keep live discovery and explicit auth failures authoritative, but do
|
|
242
|
+
// not make a usable cached picker wait the full inference timeout.
|
|
243
|
+
const modelTimeoutMs = isValidModelList(codexModelRegistry?.models)
|
|
244
|
+
? Math.min(upstreamTimeoutMs, parsePositiveInteger(options.cachedModelsTimeoutMs, 1500))
|
|
245
|
+
: upstreamTimeoutMs;
|
|
246
|
+
abort.setTimeout(modelTimeoutMs);
|
|
241
247
|
listModelsFn({ signal: abort.signal })
|
|
242
248
|
.then(async ({ status, body }) => {
|
|
243
249
|
abort.clearTimeout();
|
package/src/cli-image.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 { createInterface } from "node:readline/promises";
|
|
6
|
-
import { atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
6
|
+
import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
7
7
|
import { terminalCell } from "./cli-table.mjs";
|
|
8
8
|
import { computeImageMcpCodexConfig } from "./config.mjs";
|
|
9
9
|
import { probeImageTool } from "./image-readiness.mjs";
|
|
@@ -11,6 +11,7 @@ import { updateImageSkill } from "./image-skill.mjs";
|
|
|
11
11
|
import {
|
|
12
12
|
imageProviderConfigPath,
|
|
13
13
|
inspectImageProvider,
|
|
14
|
+
normalizeImageEndpoint,
|
|
14
15
|
readImageProviderConfig,
|
|
15
16
|
validateImageProviderConfig,
|
|
16
17
|
} from "./image-provider-config.mjs";
|
|
@@ -110,12 +111,15 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
|
|
|
110
111
|
const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
|
|
111
112
|
const skill = updateImageSkill({ enabled: true, codexPath, adapterPort, adapterHost });
|
|
112
113
|
try {
|
|
114
|
+
if (fileContent(codexPath) !== codexContent) {
|
|
115
|
+
throw new Error("Codex configuration changed during image setup; retry to preserve the newer settings");
|
|
116
|
+
}
|
|
113
117
|
atomicWriteFilePairSync(
|
|
114
118
|
codexPath,
|
|
115
119
|
codex.content,
|
|
116
120
|
providerPath,
|
|
117
121
|
`${JSON.stringify(normalized, null, 2)}\n`,
|
|
118
|
-
{ mode: 0o600 },
|
|
122
|
+
{ mode: 0o600, writeFile: atomicWriteFileIfChangedSync },
|
|
119
123
|
);
|
|
120
124
|
} catch (error) {
|
|
121
125
|
skill.rollback();
|
|
@@ -134,7 +138,12 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
|
|
|
134
138
|
const skill = updateImageSkill({ enabled: false, codexPath, adapterPort, adapterHost });
|
|
135
139
|
try {
|
|
136
140
|
if (fs.existsSync(codexPath)) {
|
|
137
|
-
|
|
141
|
+
if (fileContent(codexPath) !== codexContent) {
|
|
142
|
+
throw new Error("Codex configuration changed during image cleanup; retry to preserve the newer settings");
|
|
143
|
+
}
|
|
144
|
+
atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, {
|
|
145
|
+
mode: 0o600, writeFile: atomicWriteFileIfChangedSync,
|
|
146
|
+
});
|
|
138
147
|
} else if (providerExists) {
|
|
139
148
|
fs.unlinkSync(providerPath);
|
|
140
149
|
}
|
|
@@ -203,10 +212,11 @@ export async function runImageCommand({
|
|
|
203
212
|
output.write(`${commandName} enable-image\n`);
|
|
204
213
|
output.write("Enter an HTTPS base URL (for example, https://api.example/v1) or a full /images/generations endpoint.\n");
|
|
205
214
|
const endpointAnswer = String(await ask(`API base URL or endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
215
|
+
if (!endpointAnswer && !current?.endpoint) throw new Error("Image API base URL or endpoint is required");
|
|
216
|
+
const endpoint = normalizeImageEndpoint(endpointAnswer || current.endpoint);
|
|
217
|
+
const sameOrigin = current && new URL(endpoint).origin === new URL(current.endpoint).origin;
|
|
218
|
+
const keyAnswer = String(await askSecret(`API key${sameOrigin ? " [Enter to keep current]" : current ? " [new origin; enter a key]" : ""}: `) || "").trim();
|
|
219
|
+
const apiKey = keyAnswer || (sameOrigin ? current.api_key : undefined);
|
|
210
220
|
if (!apiKey) throw new Error("Image API key is required");
|
|
211
221
|
|
|
212
222
|
output.write("Checking image API...\n");
|
|
@@ -227,7 +237,9 @@ export async function runImageCommand({
|
|
|
227
237
|
},
|
|
228
238
|
providerPath,
|
|
229
239
|
codexPath,
|
|
230
|
-
|
|
240
|
+
// Prompts and provider validation may take minutes. Merge into the latest
|
|
241
|
+
// shared file rather than replacing edits made while we were waiting.
|
|
242
|
+
codexContent: fileContent(codexPath),
|
|
231
243
|
adapterPort,
|
|
232
244
|
adapterHost,
|
|
233
245
|
});
|
package/src/cli-options.mjs
CHANGED
|
@@ -302,7 +302,7 @@ function topicHelp(name, topic) {
|
|
|
302
302
|
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
303
|
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.`,
|
|
304
304
|
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.`,
|
|
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. 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
306
|
"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
307
|
"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
308
|
"auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
|
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() : []) {
|
package/src/config.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
6
|
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
7
|
+
import { isLoopbackHost } from "./security.mjs";
|
|
7
8
|
import { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
8
9
|
|
|
9
10
|
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
@@ -12,6 +13,10 @@ const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
|
|
|
12
13
|
const IMAGE_MCP_START = "# ccdx:image-mcp:start";
|
|
13
14
|
const IMAGE_MCP_END = "# ccdx:image-mcp:end";
|
|
14
15
|
const IMAGE_MCP_SECTION = "mcp_servers.ccdx_image";
|
|
16
|
+
const IMAGE_MCP_KEYS = ["enabled", "tool_timeout_sec", "url"];
|
|
17
|
+
|
|
18
|
+
// Only optional image-configuration conflicts may degrade to core-only startup.
|
|
19
|
+
export class ImageMcpConfigError extends Error {}
|
|
15
20
|
const STARTUP_MANAGED_PATHS = [
|
|
16
21
|
"openai_base_url",
|
|
17
22
|
"model_context_window",
|
|
@@ -92,41 +97,98 @@ function imageMcpHost(adapterHost) {
|
|
|
92
97
|
return host;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return true;
|
|
100
|
+
function imageMcpUrl(value) {
|
|
101
|
+
if (typeof value !== "string" || /[\s\x00-\x1f\x7f]/.test(value)) return null;
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(value);
|
|
104
|
+
return url.protocol === "http:" && url.pathname === "/mcp/image"
|
|
105
|
+
&& !url.username && !url.password && !url.search && !url.hash ? url : null;
|
|
106
|
+
} catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isImageDeclaration(item) {
|
|
110
|
+
return ["table", "value"].includes(item.kind)
|
|
111
|
+
&& sameKeys(item.keys.slice(0, IMAGE_MANAGED_PATH.length), IMAGE_MANAGED_PATH);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function imageMcpMarkers(content, statements) {
|
|
115
|
+
return statements.filter((item) => item.kind === "comment"
|
|
116
|
+
&& [IMAGE_MCP_START, IMAGE_MCP_END].includes(item.source.trim())
|
|
117
|
+
&& !content.slice(content.lastIndexOf("\n", item.start - 1) + 1, item.start).trim());
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
function
|
|
117
|
-
|
|
120
|
+
function knownImageMcp(section, expectedUrl, previousBaseUrl, statements, markers) {
|
|
121
|
+
if (!section || Object.keys(section).sort().join(",") !== IMAGE_MCP_KEYS.join(",")
|
|
122
|
+
|| section.enabled !== true || section.tool_timeout_sec !== 210n) return false;
|
|
123
|
+
const url = imageMcpUrl(section.url);
|
|
124
|
+
if (!url) return false;
|
|
125
|
+
if (url.href === new URL(expectedUrl).href) return true;
|
|
126
|
+
// The core and image URLs were configured together. This survives comment
|
|
127
|
+
// stripping and allows an adapter address change without guessing a new owner.
|
|
128
|
+
try {
|
|
129
|
+
const previous = new URL(previousBaseUrl);
|
|
130
|
+
if (previous.protocol === "http:" && previous.pathname === "/v1"
|
|
131
|
+
&& !previous.username && !previous.password && !previous.search && !previous.hash
|
|
132
|
+
&& previous.origin === url.origin
|
|
133
|
+
&& (isLoopbackHost(url.hostname) || url.hostname === new URL(expectedUrl).hostname)) return true;
|
|
134
|
+
} catch {}
|
|
135
|
+
// Legacy markers are an additional ownership hint, never deletion boundaries.
|
|
136
|
+
const starts = markers.filter((item) => item.source.trim() === IMAGE_MCP_START);
|
|
137
|
+
const ends = markers.filter((item) => item.source.trim() === IMAGE_MCP_END);
|
|
138
|
+
if (starts.length !== 1 || ends.length !== 1 || starts[0].start >= ends[0].start
|
|
139
|
+
|| !(isLoopbackHost(url.hostname) || url.hostname === new URL(expectedUrl).hostname)) return false;
|
|
140
|
+
const declarations = statements.filter((item) => ["table", "value"].includes(item.kind));
|
|
141
|
+
return declarations.some(isImageDeclaration)
|
|
142
|
+
&& declarations.filter(isImageDeclaration).every((item) => item.start > starts[0].start && item.end < ends[0].start)
|
|
143
|
+
&& declarations.filter((item) => item.start > starts[0].start && item.start < ends[0].start).every(isImageDeclaration);
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
function
|
|
146
|
+
function removeImageMcpDeclarations(content, statements, markers) {
|
|
147
|
+
const declarations = statements.filter(isImageDeclaration);
|
|
148
|
+
// A shared inline parent is not a surgical edit target. Reuse it unchanged or
|
|
149
|
+
// leave it for the user; never serialize away sibling settings or credentials.
|
|
150
|
+
if (!declarations.length) return null;
|
|
151
|
+
const ranges = [...declarations, ...markers].map((item) => {
|
|
152
|
+
const start = content.lastIndexOf("\n", item.start - 1) + 1;
|
|
153
|
+
const newline = content.indexOf("\n", item.end);
|
|
154
|
+
return [start, newline < 0 ? content.length : newline + 1];
|
|
155
|
+
}).sort((left, right) => right[0] - left[0]);
|
|
156
|
+
for (const [start, end] of ranges) content = content.slice(0, start) + content.slice(end);
|
|
157
|
+
return content;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ensureImageMcp(lines, enabled, adapterPort, adapterHost, previousBaseUrl) {
|
|
121
161
|
const previous = lines.join("\n");
|
|
122
|
-
|
|
162
|
+
const config = parseCodexToml(previous);
|
|
163
|
+
const section = configValue(config, IMAGE_MANAGED_PATH);
|
|
164
|
+
const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
|
|
165
|
+
if (section !== undefined) {
|
|
166
|
+
// Reuse compatible registrations without rewriting comments, formatting,
|
|
167
|
+
// user tool policies, or timeouts. An explicit disabled flag is preserved.
|
|
168
|
+
if (enabled && imageMcpUrl(section?.url)?.href === new URL(url).href
|
|
169
|
+
&& [undefined, true].includes(section?.enabled)) return false;
|
|
170
|
+
const statements = codexTomlStatements(previous);
|
|
171
|
+
const markers = imageMcpMarkers(previous, statements);
|
|
172
|
+
if (!knownImageMcp(section, url, previousBaseUrl, statements, markers)) {
|
|
173
|
+
if (!enabled) return false;
|
|
174
|
+
throw new ImageMcpConfigError(`Codex config already defines [${IMAGE_MCP_SECTION}] outside the CCDX-managed block; its settings were preserved`);
|
|
175
|
+
}
|
|
176
|
+
const removed = removeImageMcpDeclarations(previous, statements, markers);
|
|
177
|
+
if (removed === null) {
|
|
178
|
+
if (!enabled) return false;
|
|
179
|
+
throw new ImageMcpConfigError(`Cannot safely update [${IMAGE_MCP_SECTION}] inside a shared inline table; its settings were preserved`);
|
|
180
|
+
}
|
|
181
|
+
lines.splice(0, lines.length, ...removed.split("\n"));
|
|
182
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
183
|
+
}
|
|
123
184
|
if (enabled) {
|
|
124
|
-
|
|
125
|
-
|
|
185
|
+
const parent = config.mcp_servers;
|
|
186
|
+
if (parent !== undefined && (typeof parent !== "object" || parent === null || Array.isArray(parent)
|
|
187
|
+
|| codexTomlStatements(previous).some((item) => item.kind === "value" && sameKeys(item.keys, ["mcp_servers"])))) {
|
|
188
|
+
throw new ImageMcpConfigError("Cannot append the image MCP to an inline or non-table mcp_servers setting; its settings were preserved");
|
|
126
189
|
}
|
|
127
190
|
while (lines.length && lines.at(-1).trim() === "") lines.pop();
|
|
128
191
|
if (lines.length) lines.push("");
|
|
129
|
-
const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
|
|
130
192
|
lines.push(
|
|
131
193
|
IMAGE_MCP_START,
|
|
132
194
|
`[${IMAGE_MCP_SECTION}]`,
|
|
@@ -149,7 +211,6 @@ export function computeUpdatedCodexConfig(
|
|
|
149
211
|
const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
|
|
150
212
|
const hadTrailingNewline = content.endsWith("\n");
|
|
151
213
|
const lines = content.split("\n");
|
|
152
|
-
if (hadTrailingNewline) lines.pop();
|
|
153
214
|
|
|
154
215
|
let changed = false;
|
|
155
216
|
const openaiLine = `openai_base_url = "${baseUrl}"`;
|
|
@@ -166,10 +227,11 @@ export function computeUpdatedCodexConfig(
|
|
|
166
227
|
changed = setTomlKey(lines, "shell_environment_policy.set", "OPENAI_API_KEY", "dummy") || changed;
|
|
167
228
|
changed = ensureContextManagementDefault(lines) || changed;
|
|
168
229
|
if (typeof imageProviderEnabled === "boolean") {
|
|
169
|
-
changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost) || changed;
|
|
230
|
+
changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost, config.openai_base_url) || changed;
|
|
170
231
|
}
|
|
171
232
|
|
|
172
|
-
const
|
|
233
|
+
const joined = lines.join("\n");
|
|
234
|
+
const updated = joined + (hadTrailingNewline && !joined.endsWith("\n") ? "\n" : "");
|
|
173
235
|
validateManagedConfigEdit(config, updated, typeof imageProviderEnabled === "boolean"
|
|
174
236
|
? [...STARTUP_MANAGED_PATHS, IMAGE_MANAGED_PATH] : STARTUP_MANAGED_PATHS);
|
|
175
237
|
return { content: updated, changed };
|
|
@@ -196,20 +258,26 @@ context_management = true
|
|
|
196
258
|
: base;
|
|
197
259
|
}
|
|
198
260
|
|
|
199
|
-
|
|
261
|
+
function computeImageConfig(
|
|
200
262
|
content,
|
|
201
263
|
{ enabled, adapterPort = 2026, adapterHost = "127.0.0.1" } = {},
|
|
264
|
+
previousBaseUrl,
|
|
202
265
|
) {
|
|
203
266
|
const config = parseCodexToml(content);
|
|
204
267
|
const hadTrailingNewline = content.endsWith("\n");
|
|
205
268
|
const lines = content.split("\n");
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
269
|
+
const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost, previousBaseUrl ?? config.openai_base_url);
|
|
270
|
+
if (!changed) return { content, changed: false };
|
|
271
|
+
const joined = lines.join("\n");
|
|
272
|
+
const updated = joined + ((hadTrailingNewline || lines.length) && !joined.endsWith("\n") ? "\n" : "");
|
|
209
273
|
validateManagedConfigEdit(config, updated, [IMAGE_MANAGED_PATH]);
|
|
210
274
|
return { content: updated, changed };
|
|
211
275
|
}
|
|
212
276
|
|
|
277
|
+
export function computeImageMcpCodexConfig(content, options = {}) {
|
|
278
|
+
return computeImageConfig(content, options);
|
|
279
|
+
}
|
|
280
|
+
|
|
213
281
|
export function ensureCodexConfig(adapterPort = 2026, {
|
|
214
282
|
filePath = CONFIG_PATH,
|
|
215
283
|
host = "127.0.0.1",
|
|
@@ -217,26 +285,41 @@ export function ensureCodexConfig(adapterPort = 2026, {
|
|
|
217
285
|
} = {}) {
|
|
218
286
|
const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
|
|
219
287
|
|
|
220
|
-
if (!fs.existsSync(filePath)) {
|
|
221
|
-
// Codex config does not exist yet; create the local proxy defaults.
|
|
222
|
-
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host, { imageProviderEnabled }));
|
|
223
|
-
console.log(status("ok", "Created ~/.codex/config.toml"));
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
288
|
let content;
|
|
228
289
|
try {
|
|
229
290
|
content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
|
|
230
291
|
} catch (error) {
|
|
231
|
-
if (error?.code
|
|
232
|
-
|
|
292
|
+
if (error?.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
|
|
293
|
+
throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
|
|
294
|
+
}
|
|
295
|
+
if (error?.code !== "ENOENT") throw error;
|
|
233
296
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
297
|
+
// Commit essential configuration independently. Image-only parse/edit/write
|
|
298
|
+
// failures must not roll it back or close an otherwise usable GPT adapter.
|
|
299
|
+
const core = content === undefined
|
|
300
|
+
? { content: initialCodexConfig(adapterPort, host), changed: true }
|
|
301
|
+
: computeUpdatedCodexConfig(content, adapterPort, host);
|
|
302
|
+
if (!core.changed) {
|
|
237
303
|
console.log(status("ok", `Codex already points to ${baseUrl}`));
|
|
238
|
-
|
|
304
|
+
} else {
|
|
305
|
+
atomicWriteFileIfChangedSync(filePath, core.content);
|
|
306
|
+
console.log(status("ok", content === undefined ? "Created ~/.codex/config.toml" : `Configured Codex base URL: ${baseUrl}`));
|
|
307
|
+
}
|
|
308
|
+
// Disabled means no image maintenance, including no implicit removal of old
|
|
309
|
+
// or user-owned entries. Explicit disable-image owns the cleanup operation.
|
|
310
|
+
if (!imageProviderEnabled) return { imageMcpSkipped: false };
|
|
311
|
+
try {
|
|
312
|
+
const previousBaseUrl = content === undefined ? undefined : parseCodexToml(content).openai_base_url;
|
|
313
|
+
const image = computeImageConfig(core.content, { enabled: true, adapterPort, adapterHost: host }, previousBaseUrl);
|
|
314
|
+
if (image.changed) {
|
|
315
|
+
if (fs.readFileSync(filePath, "utf8") !== core.content) {
|
|
316
|
+
throw new ImageMcpConfigError("Codex config changed during image setup; the newer configuration was preserved");
|
|
317
|
+
}
|
|
318
|
+
atomicWriteFileIfChangedSync(filePath, image.content);
|
|
319
|
+
}
|
|
320
|
+
return { imageMcpSkipped: false };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.warn(status("warn", `Image MCP setup was skipped: ${error.message}. Core proxy startup will continue.`));
|
|
323
|
+
return { imageMcpSkipped: true };
|
|
239
324
|
}
|
|
240
|
-
atomicWriteFileIfChangedSync(filePath, updated.content);
|
|
241
|
-
console.log(status("ok", `Configured Codex base URL: ${baseUrl}`));
|
|
242
325
|
}
|
|
@@ -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
|
}
|
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/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,
|
|
@@ -177,6 +182,8 @@ export function runtimeStatusPayload({
|
|
|
177
182
|
admission: admission?.diagnostics?.() || admission?.stats?.() || null,
|
|
178
183
|
response_history: responseHistoryStats(),
|
|
179
184
|
image_optimization: imageOptimizationStats(),
|
|
185
|
+
image_generation: imageGeneration || null,
|
|
186
|
+
logging: { usage: usageLoggingStats(), debug: debugLoggingStats() },
|
|
180
187
|
image_history_pressure: imagePressure?.snapshot?.() || null,
|
|
181
188
|
response_failures: responseFailures?.snapshot?.() || null,
|
|
182
189
|
copilot: codexRuntime,
|
|
@@ -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
|
|
|
@@ -319,7 +319,7 @@ export function createResponsesHandler(options) {
|
|
|
319
319
|
"Cache-Control": "no-cache",
|
|
320
320
|
Connection: "keep-alive",
|
|
321
321
|
});
|
|
322
|
-
const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n
|
|
322
|
+
const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, { signal: abort.signal });
|
|
323
323
|
if (!written) return false;
|
|
324
324
|
if (event === "response.completed") {
|
|
325
325
|
rememberResponseHistory(prepared, data.response);
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -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;
|
package/src/stream-errors.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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)
|
|
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;
|