@bitkyc08/opencodex 2.55.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 (256) 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-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -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,28 +20,38 @@ 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 {
23
27
  appendUsageEntry,
28
+ classifyCacheTelemetryProvenance,
24
29
  isKnownAdmissionKind,
30
+ isKnownAffinityMove,
31
+ isKnownAffinityReason,
32
+ isKnownCacheTelemetryProvenance,
25
33
  isKnownInboundProtocol,
26
34
  isKnownTerminalSource,
27
35
  isKnownTransportPhase,
28
36
  isKnownUsageSurface,
29
37
  isCodexUsageAccountLogLabel,
38
+ isLogicalRequestId,
30
39
  isValidReasoningWireValue,
31
40
  normalizeClaudeCompatibilityUsageLog,
41
+ normalizeRequestSpend,
32
42
  readRecentUsageEntries,
33
43
  usageForFinalLog,
34
44
  usageStatusForFinalLog,
35
45
  usageTotalTokens,
36
46
  type AttemptRecoveryKind,
47
+ type CacheTelemetryProvenance,
48
+ type PersistedRequestSpend,
37
49
  type PersistedUsageAttempt,
38
50
  type PersistedUsageEntry,
39
51
  type PersistedClaudeCompatibilityLog,
40
52
  type UsageStatus,
41
53
  } from "../usage/log";
54
+ import type { RequestExecutionBudget } from "../lib/request-execution-budget";
42
55
  import {
43
56
  appendUsageDebug,
44
57
  isUsageDebugEnabled,
@@ -57,6 +70,29 @@ import { modelRecordValue } from "../reasoning-effort";
57
70
  export interface RequestLogContext {
58
71
  model: string;
59
72
  provider: string;
73
+ /**
74
+ * Identity of the ONE logical request this context serves (#4546). Set from the execution
75
+ * budget minted at ingress; a retry leg, a repair refetch and a combo child share it.
76
+ */
77
+ logicalRequestId?: string;
78
+ /**
79
+ * Internal live reference to this request's execution budget; omitted from RequestLogEntry and
80
+ * JSONL. Read at final-log time so the row reports the budget's FINAL state rather than a
81
+ * snapshot taken before the recovery legs that the row is meant to explain.
82
+ */
83
+ executionBudget?: RequestExecutionBudget;
84
+ /**
85
+ * True once usage counts were taken from a response wire rather than reported raw by the
86
+ * adapter. It decides cache provenance: the normalizer writes zero-default token-detail
87
+ * objects, so an all-zero cache detail from a parsed wire is not a measured cache miss.
88
+ */
89
+ usageWireParsed?: boolean;
90
+ /**
91
+ * Every affinity reason recorded for this request, in order. `affinityReason` keeps the final
92
+ * one for the existing row shape; a request that moved twice has two causes and losing the
93
+ * first one loses the more expensive half of the story.
94
+ */
95
+ affinityMoveReasons?: CodexAffinityReason[];
60
96
  /** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
61
97
  firstOutputMs?: number;
62
98
  /** Best-effort chat/session correlation for Logs grouping (#330). Opaque; omit when unknown. */
@@ -106,6 +142,15 @@ export interface RequestLogContext {
106
142
  preserveResolvedModelFromRoute?: boolean;
107
143
  usage?: OcxUsage;
108
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;
109
154
  attempts?: PersistedUsageAttempt[];
110
155
  /** Internal mutable final attempt; omitted from RequestLogEntry/JSONL. */
111
156
  activeAttempt?: PersistedUsageAttempt;
@@ -143,6 +188,11 @@ export interface RequestLogContext {
143
188
  affinity?: CodexAffinityMove;
144
189
  /** Why the binding was kept, moved, or released (#4546). */
145
190
  affinityReason?: CodexAffinityReason;
191
+ /**
192
+ * Set when this request dropped account-bound continuation because the serving
193
+ * Codex pool account was not the issuer. Never an account identifier.
194
+ */
195
+ conversationStateScrub?: "account-change";
146
196
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
147
197
  terminalSource?: "upstream" | "synthetic";
148
198
  /** Bounded route-decision trace (RI-01); never contains secrets. */
@@ -153,6 +203,8 @@ export interface RequestLogContext {
153
203
 
154
204
  export interface RequestLogEntry {
155
205
  requestId: string;
206
+ /** The logical request this row belongs to (#4546); absent on rows written without a budget. */
207
+ logicalRequestId?: string;
156
208
  timestamp: number;
157
209
  model: string;
158
210
  provider: string;
@@ -206,13 +258,30 @@ export interface RequestLogEntry {
206
258
  usage?: OcxUsage;
207
259
  totalTokens?: number;
208
260
  attempts?: PersistedUsageAttempt[];
261
+ /**
262
+ * Upstream spend for the whole logical request: sends aggregated across attempts and combo
263
+ * children, split into settled and unresolved, with the budget state and move reasons that
264
+ * explain them. Per-attempt `sendCount` stays the accounting source; this is the total.
265
+ */
266
+ spend?: PersistedRequestSpend;
267
+ /** Whether this row's cache detail was observed, synthesized for the wire, or absent. */
268
+ cacheProvenance?: CacheTelemetryProvenance;
209
269
  /** Codex pool affinity decision for this request (diagnostics for #186). */
210
270
  affinity?: CodexAffinityMove;
211
271
  /** Why that decision was made (#4546): a move is the expensive event, so it names its cause. */
212
272
  affinityReason?: CodexAffinityReason;
273
+ /**
274
+ * Set when this request dropped account-bound continuation after a Codex pool
275
+ * account change. Never an account identifier.
276
+ */
277
+ conversationStateScrub?: "account-change";
213
278
  /** Where the upstream terminal/failure was observed. */
214
279
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
215
- /** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */
280
+ /**
281
+ * Whether the HTTP status and message originated upstream or were synthesized by this
282
+ * proxy. Covers SSE tails and pre-stream JSON refusals. Management surfaces this so a
283
+ * local refusal cannot be presented as an upstream reason.
284
+ */
216
285
  terminalSource?: "upstream" | "synthetic";
217
286
  /** Bounded route-decision trace (RI-01); never contains secrets. */
218
287
  routeDecision?: RouteDecisionTraceV1;
@@ -287,8 +356,10 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
287
356
  const closeReason = asCloseReason(entry.closeReason);
288
357
  const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision);
289
358
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
359
+ const spend = normalizeRequestSpend(entry.spend);
290
360
  return {
291
361
  requestId: entry.requestId,
362
+ ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}),
292
363
  timestamp: entry.timestamp,
293
364
  model: entry.model,
294
365
  provider: entry.provider,
@@ -328,10 +399,33 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
328
399
  ...(entry.usage ? { usage: entry.usage } : {}),
329
400
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
330
401
  ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
402
+ ...(spend ? { spend } : {}),
403
+ ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance)
404
+ ? { cacheProvenance: entry.cacheProvenance }
405
+ : {}),
406
+ ...persistedAffinityFields(entry),
331
407
  ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}),
332
408
  ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}),
333
409
  ...(routeDecision ? { routeDecision } : {}),
334
410
  ...(claudeCompatibility ? { claudeCompatibility } : {}),
411
+ ...(entry.conversationStateScrub === "account-change"
412
+ ? { conversationStateScrub: "account-change" }
413
+ : {}),
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Affinity survived only in memory before this: `addFinalRequestLog` set it on the row and the
419
+ * field-by-field disk projection never named it, so the move that discarded a warm prefix was
420
+ * gone at the next restart — the same whitelist trap #4592 hit one layer up.
421
+ */
422
+ function persistedAffinityFields(
423
+ entry: Pick<RequestLogEntry, "affinity" | "affinityReason">,
424
+ ): Pick<PersistedUsageEntry, "affinity" | "affinityReason"> {
425
+ if (!isKnownAffinityMove(entry.affinity)) return {};
426
+ return {
427
+ affinity: entry.affinity,
428
+ ...(isKnownAffinityReason(entry.affinityReason) ? { affinityReason: entry.affinityReason } : {}),
335
429
  };
336
430
  }
337
431
 
@@ -410,6 +504,7 @@ export function addRequestLog(entry: RequestLogEntry) {
410
504
  : {};
411
505
  appendUsageEntry({
412
506
  requestId: entry.requestId,
507
+ ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}),
413
508
  timestamp: entry.timestamp,
414
509
  provider: entry.provider,
415
510
  model: entry.model,
@@ -451,11 +546,19 @@ export function addRequestLog(entry: RequestLogEntry) {
451
546
  ...(entry.usage ? { usage: entry.usage } : {}),
452
547
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
453
548
  ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
549
+ ...(entry.spend ? { spend: entry.spend } : {}),
550
+ ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance)
551
+ ? { cacheProvenance: entry.cacheProvenance }
552
+ : {}),
553
+ ...persistedAffinityFields(entry),
454
554
  ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}),
455
555
  ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}),
456
556
  ...failureDiagnostics,
457
557
  ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
458
558
  ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}),
559
+ ...(entry.conversationStateScrub === "account-change"
560
+ ? { conversationStateScrub: "account-change" }
561
+ : {}),
459
562
  });
460
563
  } catch {
461
564
  /* request logging must never fail a user request */
@@ -682,8 +785,14 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk
682
785
  }
683
786
  const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage);
684
787
  if (usage && !logCtx.usageFromBridge) {
685
- logCtx.usage = usage;
686
- 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
+ }
792
+ // Counts taken off a wire, not reported raw. The zero-default token-detail objects strict
793
+ // clients require are indistinguishable here from a measured zero, so the cache detail these
794
+ // counts carry is recorded as synthesized rather than as an observed miss.
795
+ logCtx.usageWireParsed = true;
687
796
  }
688
797
  }
689
798
 
@@ -741,6 +850,15 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
741
850
  return undefined;
742
851
  }
743
852
 
853
+ /**
854
+ * Mark a refusal this proxy synthesized locally. Sets origin to `synthetic` and a
855
+ * distinct local reason so the request log cannot be read as an upstream overload.
856
+ */
857
+ export function markLocalRequestLogRefusal(logCtx: RequestLogContext, reason: string): void {
858
+ logCtx.localTerminalReason = reason;
859
+ logCtx.terminalSource = "synthetic";
860
+ }
861
+
744
862
  export function inspectResponseLogJson(logCtx: RequestLogContext, text: string): void {
745
863
  try {
746
864
  applyResponseLogMetadata(logCtx, JSON.parse(text));
@@ -977,6 +1095,158 @@ export function httpStatusForRequestLogTerminal(
977
1095
  return httpStatusForTerminalStatus(status);
978
1096
  }
979
1097
 
1098
+ /**
1099
+ * Aggregate one logical request's upstream spend from the rows that recorded it.
1100
+ *
1101
+ * Attempts are the accounting source and combo children are attempts of the same context, so a
1102
+ * sum over `logCtx.attempts` is the send count for one user turn — the number the amplification
1103
+ * in #4546 is measured in. A terminal status is what makes a send explainable, so the split is
1104
+ * drawn there rather than at success: a 502 is settled spend, an attempt abandoned in flight is
1105
+ * not. The budget's own counter is folded in as `reserved` because a leg that re-sent without
1106
+ * opening an attempt row is charged and unobserved, and that difference belongs in
1107
+ * `unresolved` rather than quietly inflating `settled`.
1108
+ */
1109
+ export function requestSpendRecord(
1110
+ logCtx: Pick<RequestLogContext, "executionBudget" | "affinityMoveReasons" | "affinityReason">,
1111
+ attempts: readonly PersistedUsageAttempt[] | undefined,
1112
+ ): PersistedRequestSpend | undefined {
1113
+ const rows = attempts ?? [];
1114
+ const budget = logCtx.executionBudget;
1115
+ const reasons = [...new Set(
1116
+ (logCtx.affinityMoveReasons ?? (logCtx.affinityReason ? [logCtx.affinityReason] : []))
1117
+ .filter(isKnownAffinityReason),
1118
+ )];
1119
+ if (rows.length === 0 && !budget && reasons.length === 0) return undefined;
1120
+ const sends = rows.reduce((total, attempt) => total + attempt.sendCount, 0);
1121
+ const settled = rows.reduce(
1122
+ (total, attempt) => attempt.status >= 100 ? total + attempt.sendCount : total,
1123
+ 0,
1124
+ );
1125
+ const charged = Math.max(sends, budget?.used ?? 0);
1126
+ return {
1127
+ sends,
1128
+ settled,
1129
+ unresolved: Math.max(0, charged - settled),
1130
+ ...(budget ? { reserved: budget.used, policyVersion: budget.policyVersion } : {}),
1131
+ ...(reasons.length > 0 ? { moveReasons: reasons } : {}),
1132
+ };
1133
+ }
1134
+
1135
+ /**
1136
+ * Record an affinity decision so both the row's final answer and the sequence survive. A request
1137
+ * that moved for `quota_refusal` and then again for `transient` paid for two discarded prefixes,
1138
+ * and the single-valued field can only report the second.
1139
+ */
1140
+ export function noteAffinityMove(
1141
+ logCtx: RequestLogContext,
1142
+ move: CodexAffinityMove,
1143
+ reason: CodexAffinityReason,
1144
+ ): void {
1145
+ logCtx.affinity = move;
1146
+ logCtx.affinityReason = reason;
1147
+ (logCtx.affinityMoveReasons ??= []).push(reason);
1148
+ }
1149
+
1150
+ /**
1151
+ * The affinity scope a released binding belonged to: one thread, one model lane.
1152
+ *
1153
+ * Both halves are part of the key. A thread holds a separate binding per model lane, so a
1154
+ * quota refusal on one lane and a transient streak on another are two releases; keyed by thread
1155
+ * alone the second overwrites the first and one of the two rows reports a cause that never
1156
+ * happened on it.
1157
+ */
1158
+ export interface AffinityModelLane {
1159
+ model: string;
1160
+ /** Thread/conversation that owns the binding; omitted when the caller has no thread identity. */
1161
+ conversationId?: string;
1162
+ }
1163
+
1164
+ /**
1165
+ * Release reasons waiting for the request that can report them (#4546, #4598).
1166
+ *
1167
+ * Bounded like the routing-side map it mirrors: this is a diagnostic, and an unbounded map keyed
1168
+ * by conversation is a leak.
1169
+ */
1170
+ const pendingNoAccountReasons = new Map<string, CodexAffinityReason>();
1171
+ const MAX_PENDING_NO_ACCOUNT_REASONS = 1024;
1172
+
1173
+ function affinityLaneKey(lane: AffinityModelLane): string {
1174
+ return `${lane.conversationId ?? ""}\u0000${lane.model}`;
1175
+ }
1176
+
1177
+ export function noteNoAccountAffinityReason(lane: AffinityModelLane, reason: CodexAffinityReason): void {
1178
+ if (!isKnownAffinityReason(reason)) return;
1179
+ const key = affinityLaneKey(lane);
1180
+ if (!pendingNoAccountReasons.has(key) && pendingNoAccountReasons.size >= MAX_PENDING_NO_ACCOUNT_REASONS) {
1181
+ const oldest = pendingNoAccountReasons.keys().next();
1182
+ if (!oldest.done) pendingNoAccountReasons.delete(oldest.value);
1183
+ }
1184
+ pendingNoAccountReasons.set(key, reason);
1185
+ }
1186
+
1187
+ /** Read and forget one lane's reason. Other lanes on the same thread keep theirs. */
1188
+ export function takeNoAccountAffinityReason(lane: AffinityModelLane): CodexAffinityReason | undefined {
1189
+ const key = affinityLaneKey(lane);
1190
+ const reason = pendingNoAccountReasons.get(key);
1191
+ if (reason !== undefined) pendingNoAccountReasons.delete(key);
1192
+ return reason;
1193
+ }
1194
+
1195
+ /** Test-only process-state reset for isolated harnesses. */
1196
+ export function clearNoAccountAffinityReasonsForTests(): void {
1197
+ pendingNoAccountReasons.clear();
1198
+ }
1199
+
1200
+ /**
1201
+ * Report a selection that produced no account, on the request that failed because of it.
1202
+ *
1203
+ * A no-account resolve reaches no auth context, so until now its cause was handed to whichever
1204
+ * later resolve happened to succeed — and a pool that stays exhausted never produces one, leaving
1205
+ * the failure permanently unexplained. Attaching the reason to THIS request's own record is what
1206
+ * makes the failure self-describing: the row is written, persisted and hydrated like any other,
1207
+ * and it survives a restart.
1208
+ *
1209
+ * Deliberately not a separate synthetic row. `/api/usage` counts one row as one request, so an
1210
+ * extra event row would report a request that never existed and skew the very cost totals this
1211
+ * work exists to make trustworthy.
1212
+ */
1213
+ export function recordNoAccountAffinityFailure(
1214
+ logCtx: RequestLogContext,
1215
+ lane: AffinityModelLane,
1216
+ reason?: CodexAffinityReason,
1217
+ ): CodexAffinityReason | undefined {
1218
+ const resolved = isKnownAffinityReason(reason) ? reason : takeNoAccountAffinityReason(lane);
1219
+ if (resolved === undefined) return undefined;
1220
+ noteAffinityMove(logCtx, "cleared", resolved);
1221
+ logCtx.errorCode ??= "codex_no_account";
1222
+ return resolved;
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
+ }
1249
+
980
1250
  export function addFinalRequestLog(
981
1251
  requestId: string,
982
1252
  start: number,
@@ -1007,13 +1277,19 @@ export function addFinalRequestLog(
1007
1277
  logCtx.activeAttempt,
1008
1278
  effectiveStatus,
1009
1279
  Date.now() - (logCtx.activeAttemptStartedAt ?? start),
1010
- logCtx.usage,
1280
+ keyUsageOwners.has(logCtx.activeAttempt)
1281
+ ? logCtx.activeAttempt.usage
1282
+ : logCtx.usage,
1011
1283
  );
1012
1284
  // The final row and its active physical attempt describe the same terminal. Preserve the
1013
1285
  // semantic code on both so detailed attempt telemetry cannot regress to a generic status code.
1014
1286
  if (errorCode) logCtx.activeAttempt.errorCode = errorCode;
1015
1287
  else delete logCtx.activeAttempt.errorCode;
1016
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);
1017
1293
  const existing = finalizedUsage(
1018
1294
  logCtx.providerAdapter ?? logCtx.provider,
1019
1295
  logCtx.usage,
@@ -1032,6 +1308,11 @@ export function addFinalRequestLog(
1032
1308
  const loggedUsage = aggregate?.usage ?? existing.usage;
1033
1309
  const usageStatus = aggregate?.status ?? existing.status;
1034
1310
  const totalTokens = aggregate?.totalTokens ?? existing.totalTokens;
1311
+ const spend = requestSpendRecord(logCtx, attempts);
1312
+ const cacheProvenance = classifyCacheTelemetryProvenance(loggedUsage, {
1313
+ wireParsed: logCtx.usageWireParsed === true,
1314
+ });
1315
+ const logicalRequestId = logCtx.logicalRequestId ?? logCtx.executionBudget?.logicalRequestId;
1035
1316
  // Sanitize at the logging layer, not only at the one call site that populates this today.
1036
1317
  // The value originates in an upstream-supplied model id, so an unsanitized newline would
1037
1318
  // let a single field forge a record boundary in any line-oriented log viewer. Doing it here
@@ -1041,6 +1322,7 @@ export function addFinalRequestLog(
1041
1322
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility);
1042
1323
  addLog({
1043
1324
  requestId,
1325
+ ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}),
1044
1326
  timestamp: start,
1045
1327
  model: isCombo ? logCtx.requestedModel! : logCtx.model,
1046
1328
  provider: isCombo ? "combo" : logCtx.provider,
@@ -1084,8 +1366,16 @@ export function addFinalRequestLog(
1084
1366
  ...(loggedUsage ? { usage: loggedUsage } : {}),
1085
1367
  ...(totalTokens !== undefined ? { totalTokens } : {}),
1086
1368
  ...(attempts !== undefined ? { attempts } : {}),
1369
+ ...(spend ? { spend } : {}),
1370
+ // "unknown" is recorded rather than omitted whenever usage exists: a row that reported tokens
1371
+ // with no cache detail at all is a different fact from a row with no usage, and the summary
1372
+ // has to refuse both as a hit-rate denominator.
1373
+ ...(loggedUsage || cacheProvenance !== "unknown" ? { cacheProvenance } : {}),
1087
1374
  ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}),
1088
1375
  ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}),
1376
+ ...(logCtx.conversationStateScrub === "account-change"
1377
+ ? { conversationStateScrub: "account-change" }
1378
+ : {}),
1089
1379
  ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
1090
1380
  ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1091
1381
  ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
@@ -1284,6 +1574,84 @@ export function sealRequestAttemptIdentity(
1284
1574
  attempt.provider = provider;
1285
1575
  attempt.adapter = adapter;
1286
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
+ }
1287
1655
  }
1288
1656
 
1289
1657
  /** Capture only the resolved upstream route; inbound auth and today's config cannot label old usage. */