codex-copilot-dx 0.9.13 → 0.9.15
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 +61 -433
- package/docs/README.md +55 -0
- package/docs/REFERENCE.md +481 -0
- package/package.json +4 -3
- package/src/adapter.mjs +2 -0
- package/src/cli-options.mjs +1 -1
- package/src/cli-status.mjs +3 -0
- package/src/codex-client-doctor.mjs +67 -0
- package/src/codex-model-catalog.mjs +16 -2
- package/src/copilot-client.mjs +2 -1
- package/src/copilot-responses-policy.mjs +35 -18
- package/src/dashboard/index.html +7 -0
- package/src/dashboard/language.js +11 -0
- package/src/dashboard/ui.css +7 -6
- package/src/dashboard/ui.js +30 -0
- package/src/doctor.mjs +3 -0
- package/src/image-optimization.mjs +1 -1
- package/src/models.mjs +6 -0
- package/src/observability.mjs +17 -0
- package/src/responses-bridge.mjs +1 -1
- package/src/responses-chat-payload.mjs +2 -1
- package/src/responses-handler.mjs +12 -2
- package/src/responses-proxy.mjs +10 -0
- package/src/sse-keepalive.mjs +42 -0
- package/src/stream-performance.mjs +14 -1
- package/src/usage.mjs +3 -0
package/src/cli-options.mjs
CHANGED
|
@@ -344,7 +344,7 @@ function topicHelp(name, topic) {
|
|
|
344
344
|
start: `Usage:\n ${name} [start] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex configuration, and attempts to open Codex App when auto-launch is enabled and supported.`,
|
|
345
345
|
auth: `Usage:\n ${name} auth status [--online] [--format table|plain]\n\nShows the saved GitHub Copilot account without exposing credentials. --online also verifies its entitlement and model catalog.`,
|
|
346
346
|
"auth status": `Usage:\n ${name} auth status [--online] [--format table|plain]\n\nShows the saved GitHub Copilot account without exposing credentials. --online also verifies its entitlement and model catalog. Interactive terminals use a table by default.`,
|
|
347
|
-
doctor: `Usage:\n ${name} doctor [--online] [--compat]\n ${name} doctor config\n\nChecks the local credential, Codex configuration, and
|
|
347
|
+
doctor: `Usage:\n ${name} doctor [--online] [--compat]\n ${name} doctor config\n\nChecks the local credential, Codex configuration, adapter, installed App version, running App identities and observed client catalog. App-internal cache is not externally verified. No processes are killed or client files changed. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage. The config subcommand checks only the local file and previews startup changes, without network, authentication, file writes or process inspection.`,
|
|
348
348
|
"doctor config": `Usage:\n ${name} doctor config\n\nReads ~/.codex/config.toml, checks TOML syntax and CCDX-managed settings, and previews the existing startup writer's changes. Does not merge Codex profiles or validate the complete installed Codex schema. No authentication, network requests, file writes, or adapter/GUI startup. Existing context_management=false is valid. Exit 1 for errors, 0 for OK/warnings; unsupported arguments exit 2.`,
|
|
349
349
|
status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
|
|
350
350
|
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.`,
|
package/src/cli-status.mjs
CHANGED
|
@@ -195,5 +195,8 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
|
|
|
195
195
|
lines.push(status("info", `Routing: /v1/responses -> ${routingTarget(data.routing.responses)}`));
|
|
196
196
|
}
|
|
197
197
|
lines.push(status("info", `Limits: request body ${mebibytes(limits.max_body_bytes)}, decoded body ${mebibytes(limits.max_decoded_body_bytes)}`));
|
|
198
|
+
const recentContext = data.stream_performance?.recent_requests?.at(-1)?.context;
|
|
199
|
+
const contextValue = (value, format) => value === null || value === undefined ? "n/a" : format(value);
|
|
200
|
+
if (recentContext) lines.push(status("info", `Latest request context: wire ${contextValue(recentContext.payload_bytes, mebibytes)}, ${contextValue(recentContext.images, count)} images, input ${contextValue(recentContext.input_tokens, count)} tokens / window ${contextValue(recentContext.context_window_tokens, count)} (reported metadata, not live session occupancy)`));
|
|
198
201
|
return lines.join("\n");
|
|
199
202
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { readAdapterStatus } from "./cli-status.mjs";
|
|
7
|
+
|
|
8
|
+
export function parseRunningCodexApps(stdout) {
|
|
9
|
+
const apps = new Map();
|
|
10
|
+
for (const line of String(stdout).split("\n")) {
|
|
11
|
+
const match = /^\s*(\d+)\s+.*\/(ChatGPT|Codex)\.app\/Contents\//.exec(line);
|
|
12
|
+
if (!match) continue;
|
|
13
|
+
const name = `${match[2]}.app`;
|
|
14
|
+
const pids = apps.get(name) || [];
|
|
15
|
+
if (pids.length < 8) pids.push(Number(match[1]));
|
|
16
|
+
apps.set(name, pids);
|
|
17
|
+
}
|
|
18
|
+
return [...apps].map(([name, pids]) => ({ name, pids }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function inspectCodexClient({
|
|
22
|
+
home = os.homedir(), platform = process.platform, running,
|
|
23
|
+
host, port, fetchImpl = fetch, readFileFn = fs.readFileSync,
|
|
24
|
+
execFileFn = promisify(execFile), readStatus = readAdapterStatus,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const checks = [];
|
|
27
|
+
if (platform === "darwin") {
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const root of ["/Applications", path.join(home, "Applications")]) {
|
|
30
|
+
for (const name of ["ChatGPT.app", "Codex.app"]) {
|
|
31
|
+
try {
|
|
32
|
+
const plist = readFileFn(path.join(root, name, "Contents", "Info.plist"), "utf8");
|
|
33
|
+
const version = /<key>CFBundleShortVersionString<\/key>\s*<string>([0-9A-Za-z.+_-]{1,64})<\/string>/.exec(plist)?.[1];
|
|
34
|
+
if (version && !seen.has(`${name}/${version}`)) {
|
|
35
|
+
checks.push({ kind: "info", message: `Installed ${name}: App version ${version}` });
|
|
36
|
+
seen.add(`${name}/${version}`);
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
// comm contains executable names, never command-line arguments or environments.
|
|
43
|
+
const { stdout } = await execFileFn("ps", ["-axo", "pid=,comm="], { timeout: 1500, maxBuffer: 256 * 1024, encoding: "utf8" });
|
|
44
|
+
const apps = parseRunningCodexApps(stdout);
|
|
45
|
+
for (const app of apps) checks.push({ kind: "info", message: `Running ${app.name}: PID ${app.pids.join(", ")}; fully quit with Command-Q before reopening (closing the window may keep it running)` });
|
|
46
|
+
if (!apps.length) checks.push({ kind: "info", message: "No running Codex/ChatGPT App process observed" });
|
|
47
|
+
} catch { checks.push({ kind: "info", message: "Running Codex App processes could not be inspected" }); }
|
|
48
|
+
}
|
|
49
|
+
if (running?.ok || running?.incompatible) {
|
|
50
|
+
try {
|
|
51
|
+
const { data } = await readStatus({ host, port, fetchImpl, timeoutMs: 1500 });
|
|
52
|
+
const source = ["live", "cache", "built-in"].includes(data.models?.source) ? data.models.source : "unknown";
|
|
53
|
+
checks.push({ kind: "info", message: `Running adapter ${data.version}; upstream model directory source: ${source}` });
|
|
54
|
+
const catalog = data.models?.client_catalog;
|
|
55
|
+
const observed = catalog?.last_request;
|
|
56
|
+
if (!catalog) {
|
|
57
|
+
checks.push({ kind: "info", message: "Running adapter does not expose client-catalog diagnostics; upgrade and restart CCDX" });
|
|
58
|
+
} else if (observed) {
|
|
59
|
+
const version = /^[0-9]+\.[0-9]+\.[0-9]+$/.test(observed.client_version || "") ? observed.client_version : "unknown";
|
|
60
|
+
const app = ["ChatGPT.app", "Codex.app", "custom binary"].includes(observed.application) ? observed.application : "unknown App";
|
|
61
|
+
checks.push({ kind: "info", message: `Last catalog request: client ${version}, ${app}, ${observed.source === "bundled" ? "bundled" : "unavailable"}; version match ${observed.matched === true ? "observed" : "not verified"}` });
|
|
62
|
+
} else checks.push({ kind: "info", message: "No client model-catalog request observed by this adapter" });
|
|
63
|
+
} catch { checks.push({ kind: "info", message: "Client model-catalog diagnostics unavailable from the running adapter" }); }
|
|
64
|
+
}
|
|
65
|
+
checks.push({ kind: "info", message: "App-internal model cache is not externally verified; process age alone does not prove a stale catalog" });
|
|
66
|
+
return checks;
|
|
67
|
+
}
|
|
@@ -248,6 +248,7 @@ export function createCodexModelCatalog({
|
|
|
248
248
|
const catalogFailures = new Map();
|
|
249
249
|
const catalogPending = new Map();
|
|
250
250
|
let activeBinaryKeys = new Set();
|
|
251
|
+
let lastObservation = null;
|
|
251
252
|
|
|
252
253
|
function retainCurrentEntries(map) {
|
|
253
254
|
for (const key of map.keys()) {
|
|
@@ -341,8 +342,21 @@ export function createCodexModelCatalog({
|
|
|
341
342
|
|
|
342
343
|
async function load({ clientVersion = "" } = {}) {
|
|
343
344
|
const candidate = await selectBinary(clientVersion);
|
|
344
|
-
|
|
345
|
+
const catalog = candidate ? await loadCatalog(candidate) : null;
|
|
346
|
+
const app = candidate?.binaryPath.match(/(?:^|\/)(ChatGPT|Codex)\.app\//)?.[1];
|
|
347
|
+
lastObservation = {
|
|
348
|
+
client_version: wholeSemver(clientVersion) || null,
|
|
349
|
+
application: app ? `${app}.app` : candidate ? "custom binary" : null,
|
|
350
|
+
source: catalog ? "bundled" : "unavailable",
|
|
351
|
+
matched: Boolean(catalog && wholeSemver(clientVersion)),
|
|
352
|
+
observed_at: new Date(now()).toISOString(),
|
|
353
|
+
};
|
|
354
|
+
return catalog;
|
|
345
355
|
}
|
|
346
356
|
|
|
347
|
-
return { load
|
|
357
|
+
return { load, snapshot: () => ({
|
|
358
|
+
last_request: lastObservation ? { ...lastObservation } : null,
|
|
359
|
+
observed_versions: [...new Set(versionCache.values())].slice(0, 8),
|
|
360
|
+
client_cache_verified: false,
|
|
361
|
+
}) };
|
|
348
362
|
}
|
package/src/copilot-client.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { debugLog } from "./log.mjs";
|
|
|
5
5
|
import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
|
|
6
6
|
import { status } from "./status.mjs";
|
|
7
7
|
import { withChatStreamUsage } from "./stream-contract.mjs";
|
|
8
|
-
import { markUpstreamStarted } from "./stream-performance.mjs";
|
|
8
|
+
import { markRequestObservation, markUpstreamStarted } from "./stream-performance.mjs";
|
|
9
9
|
|
|
10
10
|
function abortError(signal) {
|
|
11
11
|
if (signal?.reason instanceof Error) return signal.reason;
|
|
@@ -178,6 +178,7 @@ export function createCopilotClientRuntime({
|
|
|
178
178
|
? preparedPayload.summary
|
|
179
179
|
: summarizeReqBody(reqBody);
|
|
180
180
|
console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
|
|
181
|
+
markRequestObservation({ payload_bytes: bodyBytes, images: summary.images });
|
|
181
182
|
if (adapted) debugLog(`responses payload adapted stage=${stage} bytes=${bodyBytes}/${preparedPayload.targetBytes}`);
|
|
182
183
|
const headers = buildHeaders({ token, version: getVSCodeVersion(), initiator: "user", vision: false });
|
|
183
184
|
headers["Content-Type"] = "application/json; charset=utf-8";
|
|
@@ -44,27 +44,44 @@ function toolName(tool) {
|
|
|
44
44
|
// the removal. Revalidate against Copilot before deleting or broadening it.
|
|
45
45
|
// Reference: https://github.com/Menci/Floway/blob/ac2bbb04352033a7d9d74574a9d465d40395ef06/packages/provider-copilot/src/interceptors/openai-responses/strip-image-generation.ts
|
|
46
46
|
export function applyCopilotResponsesRequestPolicies(body, options = {}) {
|
|
47
|
-
if (!
|
|
47
|
+
if (!body || typeof body !== "object") return false;
|
|
48
48
|
const removedNames = new Set();
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const survivingNames = new Set();
|
|
50
|
+
let survivingTools = 0;
|
|
51
|
+
let changed = false;
|
|
52
|
+
const filter = (container, topLevel = false) => {
|
|
53
|
+
if (!Array.isArray(container?.tools)) return;
|
|
54
|
+
const tools = container.tools;
|
|
55
|
+
const filtered = tools.filter((tool) => {
|
|
56
|
+
const removed = isCopilotImageNamespaceTool(tool, options);
|
|
52
57
|
const name = toolName(tool);
|
|
53
|
-
if (name) removedNames.add(name);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
if (removed && name) removedNames.add(name);
|
|
59
|
+
if (!removed) {
|
|
60
|
+
survivingTools += 1;
|
|
61
|
+
if (name) survivingNames.add(name);
|
|
62
|
+
}
|
|
63
|
+
return !removed;
|
|
64
|
+
});
|
|
65
|
+
if (filtered.length === tools.length) return;
|
|
66
|
+
changed = true;
|
|
67
|
+
if (topLevel && !filtered.length) delete container.tools;
|
|
68
|
+
else container.tools = filtered;
|
|
69
|
+
};
|
|
70
|
+
filter(body, true);
|
|
71
|
+
for (const item of Array.isArray(body.input) ? body.input : []) {
|
|
72
|
+
if (["additional_tools", "tool_search_output"].includes(item?.type)) filter(item);
|
|
73
|
+
}
|
|
74
|
+
if (!changed) return false;
|
|
60
75
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
const removedChoice = (choice) => isCopilotImageNamespaceTool(choice, options)
|
|
77
|
+
|| (removedNames.has(toolName(choice)) && !survivingNames.has(toolName(choice)));
|
|
78
|
+
if (body.tool_choice?.type === "allowed_tools" && Array.isArray(body.tool_choice.tools)) {
|
|
79
|
+
const choices = body.tool_choice.tools.filter((choice) => !removedChoice(choice));
|
|
80
|
+
body.tool_choice = choices.length ? { ...body.tool_choice, tools: choices } : "none";
|
|
81
|
+
} else if (removedChoice(body.tool_choice)) {
|
|
82
|
+
if (survivingTools) body.tool_choice = "none";
|
|
83
|
+
else delete body.tool_choice;
|
|
84
|
+
} else if (body.tool_choice === "required" && !survivingTools) {
|
|
68
85
|
delete body.tool_choice;
|
|
69
86
|
}
|
|
70
87
|
return true;
|
package/src/dashboard/index.html
CHANGED
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
</article>
|
|
49
49
|
</section>
|
|
50
50
|
|
|
51
|
+
<details class="panel dashboard-section" id="request-context">
|
|
52
|
+
<summary data-i18n>RECENT REQUEST CONTEXT</summary>
|
|
53
|
+
<p id="context-note" class="table-note">—</p>
|
|
54
|
+
<div class="table-wrap"><table><thead><tr><th scope="col" data-i18n>MODEL</th><th scope="col" data-i18n>WIRE MiB</th><th scope="col" data-i18n>IMAGES</th><th scope="col" data-i18n>INPUT TOKENS</th><th scope="col" data-i18n>MODEL WINDOW</th><th scope="col" data-i18n>INPUT / WINDOW</th><th scope="col" data-i18n>OUTCOME</th></tr></thead><tbody id="context-body"><tr><td colspan="7" data-i18n>No observed request metadata.</td></tr></tbody></table></div>
|
|
55
|
+
<p class="hint" data-i18n>Recent upstream request metadata, not exact live session occupancy. Missing values are unknown. No prompt or image content is retained.</p>
|
|
56
|
+
</details>
|
|
57
|
+
|
|
51
58
|
<section class="panel dashboard-section" aria-label="Live upstream models" data-i18n-label>
|
|
52
59
|
<div class="panel-heading"><h2 data-i18n>UPSTREAM GPT MODELS</h2><div class="heading-actions"><span id="models-state" data-i18n>CHECKING LIVE</span><button id="refresh-models" type="button" data-i18n>Refresh</button></div></div>
|
|
53
60
|
<p id="models-note" class="table-note" data-i18n>Querying GitHub Copilot…</p>
|
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
"Switch to light mode": "切换为日间模式",
|
|
11
11
|
"Switch to dark mode": "切换为夜间模式",
|
|
12
12
|
"Overview": "概览",
|
|
13
|
+
"RECENT REQUEST CONTEXT": "近期请求上下文",
|
|
14
|
+
"WIRE MiB": "上游请求 MiB",
|
|
15
|
+
"IMAGES": "图片数",
|
|
16
|
+
"INPUT TOKENS": "输入 Token",
|
|
17
|
+
"MODEL WINDOW": "模型窗口",
|
|
18
|
+
"INPUT / WINDOW": "输入 / 窗口",
|
|
19
|
+
"OUTCOME": "终态",
|
|
20
|
+
"Unknown": "未知",
|
|
21
|
+
"No observed request metadata.": "暂无已观察到的请求元数据。",
|
|
22
|
+
"20 latest finished requests / compactions: {completed} completed, {incomplete} incomplete, {failed} failed": "最近 20 次已结束请求 / 压缩:{completed} 已完成、{incomplete} 未完成、{failed} 失败",
|
|
23
|
+
"Recent upstream request metadata, not exact live session occupancy. Missing values are unknown. No prompt or image content is retained.": "近期上游请求元数据,不等同于会话实时精确占用。缺失值表示未知;不保留提示词或图片内容。",
|
|
13
24
|
"Runtime details": "运行详情",
|
|
14
25
|
"ADAPTER": "适配服务",
|
|
15
26
|
"KNOWN MODELS": "已知模型",
|
package/src/dashboard/ui.css
CHANGED
|
@@ -31,7 +31,7 @@ h1 span { color: var(--accent-soft); }
|
|
|
31
31
|
.overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
|
32
32
|
.card, .panel, .lower { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 2px; }
|
|
33
33
|
.card { min-height: 139px; padding: 18px; }
|
|
34
|
-
.card h2, .panel h2, .lower h2 { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; margin: 0; }
|
|
34
|
+
.card h2, .panel h2, .lower h2, .panel > summary, .analytics-section > summary { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; line-height: 1.6; margin: 0; }
|
|
35
35
|
.card strong, .panel > strong { display: block; margin-top: 21px; color: var(--strong); font-size: 23px; font-weight: 650; line-height: 1.25; letter-spacing: -.035em; overflow-wrap: anywhere; }
|
|
36
36
|
.card p { margin: 9px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
37
37
|
.details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; margin-top: 11px; }
|
|
@@ -88,15 +88,16 @@ td { color: var(--table-text); }
|
|
|
88
88
|
.lower li span, .lower li.empty { color: var(--muted); line-height: 1.5; }
|
|
89
89
|
summary { cursor: pointer; line-height: 1.6; overflow-wrap: anywhere; }
|
|
90
90
|
summary:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
details > summary { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 8px; list-style: none; }
|
|
92
|
+
details > summary::-webkit-details-marker { display: none; }
|
|
93
|
+
details > summary::marker { content: ""; }
|
|
94
|
+
details > summary::before { content: "+"; color: var(--heading); font-weight: 700; letter-spacing: 0; }
|
|
95
|
+
details[open] > summary::before { content: "−"; }
|
|
96
|
+
.settings-panel > summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; }
|
|
95
97
|
.failure-detail { display: grid; grid-template-columns: minmax(110px, auto) minmax(0, 1fr); gap: 7px 18px; margin: 14px 0; line-height: 1.6; }
|
|
96
98
|
.failure-detail dt { color: var(--muted); }
|
|
97
99
|
.failure-detail dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
98
100
|
.analytics-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
|
|
99
|
-
.analytics-section > summary { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
|
|
100
101
|
.analytics-controls { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
|
|
101
102
|
.analytics-controls label { color: var(--muted); font-size: 11px; }
|
|
102
103
|
select { max-width: 260px; padding: 5px; font: inherit; color: var(--text); background: var(--page); border: 1px solid var(--border); }
|
package/src/dashboard/ui.js
CHANGED
|
@@ -16,6 +16,8 @@ const finite = (value) => value !== null && value !== undefined && Number.isFini
|
|
|
16
16
|
const number = (value) => finite(value) ? Math.max(0, Math.round(Number(value))).toLocaleString() : "—";
|
|
17
17
|
const mib = (value) => finite(value) ? `${(Number(value) / 1048576).toFixed(1)} MiB` : "—";
|
|
18
18
|
let authLoading = false;
|
|
19
|
+
let contextRequests = [];
|
|
20
|
+
let contextCompactions = {};
|
|
19
21
|
|
|
20
22
|
function renderAuth(data) {
|
|
21
23
|
const account = data.login ? `@${data.login}` : data.id ? `ID ${data.id}` : phrase("account unknown");
|
|
@@ -156,9 +158,36 @@ function render(data) {
|
|
|
156
158
|
: "Setup state unknown / ccdx image-status", { active: number(image?.active), failed: number(image?.failed), delivery: number(image?.delivery_failures) });
|
|
157
159
|
|
|
158
160
|
renderFailures(data.response_failures?.recent, data.stream_performance?.recent_requests);
|
|
161
|
+
renderContext(data.stream_performance);
|
|
159
162
|
text("updated", "Snapshot {time}", { time: new Date().toLocaleTimeString() });
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
function renderContext(performance) {
|
|
166
|
+
if (performance !== undefined) {
|
|
167
|
+
contextRequests = Array.isArray(performance?.recent_requests) ? performance.recent_requests.slice(-20).reverse() : [];
|
|
168
|
+
contextCompactions = performance?.by_route?.responses_compact?.terminal_outcomes?.totals || {};
|
|
169
|
+
}
|
|
170
|
+
const body = element("context-body");
|
|
171
|
+
if (!body) return;
|
|
172
|
+
text("context-note", "20 latest finished requests / compactions: {completed} completed, {incomplete} incomplete, {failed} failed", {
|
|
173
|
+
completed: number(contextCompactions.completed), incomplete: number(contextCompactions.incomplete), failed: number(contextCompactions.failed),
|
|
174
|
+
});
|
|
175
|
+
if (element("request-context")?.open === false) return;
|
|
176
|
+
body.replaceChildren();
|
|
177
|
+
for (const request of contextRequests) {
|
|
178
|
+
const context = request.context || {};
|
|
179
|
+
const outcome = ["completed", "incomplete", "failed", "cancelled"].includes(request.outcome)
|
|
180
|
+
? request.outcome[0].toUpperCase() + request.outcome.slice(1) : "Unknown";
|
|
181
|
+
const ratio = finite(context.input_tokens) && finite(context.context_window_tokens) && Number(context.context_window_tokens) > 0
|
|
182
|
+
? `${(100 * Number(context.input_tokens) / Number(context.context_window_tokens)).toFixed(1)}%` : "—";
|
|
183
|
+
body.append(tableRow([String(request.model || "unknown").slice(0, 80),
|
|
184
|
+
finite(context.payload_bytes) ? (Number(context.payload_bytes) / 1048576).toFixed(2) : "—",
|
|
185
|
+
number(context.images), number(context.input_tokens), number(context.context_window_tokens), ratio,
|
|
186
|
+
outcome], { labels: [6] }));
|
|
187
|
+
}
|
|
188
|
+
if (!contextRequests.length) emptyTable("context-body", 7, "No observed request metadata.");
|
|
189
|
+
}
|
|
190
|
+
|
|
162
191
|
async function refresh() {
|
|
163
192
|
const button = element("refresh");
|
|
164
193
|
button.disabled = true;
|
|
@@ -186,6 +215,7 @@ async function refresh() {
|
|
|
186
215
|
}
|
|
187
216
|
|
|
188
217
|
element("refresh").addEventListener("click", () => { refresh(); loadAuth(); });
|
|
218
|
+
element("request-context").addEventListener("toggle", () => { if (element("request-context").open) renderContext(); });
|
|
189
219
|
refresh();
|
|
190
220
|
loadAuth();
|
|
191
221
|
|
package/src/doctor.mjs
CHANGED
|
@@ -312,6 +312,7 @@ export async function collectDoctorChecks({
|
|
|
312
312
|
onlineTimeoutMs = 10000,
|
|
313
313
|
compatTimeoutMs = 120000,
|
|
314
314
|
inspectAdapterCompatibilityFn = inspectAdapterCompatibility,
|
|
315
|
+
inspectCodexClientFn,
|
|
315
316
|
} = {}) {
|
|
316
317
|
if (configOnly) return inspectCodexConfig({ home, host, port });
|
|
317
318
|
checkedDoctorProfile(profile);
|
|
@@ -353,6 +354,8 @@ export async function collectDoctorChecks({
|
|
|
353
354
|
fix: "ccdx start",
|
|
354
355
|
});
|
|
355
356
|
}
|
|
357
|
+
const inspectClient = inspectCodexClientFn || (await import("./codex-client-doctor.mjs")).inspectCodexClient;
|
|
358
|
+
checks.push(...await inspectClient({ home, host, port, fetchImpl, running }));
|
|
356
359
|
}
|
|
357
360
|
|
|
358
361
|
if (compat) {
|
|
@@ -546,7 +546,7 @@ export function summarizeReqBody(reqBody) {
|
|
|
546
546
|
|
|
547
547
|
for (const item of input) {
|
|
548
548
|
if (["input_image", "image", "image_url"].includes(item?.type)) countImages([item]);
|
|
549
|
-
if (item?.type === "message") countImages(item.content);
|
|
549
|
+
if (item?.type === "message" || item?.type === "agent_message") countImages(item.content);
|
|
550
550
|
const toolOutput = readResponsesToolOutputParts(item);
|
|
551
551
|
if (toolOutput) countImages(toolOutput.parts);
|
|
552
552
|
}
|
package/src/models.mjs
CHANGED
|
@@ -56,6 +56,12 @@ function copilotModelData(models) {
|
|
|
56
56
|
return Array.isArray(data) ? data : [];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export function modelContextWindowTokens(models, id) {
|
|
60
|
+
const model = copilotModelData(models).find((entry) => String(entry?.id || "").trim() === id);
|
|
61
|
+
const limit = model?.capabilities?.limits?.max_context_window_tokens;
|
|
62
|
+
return Number.isSafeInteger(limit) && limit > 0 ? limit : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
function uniqueIds(models, predicate) {
|
|
60
66
|
const ids = [];
|
|
61
67
|
const seen = new Set();
|
package/src/observability.mjs
CHANGED
|
@@ -178,10 +178,27 @@ export function runtimeStatusPayload({
|
|
|
178
178
|
modelRegistry,
|
|
179
179
|
codexClient,
|
|
180
180
|
codexModelRegistry,
|
|
181
|
+
codexModelCatalog,
|
|
181
182
|
} = {}) {
|
|
182
183
|
const memory = process.memoryUsage();
|
|
183
184
|
const codexRuntime = safeClientRuntimeStatus(codexClient);
|
|
184
185
|
const codexModels = modelRegistryStatus(codexModelRegistry || modelRegistry);
|
|
186
|
+
if (typeof codexModelCatalog?.snapshot === "function") {
|
|
187
|
+
const snapshot = codexModelCatalog.snapshot();
|
|
188
|
+
const observed = snapshot?.last_request;
|
|
189
|
+
const version = (value) => /^\d+\.\d+\.\d+$/.test(value || "") ? value : null;
|
|
190
|
+
codexModels.client_catalog = {
|
|
191
|
+
last_request: observed ? {
|
|
192
|
+
client_version: version(observed.client_version),
|
|
193
|
+
application: ["ChatGPT.app", "Codex.app", "custom binary"].includes(observed.application) ? observed.application : null,
|
|
194
|
+
source: observed.source === "bundled" ? "bundled" : "unavailable",
|
|
195
|
+
matched: observed.matched === true,
|
|
196
|
+
observed_at: typeof observed.observed_at === "string" && /^\d{4}-\d{2}-\d{2}T[0-9:.]+Z$/.test(observed.observed_at) ? observed.observed_at : null,
|
|
197
|
+
} : null,
|
|
198
|
+
observed_versions: (Array.isArray(snapshot?.observed_versions) ? snapshot.observed_versions : []).filter(version).slice(0, 8),
|
|
199
|
+
client_cache_verified: false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
185
202
|
const history = responseHistoryStats();
|
|
186
203
|
return {
|
|
187
204
|
...adapterHealthPayload(),
|
package/src/responses-bridge.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export function responsesBodyUsesCustomTools(body) {
|
|
|
49
49
|
if (body?.tool_choice?.type === "custom") return true;
|
|
50
50
|
if (Array.isArray(body?.tools) && body.tools.some((tool) => tool?.type === "custom")) return true;
|
|
51
51
|
return Array.isArray(body?.input)
|
|
52
|
-
&& body.input.some((item) =>
|
|
52
|
+
&& body.input.some((item) => ["custom_tool_call", "custom_tool_call_output", "additional_tools", "tool_search_output"].includes(item?.type));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function chatCompatibilityError(kind, itemType) {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { responsesToChat } from "./responses-bridge.mjs";
|
|
7
7
|
import { readResponsesImagePart, readResponsesToolOutputParts } from "./responses-content.mjs";
|
|
8
8
|
import { withChatStreamUsage } from "./stream-contract.mjs";
|
|
9
|
-
import { measureRequestStage } from "./stream-performance.mjs";
|
|
9
|
+
import { markRequestObservation, measureRequestStage } from "./stream-performance.mjs";
|
|
10
10
|
|
|
11
11
|
const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -74,6 +74,7 @@ function checkedChatPayload(chatReq, payload, limit, stage, adapted, assertActiv
|
|
|
74
74
|
stage: stage === "chat" ? stage : `${stage}+chat`,
|
|
75
75
|
targetBytes: limit,
|
|
76
76
|
}, { assertActive });
|
|
77
|
+
markRequestObservation({ payload_bytes: payload.bodyBytes });
|
|
77
78
|
return { chatReq, ...payload, adapted, stage };
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
CODEX_AUTO_REVIEW_MODEL,
|
|
17
17
|
modelIsResponsesOnly,
|
|
18
18
|
modelSupportsChatCompletions,
|
|
19
|
+
modelContextWindowTokens,
|
|
19
20
|
resolveCopilotPriorityTierModel,
|
|
20
21
|
resolveOpenAIModel,
|
|
21
22
|
} from "./models.mjs";
|
|
@@ -35,11 +36,13 @@ import {
|
|
|
35
36
|
import { createRoutePlan } from "./route-plan.mjs";
|
|
36
37
|
import { status } from "./status.mjs";
|
|
37
38
|
import { endStreamWithError } from "./stream-errors.mjs";
|
|
39
|
+
import { createSseKeepalive } from "./sse-keepalive.mjs";
|
|
38
40
|
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
39
41
|
import { recordResponsesUsage } from "./usage.mjs";
|
|
40
42
|
import {
|
|
41
43
|
markResponseErrorOrigin,
|
|
42
44
|
markResponseModel,
|
|
45
|
+
markRequestObservation,
|
|
43
46
|
markResponseTerminal,
|
|
44
47
|
markUpstreamHeaders,
|
|
45
48
|
measureRequestStage,
|
|
@@ -115,6 +118,7 @@ export function createResponsesHandler(options) {
|
|
|
115
118
|
chatCompletionsFn,
|
|
116
119
|
getCachedModelEndpointsFn = getCachedModelEndpoints,
|
|
117
120
|
imagePressure,
|
|
121
|
+
keepaliveIntervalMs,
|
|
118
122
|
modelRegistry,
|
|
119
123
|
openAIModelEnv,
|
|
120
124
|
requestBodyTimeoutMs,
|
|
@@ -283,6 +287,7 @@ export function createResponsesHandler(options) {
|
|
|
283
287
|
}
|
|
284
288
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
285
289
|
markResponseModel(upstreamModel);
|
|
290
|
+
markRequestObservation({ context_window_tokens: modelContextWindowTokens(modelRegistry?.models, upstreamModel) });
|
|
286
291
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
287
292
|
console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${streaming}`));
|
|
288
293
|
const usesCustomTools = responsesBodyUsesCustomTools(prepared.body);
|
|
@@ -313,6 +318,7 @@ export function createResponsesHandler(options) {
|
|
|
313
318
|
releaseRequest: releaseUpstreamPayload,
|
|
314
319
|
responseFailures,
|
|
315
320
|
streamIdleTimeoutMs,
|
|
321
|
+
keepaliveIntervalMs,
|
|
316
322
|
});
|
|
317
323
|
if (result?.successful) imagePressure?.markSuccess?.(responseHistoryPressureRootId(prepared));
|
|
318
324
|
else markAdaptiveHttpTimeout(result?.upstreamStatus);
|
|
@@ -330,12 +336,15 @@ export function createResponsesHandler(options) {
|
|
|
330
336
|
};
|
|
331
337
|
if (streaming) {
|
|
332
338
|
let streamResponseHeaders = null;
|
|
333
|
-
const
|
|
339
|
+
const keepalive = createSseKeepalive({ res, signal: abort.signal, intervalMs: keepaliveIntervalMs });
|
|
340
|
+
let successful;
|
|
341
|
+
try { successful = await forwardToChat(chatReq, async (event, data) => {
|
|
334
342
|
if (!res.headersSent) res.writeHead(200, {
|
|
335
343
|
...streamResponseHeaders,
|
|
336
344
|
"Cache-Control": "no-cache",
|
|
337
345
|
Connection: "keep-alive",
|
|
338
346
|
});
|
|
347
|
+
keepalive.activity();
|
|
339
348
|
const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, { signal: abort.signal });
|
|
340
349
|
if (!written) return false;
|
|
341
350
|
if (event === "response.completed" || event === "response.incomplete") {
|
|
@@ -370,7 +379,7 @@ export function createResponsesHandler(options) {
|
|
|
370
379
|
contentType: "text/event-stream",
|
|
371
380
|
});
|
|
372
381
|
},
|
|
373
|
-
});
|
|
382
|
+
}); } finally { keepalive.stop(); }
|
|
374
383
|
if (successful) imagePressure?.markSuccess?.(responseHistoryPressureRootId(prepared));
|
|
375
384
|
} else {
|
|
376
385
|
try {
|
|
@@ -552,6 +561,7 @@ export function createResponsesCompactHandler(options) {
|
|
|
552
561
|
}
|
|
553
562
|
}
|
|
554
563
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
564
|
+
markRequestObservation({ context_window_tokens: modelContextWindowTokens(modelRegistry?.models, upstreamModel) });
|
|
555
565
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
556
566
|
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
|
|
557
567
|
prepared = applyResponseHistoryRoutePlan(prepared, createRoutePlan({
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "./responses-compaction.mjs";
|
|
31
31
|
import { loadRuntimeConfig } from "./runtime-config.mjs";
|
|
32
32
|
import { endStreamWithError } from "./stream-errors.mjs";
|
|
33
|
+
import { createSseKeepalive } from "./sse-keepalive.mjs";
|
|
33
34
|
import {
|
|
34
35
|
incompleteUpstreamStream,
|
|
35
36
|
invalidUpstreamStream,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
isResponsesOutputEvent,
|
|
41
42
|
markFirstOutput,
|
|
42
43
|
markOutputTokens,
|
|
44
|
+
markRequestObservation,
|
|
43
45
|
markResponseErrorOrigin,
|
|
44
46
|
markResponseTerminal,
|
|
45
47
|
markStreamFailure,
|
|
@@ -123,6 +125,8 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
123
125
|
const changed = normalizeMessageIds(state, event, eventType);
|
|
124
126
|
const outputTokens = event.response?.usage?.output_tokens ?? event.usage?.output_tokens;
|
|
125
127
|
if (outputTokens !== undefined) markOutputTokens(outputTokens);
|
|
128
|
+
const inputTokens = event.response?.usage?.input_tokens ?? event.usage?.input_tokens;
|
|
129
|
+
if (inputTokens !== undefined) markRequestObservation({ input_tokens: inputTokens });
|
|
126
130
|
if (!state.sawOutput && isResponsesOutputEvent(event, eventType)) {
|
|
127
131
|
state.sawOutput = true;
|
|
128
132
|
markFirstOutput();
|
|
@@ -404,18 +408,23 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
|
|
|
404
408
|
let retryDisabledReason = holdPrelude ? null : "request_not_eligible";
|
|
405
409
|
const prelude = [];
|
|
406
410
|
let preludeBytes = 0;
|
|
411
|
+
const keepalive = createSseKeepalive({ res, signal: options.signal,
|
|
412
|
+
intervalMs: options.keepaliveIntervalMs,
|
|
413
|
+
canWrite: () => !holdPrelude && !streamState.sawTerminal });
|
|
407
414
|
const flushPrelude = async () => {
|
|
408
415
|
if (!preludeBytes) return true;
|
|
409
416
|
writeHeaders();
|
|
410
417
|
const body = prelude.length === 1 ? prelude[0] : Buffer.concat(prelude, preludeBytes);
|
|
411
418
|
prelude.length = 0;
|
|
412
419
|
preludeBytes = 0;
|
|
420
|
+
keepalive.activity();
|
|
413
421
|
return writeOrDrain(res, body, { signal: options.signal });
|
|
414
422
|
};
|
|
415
423
|
const forwardFrame = async (frame) => {
|
|
416
424
|
if (!frame) return true;
|
|
417
425
|
if (!holdPrelude) {
|
|
418
426
|
writeHeaders();
|
|
427
|
+
keepalive.activity();
|
|
419
428
|
return writeOrDrain(res, frame, { signal: options.signal });
|
|
420
429
|
}
|
|
421
430
|
prelude.push(frame);
|
|
@@ -502,6 +511,7 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
|
|
|
502
511
|
await endStreamWithError(res, error, options.abort);
|
|
503
512
|
return { successful: false, compacted: false };
|
|
504
513
|
} finally {
|
|
514
|
+
keepalive.stop();
|
|
505
515
|
await reader.cancel().catch(() => {});
|
|
506
516
|
reader.releaseLock();
|
|
507
517
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const SSE_KEEPALIVE_INTERVAL_MS = 15_000;
|
|
2
|
+
|
|
3
|
+
// Downstream-only SSE comments. Never read upstream, alter its deadlines, or
|
|
4
|
+
// write before valid stream headers / between pieces of a partially written frame.
|
|
5
|
+
export function createSseKeepalive({
|
|
6
|
+
res, signal, canWrite = () => true, intervalMs = SSE_KEEPALIVE_INTERVAL_MS,
|
|
7
|
+
now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout,
|
|
8
|
+
} = {}) {
|
|
9
|
+
let timer = null;
|
|
10
|
+
let stopped = false;
|
|
11
|
+
let lastWrite = now();
|
|
12
|
+
const stop = () => {
|
|
13
|
+
stopped = true;
|
|
14
|
+
if (timer !== null) clearTimer(timer);
|
|
15
|
+
timer = null;
|
|
16
|
+
signal?.removeEventListener("abort", stop);
|
|
17
|
+
};
|
|
18
|
+
const schedule = (delay) => {
|
|
19
|
+
timer = setTimer(tick, Math.max(1, delay));
|
|
20
|
+
timer?.unref?.();
|
|
21
|
+
};
|
|
22
|
+
const tick = () => {
|
|
23
|
+
timer = null;
|
|
24
|
+
if (stopped || signal?.aborted || res?.destroyed || res?.writableEnded) return stop();
|
|
25
|
+
const idle = Math.max(0, now() - lastWrite);
|
|
26
|
+
try {
|
|
27
|
+
if (idle >= intervalMs && res?.headersSent && !res.writableNeedDrain && canWrite()) {
|
|
28
|
+
// One small, complete comment is bounded even if this write reaches the
|
|
29
|
+
// high-water mark; no further comments are sent while backpressured.
|
|
30
|
+
res.write(": ccdx keepalive\n\n");
|
|
31
|
+
lastWrite = now();
|
|
32
|
+
}
|
|
33
|
+
} catch { return stop(); }
|
|
34
|
+
const elapsed = Math.max(0, now() - lastWrite);
|
|
35
|
+
schedule(elapsed >= intervalMs ? intervalMs : intervalMs - elapsed);
|
|
36
|
+
};
|
|
37
|
+
if (Number.isFinite(intervalMs) && intervalMs > 0 && !signal?.aborted) {
|
|
38
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
39
|
+
schedule(intervalMs);
|
|
40
|
+
}
|
|
41
|
+
return { activity: () => { lastWrite = now(); }, stop };
|
|
42
|
+
}
|