@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -21,24 +21,33 @@ import type { AdapterTierMetadata } from "../providers/fastwire";
21
21
  import { redactSecretString, sanitizeLogMetadataString } from "../lib/redact";
22
22
  import {
23
23
  appendUsageEntry,
24
+ classifyCacheTelemetryProvenance,
24
25
  isKnownAdmissionKind,
26
+ isKnownAffinityMove,
27
+ isKnownAffinityReason,
28
+ isKnownCacheTelemetryProvenance,
25
29
  isKnownInboundProtocol,
26
30
  isKnownTerminalSource,
27
31
  isKnownTransportPhase,
28
32
  isKnownUsageSurface,
29
33
  isCodexUsageAccountLogLabel,
34
+ isLogicalRequestId,
30
35
  isValidReasoningWireValue,
31
36
  normalizeClaudeCompatibilityUsageLog,
37
+ normalizeRequestSpend,
32
38
  readRecentUsageEntries,
33
39
  usageForFinalLog,
34
40
  usageStatusForFinalLog,
35
41
  usageTotalTokens,
36
42
  type AttemptRecoveryKind,
43
+ type CacheTelemetryProvenance,
44
+ type PersistedRequestSpend,
37
45
  type PersistedUsageAttempt,
38
46
  type PersistedUsageEntry,
39
47
  type PersistedClaudeCompatibilityLog,
40
48
  type UsageStatus,
41
49
  } from "../usage/log";
50
+ import type { RequestExecutionBudget } from "../lib/request-execution-budget";
42
51
  import {
43
52
  appendUsageDebug,
44
53
  isUsageDebugEnabled,
@@ -57,6 +66,29 @@ import { modelRecordValue } from "../reasoning-effort";
57
66
  export interface RequestLogContext {
58
67
  model: string;
59
68
  provider: string;
69
+ /**
70
+ * Identity of the ONE logical request this context serves (#4546). Set from the execution
71
+ * budget minted at ingress; a retry leg, a repair refetch and a combo child share it.
72
+ */
73
+ logicalRequestId?: string;
74
+ /**
75
+ * Internal live reference to this request's execution budget; omitted from RequestLogEntry and
76
+ * JSONL. Read at final-log time so the row reports the budget's FINAL state rather than a
77
+ * snapshot taken before the recovery legs that the row is meant to explain.
78
+ */
79
+ executionBudget?: RequestExecutionBudget;
80
+ /**
81
+ * True once usage counts were taken from a response wire rather than reported raw by the
82
+ * adapter. It decides cache provenance: the normalizer writes zero-default token-detail
83
+ * objects, so an all-zero cache detail from a parsed wire is not a measured cache miss.
84
+ */
85
+ usageWireParsed?: boolean;
86
+ /**
87
+ * Every affinity reason recorded for this request, in order. `affinityReason` keeps the final
88
+ * one for the existing row shape; a request that moved twice has two causes and losing the
89
+ * first one loses the more expensive half of the story.
90
+ */
91
+ affinityMoveReasons?: CodexAffinityReason[];
60
92
  /** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
61
93
  firstOutputMs?: number;
62
94
  /** Best-effort chat/session correlation for Logs grouping (#330). Opaque; omit when unknown. */
@@ -143,6 +175,11 @@ export interface RequestLogContext {
143
175
  affinity?: CodexAffinityMove;
144
176
  /** Why the binding was kept, moved, or released (#4546). */
145
177
  affinityReason?: CodexAffinityReason;
178
+ /**
179
+ * Set when this request dropped account-bound continuation because the serving
180
+ * Codex pool account was not the issuer. Never an account identifier.
181
+ */
182
+ conversationStateScrub?: "account-change";
146
183
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
147
184
  terminalSource?: "upstream" | "synthetic";
148
185
  /** Bounded route-decision trace (RI-01); never contains secrets. */
@@ -153,6 +190,8 @@ export interface RequestLogContext {
153
190
 
154
191
  export interface RequestLogEntry {
155
192
  requestId: string;
193
+ /** The logical request this row belongs to (#4546); absent on rows written without a budget. */
194
+ logicalRequestId?: string;
156
195
  timestamp: number;
157
196
  model: string;
158
197
  provider: string;
@@ -206,13 +245,30 @@ export interface RequestLogEntry {
206
245
  usage?: OcxUsage;
207
246
  totalTokens?: number;
208
247
  attempts?: PersistedUsageAttempt[];
248
+ /**
249
+ * Upstream spend for the whole logical request: sends aggregated across attempts and combo
250
+ * children, split into settled and unresolved, with the budget state and move reasons that
251
+ * explain them. Per-attempt `sendCount` stays the accounting source; this is the total.
252
+ */
253
+ spend?: PersistedRequestSpend;
254
+ /** Whether this row's cache detail was observed, synthesized for the wire, or absent. */
255
+ cacheProvenance?: CacheTelemetryProvenance;
209
256
  /** Codex pool affinity decision for this request (diagnostics for #186). */
210
257
  affinity?: CodexAffinityMove;
211
258
  /** Why that decision was made (#4546): a move is the expensive event, so it names its cause. */
212
259
  affinityReason?: CodexAffinityReason;
260
+ /**
261
+ * Set when this request dropped account-bound continuation after a Codex pool
262
+ * account change. Never an account identifier.
263
+ */
264
+ conversationStateScrub?: "account-change";
213
265
  /** Where the upstream terminal/failure was observed. */
214
266
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
215
- /** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */
267
+ /**
268
+ * Whether the HTTP status and message originated upstream or were synthesized by this
269
+ * proxy. Covers SSE tails and pre-stream JSON refusals. Management surfaces this so a
270
+ * local refusal cannot be presented as an upstream reason.
271
+ */
216
272
  terminalSource?: "upstream" | "synthetic";
217
273
  /** Bounded route-decision trace (RI-01); never contains secrets. */
218
274
  routeDecision?: RouteDecisionTraceV1;
@@ -287,8 +343,10 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
287
343
  const closeReason = asCloseReason(entry.closeReason);
288
344
  const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision);
289
345
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
346
+ const spend = normalizeRequestSpend(entry.spend);
290
347
  return {
291
348
  requestId: entry.requestId,
349
+ ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}),
292
350
  timestamp: entry.timestamp,
293
351
  model: entry.model,
294
352
  provider: entry.provider,
@@ -328,10 +386,33 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
328
386
  ...(entry.usage ? { usage: entry.usage } : {}),
329
387
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
330
388
  ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
389
+ ...(spend ? { spend } : {}),
390
+ ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance)
391
+ ? { cacheProvenance: entry.cacheProvenance }
392
+ : {}),
393
+ ...persistedAffinityFields(entry),
331
394
  ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}),
332
395
  ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}),
333
396
  ...(routeDecision ? { routeDecision } : {}),
334
397
  ...(claudeCompatibility ? { claudeCompatibility } : {}),
398
+ ...(entry.conversationStateScrub === "account-change"
399
+ ? { conversationStateScrub: "account-change" }
400
+ : {}),
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Affinity survived only in memory before this: `addFinalRequestLog` set it on the row and the
406
+ * field-by-field disk projection never named it, so the move that discarded a warm prefix was
407
+ * gone at the next restart — the same whitelist trap #4592 hit one layer up.
408
+ */
409
+ function persistedAffinityFields(
410
+ entry: Pick<RequestLogEntry, "affinity" | "affinityReason">,
411
+ ): Pick<PersistedUsageEntry, "affinity" | "affinityReason"> {
412
+ if (!isKnownAffinityMove(entry.affinity)) return {};
413
+ return {
414
+ affinity: entry.affinity,
415
+ ...(isKnownAffinityReason(entry.affinityReason) ? { affinityReason: entry.affinityReason } : {}),
335
416
  };
336
417
  }
337
418
 
@@ -410,6 +491,7 @@ export function addRequestLog(entry: RequestLogEntry) {
410
491
  : {};
411
492
  appendUsageEntry({
412
493
  requestId: entry.requestId,
494
+ ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}),
413
495
  timestamp: entry.timestamp,
414
496
  provider: entry.provider,
415
497
  model: entry.model,
@@ -451,11 +533,19 @@ export function addRequestLog(entry: RequestLogEntry) {
451
533
  ...(entry.usage ? { usage: entry.usage } : {}),
452
534
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
453
535
  ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
536
+ ...(entry.spend ? { spend: entry.spend } : {}),
537
+ ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance)
538
+ ? { cacheProvenance: entry.cacheProvenance }
539
+ : {}),
540
+ ...persistedAffinityFields(entry),
454
541
  ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}),
455
542
  ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}),
456
543
  ...failureDiagnostics,
457
544
  ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
458
545
  ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}),
546
+ ...(entry.conversationStateScrub === "account-change"
547
+ ? { conversationStateScrub: "account-change" }
548
+ : {}),
459
549
  });
460
550
  } catch {
461
551
  /* request logging must never fail a user request */
@@ -684,6 +774,10 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk
684
774
  if (usage && !logCtx.usageFromBridge) {
685
775
  logCtx.usage = usage;
686
776
  if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
777
+ // Counts taken off a wire, not reported raw. The zero-default token-detail objects strict
778
+ // clients require are indistinguishable here from a measured zero, so the cache detail these
779
+ // counts carry is recorded as synthesized rather than as an observed miss.
780
+ logCtx.usageWireParsed = true;
687
781
  }
688
782
  }
689
783
 
@@ -741,6 +835,15 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
741
835
  return undefined;
742
836
  }
743
837
 
838
+ /**
839
+ * Mark a refusal this proxy synthesized locally. Sets origin to `synthetic` and a
840
+ * distinct local reason so the request log cannot be read as an upstream overload.
841
+ */
842
+ export function markLocalRequestLogRefusal(logCtx: RequestLogContext, reason: string): void {
843
+ logCtx.localTerminalReason = reason;
844
+ logCtx.terminalSource = "synthetic";
845
+ }
846
+
744
847
  export function inspectResponseLogJson(logCtx: RequestLogContext, text: string): void {
745
848
  try {
746
849
  applyResponseLogMetadata(logCtx, JSON.parse(text));
@@ -977,6 +1080,133 @@ export function httpStatusForRequestLogTerminal(
977
1080
  return httpStatusForTerminalStatus(status);
978
1081
  }
979
1082
 
1083
+ /**
1084
+ * Aggregate one logical request's upstream spend from the rows that recorded it.
1085
+ *
1086
+ * Attempts are the accounting source and combo children are attempts of the same context, so a
1087
+ * sum over `logCtx.attempts` is the send count for one user turn — the number the amplification
1088
+ * in #4546 is measured in. A terminal status is what makes a send explainable, so the split is
1089
+ * drawn there rather than at success: a 502 is settled spend, an attempt abandoned in flight is
1090
+ * not. The budget's own counter is folded in as `reserved` because a leg that re-sent without
1091
+ * opening an attempt row is charged and unobserved, and that difference belongs in
1092
+ * `unresolved` rather than quietly inflating `settled`.
1093
+ */
1094
+ export function requestSpendRecord(
1095
+ logCtx: Pick<RequestLogContext, "executionBudget" | "affinityMoveReasons" | "affinityReason">,
1096
+ attempts: readonly PersistedUsageAttempt[] | undefined,
1097
+ ): PersistedRequestSpend | undefined {
1098
+ const rows = attempts ?? [];
1099
+ const budget = logCtx.executionBudget;
1100
+ const reasons = [...new Set(
1101
+ (logCtx.affinityMoveReasons ?? (logCtx.affinityReason ? [logCtx.affinityReason] : []))
1102
+ .filter(isKnownAffinityReason),
1103
+ )];
1104
+ if (rows.length === 0 && !budget && reasons.length === 0) return undefined;
1105
+ const sends = rows.reduce((total, attempt) => total + attempt.sendCount, 0);
1106
+ const settled = rows.reduce(
1107
+ (total, attempt) => attempt.status >= 100 ? total + attempt.sendCount : total,
1108
+ 0,
1109
+ );
1110
+ const charged = Math.max(sends, budget?.used ?? 0);
1111
+ return {
1112
+ sends,
1113
+ settled,
1114
+ unresolved: Math.max(0, charged - settled),
1115
+ ...(budget ? { reserved: budget.used, policyVersion: budget.policyVersion } : {}),
1116
+ ...(reasons.length > 0 ? { moveReasons: reasons } : {}),
1117
+ };
1118
+ }
1119
+
1120
+ /**
1121
+ * Record an affinity decision so both the row's final answer and the sequence survive. A request
1122
+ * that moved for `quota_refusal` and then again for `transient` paid for two discarded prefixes,
1123
+ * and the single-valued field can only report the second.
1124
+ */
1125
+ export function noteAffinityMove(
1126
+ logCtx: RequestLogContext,
1127
+ move: CodexAffinityMove,
1128
+ reason: CodexAffinityReason,
1129
+ ): void {
1130
+ logCtx.affinity = move;
1131
+ logCtx.affinityReason = reason;
1132
+ (logCtx.affinityMoveReasons ??= []).push(reason);
1133
+ }
1134
+
1135
+ /**
1136
+ * The affinity scope a released binding belonged to: one thread, one model lane.
1137
+ *
1138
+ * Both halves are part of the key. A thread holds a separate binding per model lane, so a
1139
+ * quota refusal on one lane and a transient streak on another are two releases; keyed by thread
1140
+ * alone the second overwrites the first and one of the two rows reports a cause that never
1141
+ * happened on it.
1142
+ */
1143
+ export interface AffinityModelLane {
1144
+ model: string;
1145
+ /** Thread/conversation that owns the binding; omitted when the caller has no thread identity. */
1146
+ conversationId?: string;
1147
+ }
1148
+
1149
+ /**
1150
+ * Release reasons waiting for the request that can report them (#4546, #4598).
1151
+ *
1152
+ * Bounded like the routing-side map it mirrors: this is a diagnostic, and an unbounded map keyed
1153
+ * by conversation is a leak.
1154
+ */
1155
+ const pendingNoAccountReasons = new Map<string, CodexAffinityReason>();
1156
+ const MAX_PENDING_NO_ACCOUNT_REASONS = 1024;
1157
+
1158
+ function affinityLaneKey(lane: AffinityModelLane): string {
1159
+ return `${lane.conversationId ?? ""}\u0000${lane.model}`;
1160
+ }
1161
+
1162
+ export function noteNoAccountAffinityReason(lane: AffinityModelLane, reason: CodexAffinityReason): void {
1163
+ if (!isKnownAffinityReason(reason)) return;
1164
+ const key = affinityLaneKey(lane);
1165
+ if (!pendingNoAccountReasons.has(key) && pendingNoAccountReasons.size >= MAX_PENDING_NO_ACCOUNT_REASONS) {
1166
+ const oldest = pendingNoAccountReasons.keys().next();
1167
+ if (!oldest.done) pendingNoAccountReasons.delete(oldest.value);
1168
+ }
1169
+ pendingNoAccountReasons.set(key, reason);
1170
+ }
1171
+
1172
+ /** Read and forget one lane's reason. Other lanes on the same thread keep theirs. */
1173
+ export function takeNoAccountAffinityReason(lane: AffinityModelLane): CodexAffinityReason | undefined {
1174
+ const key = affinityLaneKey(lane);
1175
+ const reason = pendingNoAccountReasons.get(key);
1176
+ if (reason !== undefined) pendingNoAccountReasons.delete(key);
1177
+ return reason;
1178
+ }
1179
+
1180
+ /** Test-only process-state reset for isolated harnesses. */
1181
+ export function clearNoAccountAffinityReasonsForTests(): void {
1182
+ pendingNoAccountReasons.clear();
1183
+ }
1184
+
1185
+ /**
1186
+ * Report a selection that produced no account, on the request that failed because of it.
1187
+ *
1188
+ * A no-account resolve reaches no auth context, so until now its cause was handed to whichever
1189
+ * later resolve happened to succeed — and a pool that stays exhausted never produces one, leaving
1190
+ * the failure permanently unexplained. Attaching the reason to THIS request's own record is what
1191
+ * makes the failure self-describing: the row is written, persisted and hydrated like any other,
1192
+ * and it survives a restart.
1193
+ *
1194
+ * Deliberately not a separate synthetic row. `/api/usage` counts one row as one request, so an
1195
+ * extra event row would report a request that never existed and skew the very cost totals this
1196
+ * work exists to make trustworthy.
1197
+ */
1198
+ export function recordNoAccountAffinityFailure(
1199
+ logCtx: RequestLogContext,
1200
+ lane: AffinityModelLane,
1201
+ reason?: CodexAffinityReason,
1202
+ ): CodexAffinityReason | undefined {
1203
+ const resolved = isKnownAffinityReason(reason) ? reason : takeNoAccountAffinityReason(lane);
1204
+ if (resolved === undefined) return undefined;
1205
+ noteAffinityMove(logCtx, "cleared", resolved);
1206
+ logCtx.errorCode ??= "codex_no_account";
1207
+ return resolved;
1208
+ }
1209
+
980
1210
  export function addFinalRequestLog(
981
1211
  requestId: string,
982
1212
  start: number,
@@ -1032,6 +1262,11 @@ export function addFinalRequestLog(
1032
1262
  const loggedUsage = aggregate?.usage ?? existing.usage;
1033
1263
  const usageStatus = aggregate?.status ?? existing.status;
1034
1264
  const totalTokens = aggregate?.totalTokens ?? existing.totalTokens;
1265
+ const spend = requestSpendRecord(logCtx, attempts);
1266
+ const cacheProvenance = classifyCacheTelemetryProvenance(loggedUsage, {
1267
+ wireParsed: logCtx.usageWireParsed === true,
1268
+ });
1269
+ const logicalRequestId = logCtx.logicalRequestId ?? logCtx.executionBudget?.logicalRequestId;
1035
1270
  // Sanitize at the logging layer, not only at the one call site that populates this today.
1036
1271
  // The value originates in an upstream-supplied model id, so an unsanitized newline would
1037
1272
  // let a single field forge a record boundary in any line-oriented log viewer. Doing it here
@@ -1041,6 +1276,7 @@ export function addFinalRequestLog(
1041
1276
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility);
1042
1277
  addLog({
1043
1278
  requestId,
1279
+ ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}),
1044
1280
  timestamp: start,
1045
1281
  model: isCombo ? logCtx.requestedModel! : logCtx.model,
1046
1282
  provider: isCombo ? "combo" : logCtx.provider,
@@ -1084,8 +1320,16 @@ export function addFinalRequestLog(
1084
1320
  ...(loggedUsage ? { usage: loggedUsage } : {}),
1085
1321
  ...(totalTokens !== undefined ? { totalTokens } : {}),
1086
1322
  ...(attempts !== undefined ? { attempts } : {}),
1323
+ ...(spend ? { spend } : {}),
1324
+ // "unknown" is recorded rather than omitted whenever usage exists: a row that reported tokens
1325
+ // with no cache detail at all is a different fact from a row with no usage, and the summary
1326
+ // has to refuse both as a hit-rate denominator.
1327
+ ...(loggedUsage || cacheProvenance !== "unknown" ? { cacheProvenance } : {}),
1087
1328
  ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}),
1088
1329
  ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}),
1330
+ ...(logCtx.conversationStateScrub === "account-change"
1331
+ ? { conversationStateScrub: "account-change" }
1332
+ : {}),
1089
1333
  ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
1090
1334
  ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1091
1335
  ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Codex pool account-change conversation-state portability (#4546).
3
+ *
4
+ * OpenAI `encrypted_content` blobs and `previous_response_id` are bound to the
5
+ * account that minted them. When pool routing serves a live conversation on a
6
+ * different account, the next turn must drop that state once before dispatch so
7
+ * the new account can continue from readable history instead of rejecting the
8
+ * ciphertext.
9
+ *
10
+ * The issuer association lives next to thread affinity in `src/codex/routing.ts`.
11
+ */
12
+
13
+ import type { CodexAuthContext } from "../../codex/auth-context";
14
+ import {
15
+ peekConversationStateIssuer,
16
+ rememberConversationStateIssuer,
17
+ } from "../../codex/routing";
18
+ import type { OcxParsedRequest } from "../../types";
19
+ import type { RequestLogContext } from "../request-log";
20
+
21
+ export type ConversationStateScrubReason = "account-change";
22
+
23
+ export type PortabilityDenial =
24
+ | "previous-response-id"
25
+ | "provider-conversation-id"
26
+ | "uploaded-file-ids"
27
+ | "encrypted-reasoning";
28
+
29
+ /**
30
+ * The parts of a request that bind it to the credential that produced them.
31
+ * Presence is what matters; the values stay opaque so nothing here logs ids.
32
+ */
33
+ export interface ConversationStateCarriers {
34
+ readonly previousResponseId?: string | null;
35
+ readonly providerConversationId?: string | null;
36
+ readonly fileIds?: readonly string[];
37
+ readonly encryptedReasoning?: unknown;
38
+ }
39
+
40
+ export type PortabilityVerdict =
41
+ | { readonly portable: true }
42
+ | { readonly portable: false; readonly reason: PortabilityDenial };
43
+
44
+ function present(value: unknown): boolean {
45
+ if (value === undefined || value === null) return false;
46
+ if (typeof value === "string" || Array.isArray(value)) return value.length > 0;
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Whether a request's conversational state can move credentials at all.
52
+ *
53
+ * `src/routing/identity-domains.ts` owns this decision once that module lands
54
+ * on this integration line (#4546). Keep the check in this one function so it
55
+ * can be swapped for the shared export without hunting call sites.
56
+ */
57
+ export function canPortConversationState(
58
+ state: ConversationStateCarriers,
59
+ ): PortabilityVerdict {
60
+ if (present(state.previousResponseId)) {
61
+ return { portable: false, reason: "previous-response-id" };
62
+ }
63
+ if (present(state.providerConversationId)) {
64
+ return { portable: false, reason: "provider-conversation-id" };
65
+ }
66
+ if (present(state.fileIds)) {
67
+ return { portable: false, reason: "uploaded-file-ids" };
68
+ }
69
+ if (present(state.encryptedReasoning)) {
70
+ return { portable: false, reason: "encrypted-reasoning" };
71
+ }
72
+ return { portable: true };
73
+ }
74
+
75
+ function providerConversationIdFromBody(body: Record<string, unknown>): string | undefined {
76
+ const conversation = body.conversation;
77
+ if (typeof conversation === "string" && conversation.trim()) return conversation.trim();
78
+ if (conversation && typeof conversation === "object" && !Array.isArray(conversation)) {
79
+ const id = (conversation as { id?: unknown }).id;
80
+ if (typeof id === "string" && id.trim()) return id.trim();
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function collectFileIds(input: unknown): string[] {
86
+ const ids: string[] = [];
87
+ if (!Array.isArray(input)) return ids;
88
+ for (const item of input) {
89
+ if (!item || typeof item !== "object") continue;
90
+ const record = item as Record<string, unknown>;
91
+ if (typeof record.file_id === "string" && record.file_id.trim()) ids.push(record.file_id);
92
+ if (Array.isArray(record.file_ids)) {
93
+ for (const id of record.file_ids) {
94
+ if (typeof id === "string" && id.trim()) ids.push(id);
95
+ }
96
+ }
97
+ for (const key of ["content", "output"]) {
98
+ const parts = record[key];
99
+ if (!Array.isArray(parts)) continue;
100
+ for (const part of parts) {
101
+ if (!part || typeof part !== "object") continue;
102
+ const partRecord = part as Record<string, unknown>;
103
+ if (typeof partRecord.file_id === "string" && partRecord.file_id.trim()) {
104
+ ids.push(partRecord.file_id);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return ids;
110
+ }
111
+
112
+ function hasEncryptedReasoning(input: unknown): boolean {
113
+ if (!Array.isArray(input)) return false;
114
+ for (const item of input) {
115
+ if (!item || typeof item !== "object") continue;
116
+ const record = item as Record<string, unknown>;
117
+ if (typeof record.encrypted_content === "string" && record.encrypted_content.length > 0) {
118
+ return true;
119
+ }
120
+ for (const key of ["content", "output"]) {
121
+ const parts = record[key];
122
+ if (!Array.isArray(parts)) continue;
123
+ for (const part of parts) {
124
+ if (!part || typeof part !== "object") continue;
125
+ const encrypted = (part as { encrypted_content?: unknown }).encrypted_content;
126
+ if (typeof encrypted === "string" && encrypted.length > 0) return true;
127
+ }
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
133
+ export function collectConversationStateCarriers(body: unknown): ConversationStateCarriers {
134
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
135
+ const record = body as Record<string, unknown>;
136
+ const previousResponseId = typeof record.previous_response_id === "string"
137
+ ? record.previous_response_id
138
+ : undefined;
139
+ return {
140
+ previousResponseId,
141
+ providerConversationId: providerConversationIdFromBody(record),
142
+ fileIds: collectFileIds(record.input),
143
+ encryptedReasoning: hasEncryptedReasoning(record.input) ? true : undefined,
144
+ };
145
+ }
146
+
147
+
148
+ /**
149
+ * Drop account-bound continuation from a request body in place. Readable user
150
+ * messages and plaintext survive; ciphertext and continuation ids do not.
151
+ */
152
+ export function scrubUnportableConversationStateInPlace(body: unknown): boolean {
153
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
154
+ const record = body as Record<string, unknown>;
155
+ let changed = false;
156
+ if (typeof record.previous_response_id === "string") {
157
+ delete record.previous_response_id;
158
+ changed = true;
159
+ }
160
+ if (record.conversation != null) {
161
+ delete record.conversation;
162
+ changed = true;
163
+ }
164
+ // Encrypted reasoning and compaction ciphertext are deliberately NOT touched here. #2247
165
+ // already strips them when a pooled thread moves accounts, and in a specific shape: the
166
+ // reasoning item keeps its readable summary with an emptied content array, and the compaction
167
+ // item becomes an operator-readable note. Stripping again from this side produced a different
168
+ // shape and broke that contract for no gain. What #2247 does not cover, and what this function
169
+ // owns, is the continuation state naming server-side objects the new account cannot read:
170
+ // `previous_response_id` and a provider-side conversation id.
171
+ return changed;
172
+ }
173
+
174
+ export function conversationStateBindingFromAuth(
175
+ authCtx: CodexAuthContext,
176
+ fallbackAffinityKey?: string | null,
177
+ ): { accountId: string; bindingKey: string } | null {
178
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return null;
179
+ const bindingKey = authCtx.affinityKey ?? fallbackAffinityKey ?? undefined;
180
+ if (!bindingKey || !authCtx.accountId) return null;
181
+ return { accountId: authCtx.accountId, bindingKey };
182
+ }
183
+
184
+ export function rememberServingConversationStateIssuer(
185
+ authCtx: CodexAuthContext,
186
+ fallbackAffinityKey?: string | null,
187
+ ): void {
188
+ const binding = conversationStateBindingFromAuth(authCtx, fallbackAffinityKey);
189
+ if (!binding) return;
190
+ rememberConversationStateIssuer(binding.bindingKey, binding.accountId);
191
+ }
192
+
193
+ export interface ApplyAccountChangeConversationStateScrubArgs {
194
+ body: unknown;
195
+ bindingKey: string;
196
+ servingAccountId: string;
197
+ /** Account this request body was prepared for, when this is an in-request move. */
198
+ priorAccountId?: string | null;
199
+ parsed?: Pick<OcxParsedRequest, "previousResponseId" | "_stripReasoningEncryptedContent">;
200
+ logCtx?: RequestLogContext;
201
+ }
202
+
203
+ /**
204
+ * If the serving account is not the issuer of the carried state, strip that
205
+ * state from the outbound body before dispatch. One cold turn, not a permanent
206
+ * downgrade: the next successful serve records the new issuer.
207
+ */
208
+ export function applyAccountChangeConversationStateScrub(
209
+ args: ApplyAccountChangeConversationStateScrubArgs,
210
+ ): boolean {
211
+ const { body, bindingKey, servingAccountId, priorAccountId, parsed, logCtx } = args;
212
+ if (!servingAccountId || !bindingKey) return false;
213
+ const issuer = peekConversationStateIssuer(bindingKey);
214
+ const accountChanged = (issuer != null && issuer !== servingAccountId)
215
+ || (priorAccountId != null && priorAccountId !== servingAccountId);
216
+ if (!accountChanged) return false;
217
+ if (canPortConversationState(collectConversationStateCarriers(body)).portable) return false;
218
+ const scrubbed = scrubUnportableConversationStateInPlace(body);
219
+ if (!scrubbed) return false;
220
+ if (parsed) {
221
+ delete parsed.previousResponseId;
222
+ parsed._stripReasoningEncryptedContent = true;
223
+ }
224
+ if (logCtx && logCtx.conversationStateScrub !== "account-change") {
225
+ console.warn(
226
+ "[opencodex] dropped continuation state after a Codex pool account change; continuing fresh",
227
+ );
228
+ logCtx.conversationStateScrub = "account-change";
229
+ } else if (logCtx) {
230
+ logCtx.conversationStateScrub = "account-change";
231
+ }
232
+ return true;
233
+ }