@bitkyc08/opencodex 2.6.32 → 2.7.1-preview.20260710

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 (61) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +8 -4
  4. package/gui/dist/assets/index-BUAMcKFd.css +1 -0
  5. package/gui/dist/assets/index-KorpEKW8.js +34 -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 +60 -10
  11. package/src/adapters/cursor/effort-map.ts +38 -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/openai-chat.ts +75 -26
  19. package/src/bridge.ts +42 -3
  20. package/src/cli/debug.ts +203 -0
  21. package/src/cli/doctor.ts +11 -0
  22. package/src/cli/help.ts +11 -0
  23. package/src/cli/index.ts +10 -0
  24. package/src/cli/v2.ts +131 -0
  25. package/src/codex/auth-api.ts +7 -3
  26. package/src/codex/catalog.ts +334 -31
  27. package/src/codex/data/upstream-models.json +830 -0
  28. package/src/codex/features.ts +178 -0
  29. package/src/codex/project-config-warnings.ts +388 -0
  30. package/src/codex/sync.ts +8 -0
  31. package/src/codex/warmup.ts +62 -7
  32. package/src/config.ts +7 -5
  33. package/src/lib/debug-log-buffer.ts +42 -0
  34. package/src/lib/debug-settings.ts +84 -0
  35. package/src/lib/debug.ts +18 -9
  36. package/src/lib/errors.ts +104 -1
  37. package/src/oauth/cursor.ts +35 -12
  38. package/src/oauth/store.ts +4 -3
  39. package/src/providers/derive.ts +8 -0
  40. package/src/providers/registry.ts +56 -21
  41. package/src/reasoning-effort.ts +37 -9
  42. package/src/responses/parser.ts +7 -2
  43. package/src/router.ts +5 -0
  44. package/src/server/adapter-resolve.ts +1 -1
  45. package/src/server/index.ts +27 -3
  46. package/src/server/management-api.ts +189 -7
  47. package/src/server/relay.ts +2 -2
  48. package/src/server/request-decompress.ts +8 -2
  49. package/src/server/request-log.ts +78 -0
  50. package/src/server/responses.ts +241 -9
  51. package/src/types.ts +34 -1
  52. package/src/usage/debug.ts +32 -5
  53. package/src/usage/summary.ts +6 -6
  54. package/src/vision/describe.ts +4 -0
  55. package/src/web-search/executor.ts +4 -0
  56. package/src/web-search/format-result.ts +11 -3
  57. package/src/web-search/index.ts +31 -2
  58. package/src/web-search/loop.ts +112 -61
  59. package/src/web-search/parse.ts +4 -1
  60. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  61. package/gui/dist/assets/index-D_JZzI0r.js +0 -15
@@ -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,52 @@ 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, plus an optional reasoning
522
+ // effort the prompt tells the agent to pass to spawn_agent. GET returns the current
523
+ // picks + available models/efforts; PUT sets or clears them.
524
+ if (url.pathname === "/api/injection-model" && req.method === "GET") {
525
+ const models = await fetchAllModels(config);
526
+ const disabled = new Set(config.disabledModels ?? []);
527
+ const { listCatalogNativeSlugs } = await import("../codex/catalog");
528
+ const { CODEX_REASONING_LEVELS } = await import("../reasoning-effort");
529
+ const nativeModels = listCatalogNativeSlugs()
530
+ .filter(slug => !disabled.has(slug))
531
+ .map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
532
+ const routedModels = models
533
+ .map(m => ({ provider: m.provider, model: m.id, namespaced: `${m.provider}/${m.id}` }))
534
+ .filter(m => !disabled.has(m.namespaced));
535
+ return jsonResponse({
536
+ model: config.injectionModel ?? null,
537
+ effort: config.injectionEffort ?? null,
538
+ efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
539
+ available: [...nativeModels, ...routedModels],
540
+ });
541
+ }
542
+ if (url.pathname === "/api/injection-model" && req.method === "PUT") {
543
+ let body: { model?: unknown; effort?: unknown };
544
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
545
+ const { isCodexReasoningEffort } = await import("../reasoning-effort");
546
+ const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
547
+ let effort = config.injectionEffort;
548
+ // `effort` key semantics: absent -> unchanged; null/"" -> clear; ladder value -> set;
549
+ // anything else -> 400. Clearing the model always clears the effort (it is meaningless alone).
550
+ if ("effort" in body) {
551
+ const requestedEffort = typeof body.effort === "string" && body.effort.length > 0 ? body.effort : undefined;
552
+ if (requestedEffort !== undefined && !isCodexReasoningEffort(requestedEffort)) {
553
+ return jsonResponse({ error: `unknown reasoning effort "${requestedEffort}"` }, 400);
554
+ }
555
+ effort = requestedEffort;
556
+ }
557
+ if (!model) effort = undefined;
558
+ if (model) config.injectionModel = model;
559
+ else delete config.injectionModel;
560
+ if (effort) config.injectionEffort = effort;
561
+ else delete config.injectionEffort;
562
+ saveConfig(config);
563
+ return jsonResponse({ ok: true, model: config.injectionModel ?? null, effort: config.injectionEffort ?? null });
564
+ }
565
+
384
566
  // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
385
567
  // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
386
568
  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);
@@ -9,8 +9,14 @@
9
9
  * `content-encoding: zstd` bodies that `req.json()` cannot parse.
10
10
  */
11
11
 
12
- /** Cap decompressed request bodies (a compressed bomb must not inflate unbounded). */
13
- export const MAX_DECOMPRESSED_BODY_BYTES = 64 * 1024 * 1024;
12
+ /**
13
+ * Cap decompressed request bodies (a compressed bomb must not inflate unbounded). Codex compresses
14
+ * EVERY responses request with zstd (no size threshold), and image-heavy histories inflate fast:
15
+ * ~12 full-res screenshots as base64 already cross 64MB decompressed. The proxy is fed by the user's
16
+ * own local Codex over loopback, so the bomb threat is weak; this cap is really an OOM guard. Keep it
17
+ * generous enough that ordinary multi-image sessions decode, while still bounding a runaway body.
18
+ */
19
+ export const MAX_DECOMPRESSED_BODY_BYTES = 256 * 1024 * 1024;
14
20
 
15
21
  export class UnsupportedContentEncodingError extends Error {
16
22
  constructor(readonly encoding: string) {
@@ -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;
@@ -208,6 +218,7 @@ export function inspectResponseLogJson(logCtx: RequestLogContext, text: string):
208
218
  } catch {
209
219
  /* body may not be JSON; request log metadata is best-effort only */
210
220
  }
221
+ captureUpstreamError(logCtx, text);
211
222
  if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
212
223
  logCtx.usageDebugBodyKind = "json";
213
224
  logCtx.usageDebugBodySample = truncateForDebug(text);
@@ -223,6 +234,7 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
223
234
  } catch {
224
235
  /* SSE block payload may not be JSON; metadata inspection is best-effort */
225
236
  }
237
+ captureUpstreamError(logCtx, payload);
226
238
  if (debugEnabled) {
227
239
  if (!sseAlreadyMarked) {
228
240
  logCtx.usageDebugBodyKind = "sse";
@@ -235,10 +247,75 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
235
247
  }
236
248
  }
237
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
+
238
305
  export function httpStatusForTerminalStatus(status: ResponsesTerminalStatus): number {
239
306
  return status === "completed" ? 200 : 502;
240
307
  }
241
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
+
242
319
  export function addFinalRequestLog(
243
320
  requestId: string,
244
321
  start: number,
@@ -276,6 +353,7 @@ export function addFinalRequestLog(
276
353
  ...(errorCode ? { errorCode } : {}),
277
354
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
278
355
  ...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
356
+ ...(logCtx.upstreamError ? { upstreamError: logCtx.upstreamError } : {}),
279
357
  usageStatus,
280
358
  ...(loggedUsage ? { usage: loggedUsage } : {}),
281
359
  ...(totalTokens !== undefined ? { totalTokens } : {}),