@bitkyc08/opencodex 2.6.31-preview.20260707 → 2.7.0

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.
Files changed (59) hide show
  1. package/README.ko.md +19 -3
  2. package/README.md +17 -2
  3. package/README.zh-CN.md +16 -3
  4. package/gui/dist/assets/index-BGdxwydf.js +34 -0
  5. package/gui/dist/assets/index-DANCQ2Jt.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +62 -1
  9. package/src/adapters/cursor/cursor-errors.ts +28 -1
  10. package/src/adapters/cursor/discovery.ts +56 -10
  11. package/src/adapters/cursor/effort-map.ts +35 -7
  12. package/src/adapters/cursor/live-models.ts +3 -0
  13. package/src/adapters/cursor/live-transport.ts +136 -7
  14. package/src/adapters/cursor/protobuf-request.ts +24 -1
  15. package/src/adapters/cursor/request-builder.ts +6 -5
  16. package/src/adapters/cursor/transport-retry.ts +22 -3
  17. package/src/adapters/cursor.ts +2 -1
  18. package/src/adapters/kiro.ts +1 -1
  19. package/src/adapters/openai-chat.ts +75 -26
  20. package/src/bridge.ts +50 -4
  21. package/src/cli/debug.ts +203 -0
  22. package/src/cli/doctor.ts +11 -0
  23. package/src/cli/help.ts +11 -0
  24. package/src/cli/index.ts +10 -0
  25. package/src/cli/v2.ts +131 -0
  26. package/src/codex/account-store.ts +42 -1
  27. package/src/codex/auth-api.ts +43 -0
  28. package/src/codex/catalog.ts +356 -29
  29. package/src/codex/data/upstream-models.json +830 -0
  30. package/src/codex/features.ts +178 -0
  31. package/src/codex/project-config-warnings.ts +388 -0
  32. package/src/codex/sync.ts +8 -0
  33. package/src/codex/warmup.ts +193 -0
  34. package/src/config.ts +7 -5
  35. package/src/lib/debug-log-buffer.ts +42 -0
  36. package/src/lib/debug-settings.ts +84 -0
  37. package/src/lib/debug.ts +18 -9
  38. package/src/lib/errors.ts +104 -1
  39. package/src/oauth/cursor.ts +35 -12
  40. package/src/oauth/store.ts +4 -3
  41. package/src/oauth/token-guardian.ts +32 -7
  42. package/src/providers/derive.ts +8 -0
  43. package/src/providers/kiro-models.ts +3 -3
  44. package/src/providers/registry.ts +77 -56
  45. package/src/reasoning-effort.ts +34 -12
  46. package/src/responses/parser.ts +7 -2
  47. package/src/router.ts +7 -3
  48. package/src/server/adapter-resolve.ts +1 -1
  49. package/src/server/index.ts +27 -3
  50. package/src/server/management-api.ts +168 -7
  51. package/src/server/relay.ts +2 -2
  52. package/src/server/request-log.ts +86 -2
  53. package/src/server/responses.ts +209 -0
  54. package/src/types.ts +38 -2
  55. package/src/usage/debug.ts +32 -5
  56. package/src/usage/summary.ts +6 -6
  57. package/src/web-search/index.ts +1 -1
  58. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  59. package/gui/dist/assets/index-CWujz83O.js +0 -15
@@ -69,6 +69,7 @@ export {
69
69
  addFinalRequestLog,
70
70
  filterRequestLogs,
71
71
  httpStatusForTerminalStatus,
72
+ httpStatusFromTerminalError,
72
73
  nextRequestLogId,
73
74
  requestLogErrorCode,
74
75
  requestLogSpeedLabel,
@@ -157,6 +158,24 @@ export function startServer(port?: number) {
157
158
  config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
158
159
  saveConfig(config);
159
160
  }
161
+ // Sidecar model migration (KST 2026-07-10 06:00 = UTC 2026-07-09 21:00): auto-migrate the old
162
+ // gpt-5.4-mini default to gpt-5.6-luna for both search and vision sidecars. Only touches configs
163
+ // still on the old default — explicit user choices are preserved.
164
+ {
165
+ const SIDECAR_MIGRATION_CUTOFF = Date.UTC(2026, 6, 9, 21, 0); // July 9 21:00 UTC = KST July 10 06:00
166
+ if (Date.now() >= SIDECAR_MIGRATION_CUTOFF) {
167
+ let migrated = false;
168
+ if (config.webSearchSidecar?.model === "gpt-5.4-mini") {
169
+ config.webSearchSidecar = { ...config.webSearchSidecar, model: "gpt-5.6-luna" };
170
+ migrated = true;
171
+ }
172
+ if (config.visionSidecar?.model === "gpt-5.4-mini") {
173
+ config.visionSidecar = { ...config.visionSidecar, model: "gpt-5.6-luna" };
174
+ migrated = true;
175
+ }
176
+ if (migrated) saveConfig(config);
177
+ }
178
+ }
160
179
  invalidateCodexModelsCache();
161
180
 
162
181
  const listenPort = port ?? config.port ?? 10100;
@@ -247,7 +266,7 @@ export function startServer(port?: number) {
247
266
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
248
267
  }
249
268
  const goModels = await fetchAllModels(config);
250
- const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels } = await import("../codex/catalog");
269
+ const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, visibleNativeSlugs } = await import("../codex/catalog");
251
270
  const nativeSlugs = nativeOpenAiSlugs();
252
271
  const goEnabled = filterCatalogVisibleModels(goModels, config);
253
272
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
@@ -255,11 +274,16 @@ export function startServer(port?: number) {
255
274
  // Codex client → Codex catalog shape: native gpt + namespaced routed models,
256
275
  // cloned from a native template so required fields (base_instructions, etc.) are present.
257
276
  // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
258
- return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) }, 200, req, config);
277
+ // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the
278
+ // on-disk sync; codex-rs keeps them out of the picker itself).
279
+ const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
280
+ const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2");
281
+ return jsonResponse({ models: applyNativeVisibility(entries, disabledNativeSlugs(config)) }, 200, req, config);
259
282
  }
260
283
  // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
284
+ // (pure availability list — disabled natives are omitted entirely).
261
285
  const data = [
262
- ...nativeSlugs.map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
286
+ ...visibleNativeSlugs(config).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
263
287
  ...goOrdered.map(m => ({ id: `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
264
288
  ];
265
289
  return jsonResponse({ object: "list", data }, 200, req, config);
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { CatalogModel } from "../codex/catalog";
3
- import { invalidateCodexModelsCache } from "../codex/catalog";
3
+ import { invalidateCodexModelsCache, nativeModelRows } from "../codex/catalog";
4
4
  import {
5
5
  DEFAULT_SUBAGENT_MODELS,
6
6
  codexAutoStartEnabled,
@@ -24,8 +24,17 @@ import { deriveProviderPresets } from "../providers/derive";
24
24
  import { fetchProviderQuotaReports } from "../providers/quota";
25
25
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
26
26
  import { readUsageEntries } from "../usage/log";
27
+ import { getUsageDebugLogEntries } from "../usage/debug";
27
28
  import { parseRange, summarizeUsage } from "../usage/summary";
28
29
  import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
30
+ import { getDebugLogEntries } from "../lib/debug-log-buffer";
31
+ import {
32
+ clearDebugSettings,
33
+ clearDebugSetting,
34
+ getDebugSettings,
35
+ setDebugSettings,
36
+ type DebugFlag,
37
+ } from "../lib/debug-settings";
29
38
  import type { OcxConfig, OcxProviderConfig } from "../types";
30
39
  import { drainAndShutdown } from "./lifecycle";
31
40
  import { filterRequestLogs, getRequestLogEntries } from "./request-log";
@@ -41,6 +50,15 @@ export const VERSION = (() => {
41
50
  }
42
51
  })();
43
52
 
53
+ function parseDebugLogQuery(url: URL): { after: number; limit: number } {
54
+ const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
55
+ const limit = Number(url.searchParams.get("limit") ?? "500");
56
+ return {
57
+ after: Number.isFinite(after) && after > 0 ? after : 0,
58
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, 2000) : 500,
59
+ };
60
+ }
61
+
44
62
  export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
45
63
  if (!isAllowedRequestOrigin(req, config)) {
46
64
  return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
@@ -89,6 +107,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
89
107
  return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
90
108
  }
91
109
 
110
+ if (url.pathname === "/api/diagnostics/project-config" && req.method === "GET") {
111
+ const { getCachedProjectConfigDiagnostics } = await import("../codex/project-config-warnings");
112
+ const { warnings, grouped } = getCachedProjectConfigDiagnostics();
113
+ return jsonResponse({ warnings, grouped });
114
+ }
115
+
92
116
  if (url.pathname === "/api/sync" && req.method === "POST") {
93
117
  const { syncModelsToCodex } = await import("../codex/sync");
94
118
  const result = await syncModelsToCodex(undefined, config, null);
@@ -138,8 +162,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
138
162
  const ws = config.webSearchSidecar ?? {};
139
163
  const vs = config.visionSidecar ?? {};
140
164
  return jsonResponse({
141
- webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
142
- vision: { model: vs.model ?? "gpt-5.4-mini" },
165
+ webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
166
+ vision: { model: vs.model ?? "gpt-5.6-luna" },
143
167
  });
144
168
  }
145
169
 
@@ -160,8 +184,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
160
184
  const vs = config.visionSidecar ?? {};
161
185
  return jsonResponse({
162
186
  ok: true,
163
- webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
164
- vision: { model: vs.model ?? "gpt-5.4-mini" },
187
+ webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
188
+ vision: { model: vs.model ?? "gpt-5.6-luna" },
165
189
  });
166
190
  }
167
191
 
@@ -169,6 +193,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
169
193
  return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
170
194
  }
171
195
 
196
+ if (url.pathname === "/api/debug" && req.method === "GET") {
197
+ return jsonResponse(getDebugSettings());
198
+ }
199
+
200
+ if (url.pathname === "/api/debug/logs" && req.method === "GET") {
201
+ const { after, limit } = parseDebugLogQuery(url);
202
+ return jsonResponse(getDebugLogEntries({ after, limit }));
203
+ }
204
+
205
+ if (url.pathname === "/api/debug/usage-logs" && req.method === "GET") {
206
+ const { after, limit } = parseDebugLogQuery(url);
207
+ return jsonResponse(getUsageDebugLogEntries({ after, limit }));
208
+ }
209
+
210
+ if (url.pathname === "/api/debug" && req.method === "PUT") {
211
+ let body: { debug?: unknown; usage?: unknown; reset?: unknown };
212
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
213
+ if (body.reset === true) return jsonResponse(clearDebugSettings());
214
+ if (body.reset === "debug" || body.reset === "provider") return jsonResponse(clearDebugSetting("debug"));
215
+ if (body.reset === "usage") return jsonResponse(clearDebugSetting("usage"));
216
+ const partial: Partial<Record<DebugFlag, boolean>> = {};
217
+ for (const key of ["debug", "usage"] as const) {
218
+ if (body[key] === undefined) continue;
219
+ if (typeof body[key] !== "boolean") return jsonResponse({ error: `${key} must be a boolean` }, 400);
220
+ partial[key] = body[key];
221
+ }
222
+ if (Object.keys(partial).length === 0) {
223
+ return jsonResponse({ error: "provide debug/usage booleans or reset:true" }, 400);
224
+ }
225
+ return jsonResponse(setDebugSettings(partial));
226
+ }
227
+
172
228
  if (url.pathname === "/api/usage" && req.method === "GET") {
173
229
  const range = parseRange(url.searchParams.get("range"));
174
230
  const now = Date.now();
@@ -284,7 +340,17 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
284
340
  if (url.pathname === "/api/models" && req.method === "GET") {
285
341
  const models = await fetchAllModels(config);
286
342
  const disabled = new Set(config.disabledModels ?? []);
287
- return jsonResponse(models.map(m => {
343
+ // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
344
+ // from the static supported set so a disabled model stays listed and re-enableable.
345
+ const native = nativeModelRows(config).map(row => ({
346
+ provider: "openai",
347
+ id: row.slug,
348
+ namespaced: row.slug,
349
+ disabled: row.disabled,
350
+ native: true,
351
+ ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
352
+ }));
353
+ return jsonResponse([...native, ...models.map(m => {
288
354
  const namespaced = `${m.provider}/${m.id}`;
289
355
  const contextCap = providerContextCap(config, m.provider);
290
356
  return {
@@ -293,7 +359,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
293
359
  disabled: disabled.has(namespaced),
294
360
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
295
361
  };
296
- }));
362
+ })]);
297
363
  }
298
364
 
299
365
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
@@ -365,6 +431,76 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
365
431
  return jsonResponse({ ok: true, disabled });
366
432
  }
367
433
 
434
+ // multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
435
+ // boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
436
+ // the catalog so multi-agent surface metadata stays fresh. The catalog build
437
+ // itself never writes config — this endpoint is the only server-side mutation
438
+ // surface for the flag.
439
+ if (url.pathname === "/api/v2" && req.method === "GET") {
440
+ const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getMaxConcurrentThreads } = await import("../codex/features");
441
+ return jsonResponse({
442
+ enabled: isMultiAgentV2Enabled(),
443
+ agentsMaxThreadsConflict: hasAgentsMaxThreads(),
444
+ maxConcurrentThreadsPerSession: getMaxConcurrentThreads(),
445
+ multiAgentMode: config.multiAgentMode ?? "default",
446
+ });
447
+ }
448
+ if (url.pathname === "/api/v2" && req.method === "PUT") {
449
+ let body: { enabled?: unknown; maxConcurrentThreadsPerSession?: unknown; multiAgentMode?: unknown };
450
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
451
+ const wantsFlag = body.enabled !== undefined;
452
+ const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
453
+ const wantsMode = body.multiAgentMode !== undefined;
454
+ if (!wantsFlag && !wantsThreads && !wantsMode) return jsonResponse({ error: "body must set enabled, multiAgentMode, and/or maxConcurrentThreadsPerSession" }, 400);
455
+ if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
456
+ if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
457
+ return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
458
+ }
459
+ if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
460
+ return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
461
+ }
462
+ const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getMaxConcurrentThreads, setMaxConcurrentThreads } = await import("../codex/features");
463
+ const warnings: string[] = [];
464
+ if (wantsFlag && isMultiAgentV2Enabled() !== body.enabled) {
465
+ const { execFileSync } = await import("node:child_process");
466
+ const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
467
+ try {
468
+ execFileSync(command, ["features", body.enabled ? "enable" : "disable", "multi_agent_v2"],
469
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
470
+ } catch (err) {
471
+ return jsonResponse({ error: `codex features ${body.enabled ? "enable" : "disable"} failed: ${err instanceof Error ? err.message : String(err)}` }, 502);
472
+ }
473
+ await refreshCodexCatalogBestEffort();
474
+ }
475
+ if (wantsThreads) {
476
+ // setMaxConcurrentThreads is idempotent (equal value -> no write) and refuses
477
+ // when the [features.multi_agent_v2] table is missing, so a threads-only PUT
478
+ // against a never-enabled config fails loudly instead of inventing state.
479
+ const result = setMaxConcurrentThreads(body.maxConcurrentThreadsPerSession as number);
480
+ if (!result.ok) return jsonResponse({ error: result.error }, 409);
481
+ if (result.changed) warnings.push("Thread limit applies to new sessions.");
482
+ }
483
+ if (wantsMode) {
484
+ const mode = body.multiAgentMode as "v1" | "default" | "v2";
485
+ if (mode === "default") delete config.multiAgentMode;
486
+ else config.multiAgentMode = mode;
487
+ saveConfig(config);
488
+ await refreshCodexCatalogBestEffort();
489
+ warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
490
+ }
491
+ if ((wantsFlag ? body.enabled === true : isMultiAgentV2Enabled()) && hasAgentsMaxThreads()) {
492
+ warnings.push("[agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled; remove it (features.multi_agent_v2.max_concurrent_threads_per_session replaces it).");
493
+ }
494
+ if (wantsFlag) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
495
+ return jsonResponse({
496
+ ok: true,
497
+ enabled: isMultiAgentV2Enabled(),
498
+ maxConcurrentThreadsPerSession: getMaxConcurrentThreads(),
499
+ multiAgentMode: config.multiAgentMode ?? "default",
500
+ warnings,
501
+ });
502
+ }
503
+
368
504
  // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
369
505
  if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
370
506
  return jsonResponse({ providers: listOAuthProviders() });
@@ -381,6 +517,31 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
381
517
  return jsonResponse({ providers: deriveProviderPresets() });
382
518
  }
383
519
 
520
+ // Subagent prompt injection model: single native or routed model whose info is
521
+ // dynamically injected into the v1 proactive prompt. GET returns the current pick
522
+ // + available models; PUT sets or clears the pick.
523
+ if (url.pathname === "/api/injection-model" && req.method === "GET") {
524
+ const models = await fetchAllModels(config);
525
+ const disabled = new Set(config.disabledModels ?? []);
526
+ const { listCatalogNativeSlugs } = await import("../codex/catalog");
527
+ const nativeModels = listCatalogNativeSlugs()
528
+ .filter(slug => !disabled.has(slug))
529
+ .map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
530
+ const routedModels = models
531
+ .map(m => ({ provider: m.provider, model: m.id, namespaced: `${m.provider}/${m.id}` }))
532
+ .filter(m => !disabled.has(m.namespaced));
533
+ return jsonResponse({ model: config.injectionModel ?? null, available: [...nativeModels, ...routedModels] });
534
+ }
535
+ if (url.pathname === "/api/injection-model" && req.method === "PUT") {
536
+ let body: { model?: unknown };
537
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
538
+ const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
539
+ if (model) config.injectionModel = model;
540
+ else delete config.injectionModel;
541
+ saveConfig(config);
542
+ return jsonResponse({ ok: true, model: config.injectionModel ?? null });
543
+ }
544
+
384
545
  // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
385
546
  // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
386
547
  if (url.pathname === "/api/subagent-models" && req.method === "GET") {
@@ -3,7 +3,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
3
3
  import {
4
4
  addRequestLog,
5
5
  addFinalRequestLog,
6
- httpStatusForTerminalStatus,
6
+ httpStatusForRequestLogTerminal,
7
7
  inspectResponseLogJson,
8
8
  inspectResponseLogSsePayload,
9
9
  type RequestLogContext,
@@ -249,7 +249,7 @@ export function responseWithDeferredRequestLog(
249
249
  status => {
250
250
  if (logged) return;
251
251
  logged = true;
252
- addFinalRequestLog(requestId, start, logCtx, httpStatusForTerminalStatus(status), {
252
+ addFinalRequestLog(requestId, start, logCtx, httpStatusForRequestLogTerminal(status, logCtx), {
253
253
  terminalStatus: status,
254
254
  closeReason: "terminal",
255
255
  }, addLog);
@@ -1,8 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import type { ResponsesTerminalStatus } from "../bridge";
3
+ import { httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError } from "../lib/errors";
3
4
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
4
5
  import { readCodexCatalogPath } from "../codex/catalog";
5
6
  import type { OcxUsage } from "../types";
7
+ import { redactSecretString } from "../lib/redact";
6
8
  import {
7
9
  appendUsageEntry,
8
10
  usageForFinalLog,
@@ -35,6 +37,12 @@ export interface RequestLogContext {
35
37
  usageDebugBodyKind?: UsageDebugBodyKind;
36
38
  usageDebugBodySample?: string;
37
39
  usageDebugContentType?: string;
40
+ /** Secret-redacted upstream error reason (e.g. the granular Cursor "rate limit exceeded…"
41
+ * message) extracted from a `response.failed` SSE payload or non-streaming error body, so the
42
+ * request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */
43
+ upstreamError?: string;
44
+ /** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
45
+ terminalHttpStatus?: number;
38
46
  }
39
47
 
40
48
  export interface RequestLogEntry {
@@ -56,6 +64,8 @@ export interface RequestLogEntry {
56
64
  errorCode?: string;
57
65
  terminalStatus?: ResponsesTerminalStatus;
58
66
  closeReason?: "terminal" | "client_cancel" | "non_stream";
67
+ /** Secret-redacted upstream error reason, surfaced in /api/logs and the GUI detail modal. */
68
+ upstreamError?: string;
59
69
  usageStatus: UsageStatus;
60
70
  usage?: OcxUsage;
61
71
  totalTokens?: number;
@@ -159,12 +169,12 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
159
169
  const raw = usage as {
160
170
  input_tokens?: unknown;
161
171
  output_tokens?: unknown;
162
- input_tokens_details?: { cached_tokens?: unknown };
172
+ input_tokens_details?: { cached_tokens?: unknown; cache_write_tokens?: unknown };
163
173
  output_tokens_details?: { reasoning_tokens?: unknown };
164
174
  total_tokens?: unknown;
165
175
  prompt_tokens?: unknown;
166
176
  completion_tokens?: unknown;
167
- prompt_tokens_details?: { cached_tokens?: unknown };
177
+ prompt_tokens_details?: { cached_tokens?: unknown; cache_write_tokens?: unknown };
168
178
  completion_tokens_details?: { reasoning_tokens?: unknown };
169
179
  };
170
180
  if (typeof raw.input_tokens === "number" && typeof raw.output_tokens === "number") {
@@ -175,6 +185,9 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
175
185
  ...(typeof raw.input_tokens_details?.cached_tokens === "number"
176
186
  ? { cachedInputTokens: raw.input_tokens_details.cached_tokens }
177
187
  : {}),
188
+ ...(typeof raw.input_tokens_details?.cache_write_tokens === "number"
189
+ ? { cacheCreationInputTokens: raw.input_tokens_details.cache_write_tokens }
190
+ : {}),
178
191
  ...(typeof raw.output_tokens_details?.reasoning_tokens === "number"
179
192
  ? { reasoningOutputTokens: raw.output_tokens_details.reasoning_tokens }
180
193
  : {}),
@@ -188,6 +201,9 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
188
201
  ...(typeof raw.prompt_tokens_details?.cached_tokens === "number"
189
202
  ? { cachedInputTokens: raw.prompt_tokens_details.cached_tokens }
190
203
  : {}),
204
+ ...(typeof raw.prompt_tokens_details?.cache_write_tokens === "number"
205
+ ? { cacheCreationInputTokens: raw.prompt_tokens_details.cache_write_tokens }
206
+ : {}),
191
207
  ...(typeof raw.completion_tokens_details?.reasoning_tokens === "number"
192
208
  ? { reasoningOutputTokens: raw.completion_tokens_details.reasoning_tokens }
193
209
  : {}),
@@ -202,6 +218,7 @@ export function inspectResponseLogJson(logCtx: RequestLogContext, text: string):
202
218
  } catch {
203
219
  /* body may not be JSON; request log metadata is best-effort only */
204
220
  }
221
+ captureUpstreamError(logCtx, text);
205
222
  if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
206
223
  logCtx.usageDebugBodyKind = "json";
207
224
  logCtx.usageDebugBodySample = truncateForDebug(text);
@@ -217,6 +234,7 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
217
234
  } catch {
218
235
  /* SSE block payload may not be JSON; metadata inspection is best-effort */
219
236
  }
237
+ captureUpstreamError(logCtx, payload);
220
238
  if (debugEnabled) {
221
239
  if (!sseAlreadyMarked) {
222
240
  logCtx.usageDebugBodyKind = "sse";
@@ -229,10 +247,75 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
229
247
  }
230
248
  }
231
249
 
250
+ /**
251
+ * Capture the upstream error reason into the request log context. Codex/consumer surfaces only
252
+ * see an HTTP-mapped error code (502 → upstream_server_error); the granular reason lives inside
253
+ * a `response.failed` SSE payload's `error.message` (the adapter's redacted upstream message) or
254
+ * a non-streaming JSON error body. We keep the FIRST non-empty reason (the original failure) and
255
+ * run it through redactSecretString so secrets never reach /api/logs. Pure; safe on any text.
256
+ */
257
+ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): void {
258
+ if (!text || logCtx.upstreamError) return;
259
+ try {
260
+ const json = JSON.parse(text) as {
261
+ type?: unknown;
262
+ error?: { message?: unknown };
263
+ last_error?: { message?: unknown };
264
+ response?: { error?: { type?: unknown; code?: unknown; message?: unknown } };
265
+ };
266
+ captureTerminalHttpStatus(logCtx, json);
267
+ const message = json?.error?.message
268
+ ?? json?.last_error?.message
269
+ ?? json?.response?.error?.message;
270
+ if (typeof message === "string" && message.trim()) {
271
+ logCtx.upstreamError = redactSecretString(message).slice(0, 500);
272
+ }
273
+ } catch {
274
+ /* not JSON; nothing to capture */
275
+ }
276
+ }
277
+
278
+ function captureTerminalHttpStatus(
279
+ logCtx: RequestLogContext,
280
+ json: {
281
+ type?: unknown;
282
+ response?: { error?: { type?: unknown; code?: unknown; message?: unknown } };
283
+ },
284
+ ): void {
285
+ if (logCtx.terminalHttpStatus !== undefined) return;
286
+ if (json.type !== "response.failed") return;
287
+ const error = json.response?.error;
288
+ if (!error || typeof error !== "object") return;
289
+ logCtx.terminalHttpStatus = httpStatusFromTerminalError({
290
+ type: typeof error.type === "string" ? error.type : undefined,
291
+ code: error.code === null || typeof error.code === "string" ? error.code : undefined,
292
+ message: typeof error.message === "string" ? error.message : undefined,
293
+ });
294
+ }
295
+
296
+ /** Map a terminal Responses error object to the HTTP status we record in /api/logs. */
297
+ export function httpStatusFromTerminalError(error: {
298
+ type?: string;
299
+ code?: string | null;
300
+ message?: string;
301
+ } | undefined): number {
302
+ return httpStatusFromClassifiedTerminalError(error);
303
+ }
304
+
232
305
  export function httpStatusForTerminalStatus(status: ResponsesTerminalStatus): number {
233
306
  return status === "completed" ? 200 : 502;
234
307
  }
235
308
 
309
+ export function httpStatusForRequestLogTerminal(
310
+ status: ResponsesTerminalStatus,
311
+ logCtx?: RequestLogContext,
312
+ ): number {
313
+ if (status === "failed" && logCtx?.terminalHttpStatus !== undefined) {
314
+ return logCtx.terminalHttpStatus;
315
+ }
316
+ return httpStatusForTerminalStatus(status);
317
+ }
318
+
236
319
  export function addFinalRequestLog(
237
320
  requestId: string,
238
321
  start: number,
@@ -270,6 +353,7 @@ export function addFinalRequestLog(
270
353
  ...(errorCode ? { errorCode } : {}),
271
354
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
272
355
  ...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
356
+ ...(logCtx.upstreamError ? { upstreamError: logCtx.upstreamError } : {}),
273
357
  usageStatus,
274
358
  ...(loggedUsage ? { usage: loggedUsage } : {}),
275
359
  ...(totalTokens !== undefined ? { totalTokens } : {}),