@bitkyc08/opencodex 2.56.0 → 2.57.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 (145) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. package/src/web-search/xai-executor.ts +1 -1
@@ -11,7 +11,13 @@ import {
11
11
  type WsData,
12
12
  } from "../ws-bridge";
13
13
  import type { Server, ServerWebSocket } from "bun";
14
- import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "../live";
14
+ import {
15
+ handleLive,
16
+ logLiveSidebandFrame,
17
+ logLiveSidebandStage,
18
+ parseLiveSidebandTarget,
19
+ resolveLiveSidebandUpgrade,
20
+ } from "../live";
15
21
  import { RESPONSE_TTL_MS } from "../../responses/state";
16
22
 
17
23
  export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
@@ -125,8 +131,33 @@ export function sendUpstreamFrame(upstream: WebSocket, frame: string | Buffer):
125
131
  upstream.send(Uint8Array.from(frame));
126
132
  }
127
133
 
134
+ /**
135
+ * Translate the close a downstream client sent into one the upstream socket can carry.
136
+ *
137
+ * The client's code is the only evidence of WHY the call ended, and the upstream needs it:
138
+ * a user hanging up (1001) and a protocol fault (1002/1011) are different events on the
139
+ * account, and collapsing both into a bare 1000 erases that at the proxy. Two bounds make
140
+ * the relay safe anyway. Codes a WebSocket endpoint may never send — 1005 and 1006 are
141
+ * status codes the local runtime synthesizes for "no status" and "abnormal", 1015 is
142
+ * TLS-reserved, and anything outside the registered and private ranges is undefined — become
143
+ * 1000, because `upstream.close` throws on them and a throw here would strand the upstream
144
+ * socket. The reason is truncated to the 123-byte control-frame payload limit by bytes, not
145
+ * characters, so a multibyte reason cannot overrun the frame.
146
+ */
147
+ export function clientCloseForUpstream(code: number, reason?: string): { code: number; reason: string } {
148
+ const sendable = code === 1000
149
+ || code === 1001
150
+ || code === 1003
151
+ || (code >= 1007 && code <= 1011)
152
+ || (code >= 3000 && code <= 4999);
153
+ let text = reason ?? "";
154
+ while (Buffer.byteLength(text) > 123) text = text.slice(0, -1);
155
+ return { code: sendable ? code : 1000, reason: text };
156
+ }
157
+
128
158
  function finalizeLiveSideband(ws: ServerWebSocket<WsData>, upstream?: WebSocket): void {
129
159
  if (upstream && ws.data.liveUpstream !== upstream) return;
160
+ logLiveSidebandStage("relay-closed");
130
161
  if (ws.data.liveCloseFallback !== undefined) {
131
162
  clearTimeout(ws.data.liveCloseFallback);
132
163
  ws.data.liveCloseFallback = undefined;
@@ -281,6 +312,7 @@ export function openLiveSidebandUpstream(
281
312
  try {
282
313
  socket = createWebSocket(url, headers);
283
314
  } catch {
315
+ logLiveSidebandStage("upstream-failed", { status: 502, code: "upstream_error" });
284
316
  resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" });
285
317
  return;
286
318
  }
@@ -297,6 +329,8 @@ export function openLiveSidebandUpstream(
297
329
  settled = true;
298
330
  clearTimeout(timer);
299
331
  removeAbortListener();
332
+ if (result.ok) logLiveSidebandStage("upstream-open");
333
+ else logLiveSidebandStage("upstream-failed", { status: result.status, code: result.code });
300
334
  resolve(result);
301
335
  };
302
336
  const timer = setTimeout(() => {
@@ -505,6 +539,7 @@ export function attachLiveSidebandUpstream(
505
539
  // session, not the connect phase.
506
540
  if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
507
541
  ws.data.liveConnectTimer = undefined;
542
+ logLiveSidebandStage("relay-attached");
508
543
  for (const frame of takeover.frames) {
509
544
  try {
510
545
  // Mirror the live message listener exactly: same ceiling, same diagnostic
@@ -527,6 +562,7 @@ export function attachLiveSidebandUpstream(
527
562
  ws.data.liveOpened = true;
528
563
  if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
529
564
  ws.data.liveConnectTimer = undefined;
565
+ logLiveSidebandStage("relay-attached");
530
566
  // An accepted transport alone does not prove inference/quota recovery.
531
567
  // Keep healthy closes neutral; explicit transport failures are recorded below.
532
568
  const pending = ws.data.livePending ?? [];
@@ -4,6 +4,7 @@ import {
4
4
  MAX_WS_FRAME_BYTES,
5
5
  WEBSOCKET_IDLE_TIMEOUT_SECONDS,
6
6
  attachLiveSidebandUpstream,
7
+ clientCloseForUpstream,
7
8
  closeLiveSideband,
8
9
  closeLiveSidebandBeforeUpgrade,
9
10
  enqueueLiveSidebandPendingFrame,
@@ -317,13 +318,16 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) {
317
318
  }
318
319
  })();
319
320
  },
320
- close(ws: ServerWebSocket<WsData>) {
321
+ close(ws: ServerWebSocket<WsData>, code: number, reason: string) {
321
322
  if (ws.data.kind === "remote-workspace-agent") {
322
323
  ws.data.remoteWorkspaceClose?.();
323
324
  return;
324
325
  }
325
326
  if (ws.data.kind === "live-sideband") {
326
- closeLiveSideband(ws);
327
+ // Carry the client's own close through to the upstream instead of reporting every
328
+ // hang-up as a plain 1000.
329
+ const forwarded = clientCloseForUpstream(code, reason);
330
+ closeLiveSideband(ws, forwarded.code, forwarded.reason);
327
331
  return;
328
332
  }
329
333
  unregisterCodexWebSocket(ws);
@@ -268,11 +268,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
268
268
  startupOwnershipStatePaths,
269
269
  startupWindowsTaskListingCache,
270
270
  );
271
- // Startup cache invalidation is best-effort and must never block the server from
272
- // serving. It now takes K so it cannot race a convergence commit. Use the home
273
- // paired with the ownership inspection; re-reading ambient CODEX_HOME here could
274
- // invalidate a different installation after an environment or mount change.
275
- if (startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) {
271
+ // Startup cache invalidation is best-effort and applies only when startup sync is enabled.
272
+ // Check OFF before taking K: on Windows K resolves SID + LocalAppData through PowerShell,
273
+ // and run 35093667426 exceeded healthy controls by 33.8 s against that 30 s child budget.
274
+ // The permit callback still re-reads intent under K to close a concurrent disable race.
275
+ if (shouldSyncCodexOnStart(config) && startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) {
276
276
  try {
277
277
  const startupCodexHome = startupOwnershipHomes.codexHome;
278
278
  // #1046: record whether this actually rewrote the cache. `handleStart` ORs this
@@ -0,0 +1,107 @@
1
+ /** Read-ahead allowance, not a lifetime limit on SSE inspection or delivery. */
2
+ export const MAX_INSPECTION_READ_AHEAD_BYTES = 32 * 1024 * 1024;
3
+
4
+ export interface InspectionTeeOptions {
5
+ /** Release pacing so the inspection owner's existing bounded drain can run. */
6
+ clientGoneSignal?: AbortSignal;
7
+ /** Internal test seam; callers must not take this value from upstream data. */
8
+ maxReadAheadBytes?: number;
9
+ }
10
+
11
+ /**
12
+ * Keep native tee cancellation semantics while pacing the inspection branch.
13
+ *
14
+ * Inspection may lead raw client consumption by the allowance plus one source
15
+ * chunk (and native tee prefetch). It never stops merely because the whole turn
16
+ * crossed that size: terminal, usage and continuation observers retain ownership.
17
+ * Count raw client bytes BEFORE rewrites, which may shrink, drop or expand them.
18
+ * Push sources still need their own producer-side bound; this is not an RSS cap.
19
+ */
20
+ export function teeWithBoundedInspection(
21
+ source: ReadableStream<Uint8Array>,
22
+ options: InspectionTeeOptions = {},
23
+ ): [ReadableStream<Uint8Array>, ReadableStream<Uint8Array>] {
24
+ const limit = options.maxReadAheadBytes ?? MAX_INSPECTION_READ_AHEAD_BYTES;
25
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
26
+ throw new RangeError("Inspection read-ahead limit must be a positive safe integer");
27
+ }
28
+ const [client, inspection] = source.tee();
29
+ let leadBytes = 0;
30
+ let pacingReleased = false;
31
+ let credit: Promise<void> | undefined;
32
+ let wake: (() => void) | undefined;
33
+
34
+ const wakeReader = () => {
35
+ const resolve = wake;
36
+ wake = undefined;
37
+ credit = undefined;
38
+ resolve?.();
39
+ };
40
+ const releasePacing = () => {
41
+ pacingReleased = true;
42
+ wakeReader();
43
+ };
44
+ const signal = options.clientGoneSignal;
45
+ if (signal?.aborted) releasePacing();
46
+ else signal?.addEventListener("abort", releasePacing, { once: true });
47
+
48
+ const wrap = (
49
+ body: ReadableStream<Uint8Array>,
50
+ isClient: boolean,
51
+ ): ReadableStream<Uint8Array> => {
52
+ const reader = body.getReader();
53
+ let ended = false;
54
+ // An upstream error must wake an inspector waiting for credit, not remain
55
+ // hidden until the client happens to issue another read. One observer per
56
+ // reader, not a permanent Promise.race reaction attached on every chunk.
57
+ void reader.closed.catch(releasePacing);
58
+ const releaseLock = () => {
59
+ try { reader.releaseLock(); } catch { /* a pending read owns the lock */ }
60
+ };
61
+ const finish = () => {
62
+ ended = true;
63
+ releasePacing();
64
+ if (!isClient) signal?.removeEventListener("abort", releasePacing);
65
+ };
66
+ return new ReadableStream<Uint8Array>({
67
+ async pull(controller) {
68
+ if (ended) return;
69
+ if (!isClient && !pacingReleased && leadBytes >= limit) {
70
+ credit ??= new Promise<void>(resolve => { wake = resolve; });
71
+ await credit;
72
+ if (ended) return;
73
+ }
74
+ try {
75
+ const result = await reader.read();
76
+ if (ended) return;
77
+ if (result.done) {
78
+ finish();
79
+ releaseLock();
80
+ controller.close();
81
+ return;
82
+ }
83
+ if (isClient) {
84
+ leadBytes -= result.value.byteLength;
85
+ if (leadBytes < limit) wakeReader();
86
+ } else {
87
+ leadBytes += result.value.byteLength;
88
+ }
89
+ controller.enqueue(result.value);
90
+ } catch (error) {
91
+ if (ended) return;
92
+ finish();
93
+ releaseLock();
94
+ controller.error(error);
95
+ }
96
+ },
97
+ cancel(reason) {
98
+ finish();
99
+ // Awaiting one tee branch's cancellation waits for the sibling. The
100
+ // owner must be free to finish cleanup and cancel/read that sibling.
101
+ void reader.cancel(reason).catch(() => undefined);
102
+ releaseLock();
103
+ },
104
+ }, { highWaterMark: 0 });
105
+ };
106
+ return [wrap(client, true), wrap(inspection, false)];
107
+ }
@@ -72,7 +72,17 @@ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1";
72
72
  * Client protocol headers relayed verbatim to the upstream on call-create and sideband upgrade.
73
73
  * `openai-alpha: quicksilver=v2` carries the Frameless protocol negotiation — without it the
74
74
  * ChatGPT backend validates the type-less Frameless session as v1 quicksilver and 400s
75
- * (openai/codex `realtime_request_headers`, core/src/realtime_conversation.rs). Auth headers
75
+ * (openai/codex `realtime_request_headers`, core/src/realtime_conversation.rs).
76
+ *
77
+ * `x-codex-turn-metadata` is on the same list upstream builds for the sideband upgrade and was
78
+ * missing here, so every realtime turn reached the model with metadata the client had attached
79
+ * and this proxy silently dropped. The Responses passthrough already forwards it
80
+ * (`src/adapters/openai-responses/passthrough.ts`); the sideband goes to the same realtime
81
+ * upstream the caller was addressing, so there is nothing to scope it away from. That is not
82
+ * true of the images sidecar, which strips it deliberately and keeps doing so.
83
+ *
84
+ * Every name here is relayed only when the caller sent it. Nothing on this list is invented,
85
+ * which is what keeps a caller that omits one byte-identical upstream. Auth headers
76
86
  * (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list.
77
87
  */
78
88
  export const LIVE_CLIENT_PROTOCOL_HEADERS = [
@@ -82,6 +92,7 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
82
92
  "thread-id",
83
93
  "originator",
84
94
  "x-oai-attestation",
95
+ "x-codex-turn-metadata",
85
96
  ] as const;
86
97
 
87
98
  /**
@@ -128,6 +139,40 @@ export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
128
139
  }
129
140
  }
130
141
 
142
+ /**
143
+ * Sideband lifecycle stages, recorded in the same JSONL as the frame records.
144
+ *
145
+ * Frame forensics alone cannot separate the three realtime-voice failures reported in #4721.
146
+ * A join that never reached this proxy, a join whose upstream handshake was refused, and a
147
+ * relay that opened and then carried nothing all leave the same empty file, which is why the
148
+ * original report could only say "no frame log". One record per stage makes them distinct:
149
+ * no record at all means the client never dialed the proxy, `upstream-failed` carries the
150
+ * status the client was handed, and `relay-attached` with no following frame record means the
151
+ * transport is live and the silence is upstream of it.
152
+ */
153
+ export type LiveSidebandStage = "upstream-open" | "upstream-failed" | "relay-attached" | "relay-closed";
154
+
155
+ /**
156
+ * Append one lifecycle record. Same privacy rule as the frame records and for the same reason:
157
+ * no URL, no call id, no header, no frame content — only the stage and, on failure, the status
158
+ * and error code this proxy synthesized itself.
159
+ */
160
+ export function logLiveSidebandStage(
161
+ stage: LiveSidebandStage,
162
+ detail?: { status?: number; code?: string },
163
+ ): void {
164
+ const logPath = process.env[LIVE_FRAME_LOG_ENV];
165
+ if (!logPath) return;
166
+ try {
167
+ const record: Record<string, unknown> = { ts: new Date().toISOString(), stage };
168
+ if (detail?.status !== undefined) record.status = detail.status;
169
+ if (detail?.code !== undefined) record.code = detail.code;
170
+ appendFileSync(logPath, JSON.stringify(record) + "\n");
171
+ } catch {
172
+ // Diagnostics must never break the relay.
173
+ }
174
+ }
175
+
131
176
  function clientProtocolHeaders(reqHeaders: Headers): Record<string, string> {
132
177
  const out: Record<string, string> = {};
133
178
  for (const name of LIVE_CLIENT_PROTOCOL_HEADERS) {
@@ -80,17 +80,20 @@ function sparseComboConfig<T extends {
80
80
  waitForCooldownMs?: number;
81
81
  imageInput?: "auto" | "disabled";
82
82
  reasoningEffortMode?: "strict" | "adaptive";
83
- }>(combo: T): Omit<T, "cooldownMs" | "waitForCooldownMs" | "imageInput" | "reasoningEffortMode"> & {
83
+ defaultEffortMode?: "fallback" | "force";
84
+ }>(combo: T): Omit<T, "cooldownMs" | "waitForCooldownMs" | "imageInput" | "reasoningEffortMode" | "defaultEffortMode"> & {
84
85
  cooldownMs?: number;
85
86
  waitForCooldownMs?: number;
86
87
  imageInput?: "disabled";
87
88
  reasoningEffortMode?: "adaptive";
89
+ defaultEffortMode?: "force";
88
90
  } {
89
91
  const {
90
92
  cooldownMs,
91
93
  waitForCooldownMs,
92
94
  imageInput,
93
95
  reasoningEffortMode,
96
+ defaultEffortMode,
94
97
  ...rest
95
98
  } = combo;
96
99
  return {
@@ -101,6 +104,7 @@ function sparseComboConfig<T extends {
101
104
  : {}),
102
105
  ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
103
106
  ...(reasoningEffortMode === "adaptive" ? { reasoningEffortMode: "adaptive" as const } : {}),
107
+ ...(defaultEffortMode === "force" ? { defaultEffortMode: "force" as const } : {}),
104
108
  };
105
109
  }
106
110
 
@@ -170,6 +174,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
170
174
  ...(!Object.hasOwn(requestedCombo, "waitForCooldownMs") && previous?.waitForCooldownMs !== undefined
171
175
  ? { waitForCooldownMs: previous.waitForCooldownMs }
172
176
  : {}),
177
+ // The dashboard does not expose this advanced CLI/API policy. Preserve it when
178
+ // a GUI round-trip omits the field instead of silently downgrading to fallback.
179
+ ...(!Object.hasOwn(requestedCombo, "defaultEffortMode") && previous?.defaultEffortMode !== undefined
180
+ ? { defaultEffortMode: previous.defaultEffortMode }
181
+ : {}),
173
182
  };
174
183
  const error = comboConfigError(id, effectiveCombo, config.providers, {
175
184
  requireEnabledTarget: true,
@@ -466,6 +466,8 @@ export function relaySseEagerBounded(
466
466
  else hooks.onSynthetic(syntheticKind, syntheticReason);
467
467
  }
468
468
  if (cancelled && !hooks.sawTerminal()) {
469
+ // Finalize transport telemetry before the cancellation hook persists its usage row.
470
+ upstream.abort();
469
471
  hooks.onClientCancel();
470
472
  }
471
473
  if (cancelled || upstream.signal.aborted || syntheticKind === "failed" || deliveryFallbackSent) {
@@ -26,6 +26,7 @@ import {
26
26
  MAX_CLIENT_SSE_FRAME_BYTES,
27
27
  } from "./sse-frame-buffer";
28
28
  import { replaceSseDataPayload } from "./sse-payload-rewrite";
29
+ import { createBoundedResponseLogBody } from "./response-log-body";
29
30
 
30
31
  const nativePassthroughSseResponses = new WeakSet<Response>();
31
32
  const eagerRelaySseResponses = new WeakSet<Response>();
@@ -718,25 +719,16 @@ export function responseWithDeferredRequestLog(
718
719
  }
719
720
  if (!response.body || !contentType.includes("text/event-stream")) {
720
721
  if (response.body && (contentType.includes("application/json") || response.status >= 400)) {
721
- const finalizeJsonLog = async () => {
722
- const text = await response.text();
723
- // Non-JSON error bodies: inspect/log only a bounded prefix (the stored
724
- // upstreamError is 500 chars anyway); the FULL text is still forwarded to the
725
- // client below, unchanged. JSON bodies keep full inspection (usage parsing).
726
- const isJson = contentType.includes("application/json");
727
- inspectResponseLogJson(logCtx, isJson ? text : text.slice(0, 8192));
728
- addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
729
- return text;
730
- };
731
- const body = new ReadableStream<Uint8Array>({
732
- async start(controller) {
733
- try {
734
- controller.enqueue(new TextEncoder().encode(await finalizeJsonLog()));
735
- controller.close();
736
- } catch (err) {
737
- addFinalRequestLog(requestId, start, logCtx, 502, { closeReason: "non_stream" }, addLog);
738
- try { controller.error(err); } catch { /* already torn down */ }
739
- }
722
+ const body = createBoundedResponseLogBody(response.body, {
723
+ json: contentType.includes("application/json"),
724
+ inspect: text => inspectResponseLogJson(logCtx, text),
725
+ finalize: reason => {
726
+ // Preserve wire status; request history follows the adjacent SSE
727
+ // convention for a client cancellation or upstream read failure.
728
+ const status = reason === "cancel" ? 499 : reason === "read_error" ? 502 : response.status;
729
+ addFinalRequestLog(requestId, start, logCtx, status, {
730
+ closeReason: reason === "cancel" ? "client_cancel" : "non_stream",
731
+ }, addLog);
740
732
  },
741
733
  });
742
734
  return new Response(body, {
@@ -1338,6 +1330,9 @@ function startBoundedInspectionPump(options: InspectionPumpOptions): void {
1338
1330
  try {
1339
1331
  for (;;) {
1340
1332
  const { done, value } = await reader.read();
1333
+ // Hard cancellation settles a pending read as EOF. Do not flush a
1334
+ // partial terminal after the owner already finalized cancellation.
1335
+ if (cancelled) break;
1341
1336
  if (clientGoneSignal?.aborted) markClientGone();
1342
1337
  if (drainStopped) {
1343
1338
  // stopDrain() cancelled the reader; the settled read is the wake-up.
@@ -1,5 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { stampApiKeyAccountLabel, usesApiKeyAccount } from "../providers/label";
4
+ import { KEY_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
5
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
6
  import type { ResponsesTerminalStatus } from "../bridge";
4
7
  import {
5
8
  classifyError,
@@ -17,6 +20,7 @@ import { readCodexCatalogPath } from "../codex/catalog";
17
20
  import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types";
18
21
  import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
19
22
  import type { AdapterRequest } from "../adapters/base";
23
+ import type { RequestSpendSettlement } from "./responses/request-spend";
20
24
  import type { AdapterTierMetadata } from "../providers/fastwire";
21
25
  import { redactSecretString, sanitizeLogMetadataString } from "../lib/redact";
22
26
  import {
@@ -138,6 +142,15 @@ export interface RequestLogContext {
138
142
  preserveResolvedModelFromRoute?: boolean;
139
143
  usage?: OcxUsage;
140
144
  usageLogInputTokens?: number;
145
+ /**
146
+ * The output ceiling this request may actually spend, for the durable spend reservation
147
+ * (#4707). Captured from the caller's `max_output_tokens`; absent when the caller omitted it
148
+ * and the adapter's own provider/model default decides, in which case only the input estimate
149
+ * is reserved up front and settlement corrects it.
150
+ */
151
+ spendOutputCeilingTokens?: number;
152
+ /** Settles this request's durable spend entries from `addFinalRequestLog`. */
153
+ spendTracker?: RequestSpendSettlement;
141
154
  attempts?: PersistedUsageAttempt[];
142
155
  /** Internal mutable final attempt; omitted from RequestLogEntry/JSONL. */
143
156
  activeAttempt?: PersistedUsageAttempt;
@@ -772,8 +785,10 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk
772
785
  }
773
786
  const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage);
774
787
  if (usage && !logCtx.usageFromBridge) {
775
- logCtx.usage = usage;
776
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
788
+ if (!recordKeyWireAttemptUsage(logCtx, usage)) {
789
+ logCtx.usage = usage;
790
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
791
+ }
777
792
  // Counts taken off a wire, not reported raw. The zero-default token-detail objects strict
778
793
  // clients require are indistinguishable here from a measured zero, so the cache detail these
779
794
  // counts carry is recorded as synthesized rather than as an observed miss.
@@ -1206,6 +1221,31 @@ export function recordNoAccountAffinityFailure(
1206
1221
  logCtx.errorCode ??= "codex_no_account";
1207
1222
  return resolved;
1208
1223
  }
1224
+ // Attempt identity can change in place while a combo parent retains an older context copy.
1225
+ // These objects own their usage even after a rotation to an unknown key identity.
1226
+ const keyUsageOwners = new WeakSet<PersistedUsageAttempt>();
1227
+ const keyWireUsageBaselines = new WeakMap<PersistedUsageAttempt, OcxUsage | undefined>();
1228
+
1229
+ function cloneKeyUsage(usage: OcxUsage | undefined): OcxUsage | undefined {
1230
+ return usage ? { ...usage } : undefined;
1231
+ }
1232
+
1233
+ /** Replace this physical send's wire snapshot against the pre-send baseline; repeats do not sum. */
1234
+ export function recordKeyWireAttemptUsage(logCtx: RequestLogContext, usage: OcxUsage | undefined): boolean {
1235
+ if (!usage) return false;
1236
+ const attempt = logCtx.activeAttempt;
1237
+ if (!attempt || !keyUsageOwners.has(attempt) || !keyWireUsageBaselines.has(attempt)) return false;
1238
+ const baseline = keyWireUsageBaselines.get(attempt);
1239
+ const current = { ...usage };
1240
+ attempt.usage = baseline
1241
+ ? aggregateAttemptUsage([
1242
+ { ...attempt, usage: baseline, usageStatus: baseline.estimated ? "estimated" : "reported" },
1243
+ { ...attempt, usage: current, usageStatus: current.estimated ? "estimated" : "reported" },
1244
+ ]).usage
1245
+ : current;
1246
+ logCtx.usage = attempt.usage;
1247
+ return true;
1248
+ }
1209
1249
 
1210
1250
  export function addFinalRequestLog(
1211
1251
  requestId: string,
@@ -1237,13 +1277,19 @@ export function addFinalRequestLog(
1237
1277
  logCtx.activeAttempt,
1238
1278
  effectiveStatus,
1239
1279
  Date.now() - (logCtx.activeAttemptStartedAt ?? start),
1240
- logCtx.usage,
1280
+ keyUsageOwners.has(logCtx.activeAttempt)
1281
+ ? logCtx.activeAttempt.usage
1282
+ : logCtx.usage,
1241
1283
  );
1242
1284
  // The final row and its active physical attempt describe the same terminal. Preserve the
1243
1285
  // semantic code on both so detailed attempt telemetry cannot regress to a generic status code.
1244
1286
  if (errorCode) logCtx.activeAttempt.errorCode = errorCode;
1245
1287
  else delete logCtx.activeAttempt.errorCode;
1246
1288
  }
1289
+ // The one seam every request passes exactly once, whatever transport served it and however
1290
+ // it ended. The terminal usage belongs to the last send that left; the ledger resolves every
1291
+ // earlier send of this request as unresolved spend rather than handing its tokens back.
1292
+ logCtx.spendTracker?.settle(logCtx.usage);
1247
1293
  const existing = finalizedUsage(
1248
1294
  logCtx.providerAdapter ?? logCtx.provider,
1249
1295
  logCtx.usage,
@@ -1528,6 +1574,84 @@ export function sealRequestAttemptIdentity(
1528
1574
  attempt.provider = provider;
1529
1575
  attempt.adapter = adapter;
1530
1576
  if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
1577
+ else delete attempt.accountLogLabel;
1578
+ }
1579
+
1580
+ /** Preserve metered JSON failures before key recovery consumes/cancels their body. */
1581
+ export async function recordKeyAttemptFailure(logCtx: RequestLogContext, response: Response, signal?: AbortSignal): Promise<void> {
1582
+ const attempt = logCtx.activeAttempt;
1583
+ if (!attempt || !KEY_ACCOUNT_LOG_LABEL_RE.test(attempt.accountLogLabel ?? "")) return;
1584
+ attempt.status = response.status;
1585
+ const cancelOriginal = (): void => { try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ } };
1586
+ signal?.addEventListener("abort", cancelOriginal, { once: true });
1587
+ try {
1588
+ if (signal?.aborted) { cancelOriginal(); return; }
1589
+ const body = await readBoundedResponseBody(response.clone(), { signal, totalTimeoutMs: 1000, inactivityTimeoutMs: 1000 });
1590
+ if (body.truncated || body.oversized) return;
1591
+ const value = JSON.parse(body.text);
1592
+ const usage = usageFromResponsesPayload(value?.usage ?? value?.response?.usage);
1593
+ if (usage) recordKeyWireAttemptUsage(logCtx, usage);
1594
+ } catch { /* Absent/malformed usage remains unknown; recovery still owns the response. */ }
1595
+ finally { signal?.removeEventListener("abort", cancelOriginal); }
1596
+ }
1597
+
1598
+ /** Add raw per-response usage before a bridge combines multiple rounds for the client. */
1599
+ export function recordKeyAttemptUsage(logCtx: RequestLogContext, usage: OcxUsage | undefined): void {
1600
+ const attempt = logCtx.activeAttempt;
1601
+ if (!attempt || !usage) return;
1602
+ attempt.usage = attempt.usage
1603
+ ? aggregateAttemptUsage([{ ...attempt, usageStatus: attempt.usage.estimated ? "estimated" : "reported" },
1604
+ { ...attempt, usage, usageStatus: usage.estimated ? "estimated" : "reported" }]).usage
1605
+ : { ...usage };
1606
+ logCtx.usage = attempt.usage;
1607
+ }
1608
+
1609
+ /** A stable active object lets combo/stream callbacks keep pointing at the final attempt.
1610
+ * Earlier key segments are immutable, flat snapshots inserted before that active object. */
1611
+ export function noteProviderAttemptSend(
1612
+ logCtx: RequestLogContext,
1613
+ providerName: string,
1614
+ provider: OcxProviderConfig,
1615
+ inputTokenEstimate: number | undefined,
1616
+ recovery?: AttemptRecoveryKind,
1617
+ ): void {
1618
+ const attempt = logCtx.activeAttempt;
1619
+ const previous = attempt?.accountLogLabel;
1620
+ stampApiKeyAccountLabel(logCtx, providerName, provider);
1621
+ const next = logCtx.accountLogLabel;
1622
+ if (attempt && usesApiKeyAccount(provider)) keyUsageOwners.add(attempt);
1623
+ if (attempt && attempt.sendCount > 0 && previous !== next
1624
+ && (KEY_ACCOUNT_LOG_LABEL_RE.test(previous ?? "") || KEY_ACCOUNT_LOG_LABEL_RE.test(next ?? ""))) {
1625
+ // An input estimate is not evidence that a failed send used that many tokens.
1626
+ delete attempt.inputTokenEstimate;
1627
+ finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
1628
+ : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502,
1629
+ Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
1630
+ const completed = { ...attempt, recoveryKinds: [...attempt.recoveryKinds],
1631
+ ...(attempt.usage ? { usage: { ...attempt.usage } } : {}),
1632
+ ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}) };
1633
+ const attempts = logCtx.attempts ??= [attempt];
1634
+ const index = attempts.indexOf(attempt);
1635
+ if (index >= 0) attempts.splice(index, 0, completed);
1636
+ else attempts.push(completed, attempt);
1637
+ const fresh = beginRequestAttempt(completed.ordinal + 1, providerName, completed.model, completed.adapter);
1638
+ // Effort/tier metadata describes the request and is captured before the physical send.
1639
+ for (const key of ["requestedEffort", "effectiveEffort", "reasoningWireField", "reasoningWireValue", "tierOutcome"] as const) {
1640
+ if (completed[key] !== undefined) Object.assign(fresh, { [key]: completed[key] });
1641
+ }
1642
+ for (const key of Object.keys(attempt)) delete (attempt as unknown as Record<string, unknown>)[key];
1643
+ Object.assign(attempt, fresh);
1644
+ delete logCtx.usage;
1645
+ logCtx.activeAttemptStartedAt = Date.now();
1646
+ }
1647
+ if (attempt) {
1648
+ sealRequestAttemptIdentity(attempt, logCtx.provider, attempt.adapter, next);
1649
+ recordAttemptCredentialSource(attempt, providerName, provider, attempt.adapter);
1650
+ }
1651
+ noteAttemptSend(attempt, inputTokenEstimate, recovery);
1652
+ if (attempt && keyUsageOwners.has(attempt)) {
1653
+ keyWireUsageBaselines.set(attempt, cloneKeyUsage(attempt.usage));
1654
+ }
1531
1655
  }
1532
1656
 
1533
1657
  /** Capture only the resolved upstream route; inbound auth and today's config cannot label old usage. */