codex-copilot-dx 0.9.13 → 0.9.14

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.
@@ -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 adapter. --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, or file writes.`,
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.`,
@@ -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
- return candidate ? loadCatalog(candidate) : null;
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
  }
@@ -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 (!Array.isArray(body?.tools)) return false;
47
+ if (!body || typeof body !== "object") return false;
48
48
  const removedNames = new Set();
49
- const filtered = body.tools.filter((tool) => {
50
- const removed = isCopilotImageNamespaceTool(tool, options);
51
- if (removed) {
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
- return !removed;
56
- });
57
- if (filtered.length === body.tools.length) return false;
58
- if (filtered.length) body.tools = filtered;
59
- else delete body.tools;
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 choiceName = toolName(body.tool_choice);
62
- const choiceTargetsRemovedTool = choiceName
63
- && removedNames.has(choiceName)
64
- && !filtered.some((tool) => toolName(tool) === choiceName);
65
- if (isCopilotImageNamespaceTool(body.tool_choice, options)
66
- || choiceTargetsRemovedTool
67
- || (body.tool_choice === "required" && !body.tools)) {
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;
@@ -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": "已知模型",
@@ -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();
@@ -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(),
@@ -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) => item?.type === "custom_tool_call" || item?.type === "custom_tool_call_output");
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 successful = await forwardToChat(chatReq, async (event, data) => {
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({
@@ -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
+ }
@@ -159,6 +159,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
159
159
  let headersAt = null;
160
160
  let lastActivityAt = null;
161
161
  let terminalAt = null;
162
+ const context = { payload_bytes: null, images: null, input_tokens: null, context_window_tokens: null };
162
163
  const elapsed = (time) => time === null ? null : Number(Math.max(0, time - requestStartedAt).toFixed(1));
163
164
 
164
165
  return {
@@ -205,6 +206,13 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
205
206
  setModel(value) {
206
207
  model = modelLabel(value);
207
208
  },
209
+ observeContext(values) {
210
+ if (finished || !values || typeof values !== "object") return;
211
+ for (const key of Object.keys(context)) {
212
+ const value = values[key];
213
+ if (Number.isSafeInteger(value) && value >= 0 && (key !== "context_window_tokens" || value > 0)) context[key] = value;
214
+ }
215
+ },
208
216
  setErrorOrigin(value) {
209
217
  if (TERMINAL_ORIGINS.has(value)) errorOrigin = value;
210
218
  },
@@ -242,6 +250,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
242
250
  failed: failed || aborted || (terminalOutcome !== null && terminalOutcome !== "completed"),
243
251
  http_status: Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 ? statusCode : null,
244
252
  upstream_attempts: attempts,
253
+ context: { ...context },
245
254
  timings_ms: {
246
255
  ...Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, Number(preparationMs[stage].toFixed(1))])),
247
256
  upstream_start: elapsed(firstAttemptAt), upstream_headers: elapsed(headersAt),
@@ -272,7 +281,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
272
281
  snapshot() {
273
282
  return {
274
283
  by_route: Object.fromEntries(PERFORMANCE_ROUTES.map((name) => [name, routeSnapshot(routes[name])])),
275
- recent_requests: recentRequests.map((entry) => ({ ...entry, timings_ms: { ...entry.timings_ms } })),
284
+ recent_requests: recentRequests.map((entry) => ({ ...entry, context: { ...entry.context }, timings_ms: { ...entry.timings_ms } })),
276
285
  };
277
286
  },
278
287
  };
@@ -320,6 +329,10 @@ export function markOutputTokens(value) {
320
329
  tracker()?.setOutputTokens(value);
321
330
  }
322
331
 
332
+ export function markRequestObservation(values) {
333
+ tracker()?.observeContext?.(values);
334
+ }
335
+
323
336
  export function markStreamFailure() {
324
337
  tracker()?.fail();
325
338
  }
package/src/usage.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { markRequestObservation } from "./stream-performance.mjs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import readline from "node:readline";
@@ -180,6 +181,8 @@ export function recordUsage(record) {
180
181
  }
181
182
 
182
183
  export function recordResponsesUsage(args) {
184
+ const response = args.response || args.event?.response || args.event;
185
+ markRequestObservation({ input_tokens: response?.usage?.input_tokens });
183
186
  return recordUsage(buildResponsesUsageRecord(args));
184
187
  }
185
188