@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
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Codex V2 conversation lineage: root, parent, child, grandchild (#4546, wp8).
3
+ *
4
+ * The pool affinity key used to prefer `x-codex-parent-thread-id`, which collapsed two different
5
+ * identities into one map. A root bound under `app:HMAC(session, thread)` while every child bound
6
+ * under the RAW parent id, so siblings shared one binding entry unrelated to the root's, and a
7
+ * grandchild keyed on its own parent landed on a key nobody had ever bound. The proxy therefore
8
+ * treated one workflow as unrelated strangers even while the provider saw a single prompt-cache
9
+ * family.
10
+ *
11
+ * This module records the real relation -- each thread's own conversation key, its immediate
12
+ * parent's thread id, and the transitive root -- scoped per authenticated caller, bounded, and
13
+ * process-local like the binding map it feeds.
14
+ *
15
+ * What it is for, and what it is not for:
16
+ *
17
+ * - FIRST PLACEMENT. A child with no binding of its own may start where its family is already
18
+ * warm; see `pickLineageServingAccount` in ./routing. Once bound, the child is an ordinary
19
+ * binding, so a later move of the parent does not drag it.
20
+ * - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
21
+ * answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
22
+ * onto the root. No budget is implemented here.
23
+ * - WORKER CLASSIFICATION, exposed but not rewired. Admission classifies header-only today: a
24
+ * request naming a parent plus a distinct `thread-id` is worker traffic, and a request without
25
+ * `thread-id` is interactive even when it belongs to a recorded fan-out.
26
+ * {@link codexLineageWorkflowLane} is the lineage-backed answer a later lane consumes.
27
+ *
28
+ * Scope is an HMAC of the caller's Authorization header under a process-local key, the same
29
+ * posture as the affinity key itself. Two callers presenting identical thread ids can never read
30
+ * each other's lineage, and no raw identifier or durable hash is stored.
31
+ *
32
+ * LIFETIME, stated plainly because the word "affinity" invites the opposite assumption: none of
33
+ * this survives the process. The binding map is in memory, and the HMAC key above is fresh random
34
+ * bytes taken at module load, so a restart does not merely forget the table -- it makes yesterday's
35
+ * keys unreproducible. This is a warm-start hint for the life of one proxy process, never durable
36
+ * account ownership, and nothing here should be read as a promise to a conversation that outlives
37
+ * a restart.
38
+ *
39
+ * The one upgrade that is neither a fresh start nor an untouched process is a code swap under a
40
+ * live conversation, where the binding map is still populated with entries made under the
41
+ * pre-#4546 RAW parent key. Silently rebinding those cold is the exact defect this module exists
42
+ * to prevent, so a request that names a parent carries {@link CodexThreadLineage.legacyConversationKey}
43
+ * -- the key the old rule would have returned -- and routing adopts that binding once under the new
44
+ * key and retires the legacy entry. It is a one-way migration, not a second lookup path.
45
+ */
46
+ import { createHmac, randomBytes } from "node:crypto";
47
+ import { retainedUtf8Bytes } from "../lib/admission";
48
+
49
+ const CODEX_LINEAGE_COMPONENT_MAX_BYTES = 512;
50
+ const CODEX_LINEAGE_KEY = randomBytes(32);
51
+
52
+ /**
53
+ * Mirrors `CODEX_THREAD_AFFINITY_IDLE_TTL_MS` in ./routing. Deliberately duplicated rather than
54
+ * imported: lineage is a leaf module, and a value import from the routing module that consumes it
55
+ * would turn an erased type-only edge into a real cycle.
56
+ */
57
+ export const CODEX_LINEAGE_IDLE_TTL_MS = 24 * 60 * 60_000;
58
+ /** Records per authenticated scope, on the order of the binding map's own 2048-entry cap. */
59
+ export const CODEX_LINEAGE_MAX_ENTRIES = 2048;
60
+ /** Distinct authenticated callers retained. Without this the scope map is the unbounded one. */
61
+ export const CODEX_LINEAGE_MAX_SCOPES = 64;
62
+ /** Sibling hints kept per parent, most recently used first. */
63
+ export const CODEX_LINEAGE_MAX_SIBLINGS = 8;
64
+
65
+ const LOCAL_LINEAGE_SCOPE = "local";
66
+
67
+ export type CodexWorkflowLane = "worker" | "interactive";
68
+
69
+ interface CodexLineageRecord {
70
+ threadId: string;
71
+ /** This thread's own pool binding key, byte-identical to what `codexPoolAffinityKey` returns. */
72
+ conversationKey: string;
73
+ /** Immediate parent's raw thread id, retained once seen even if a later turn omits the header. */
74
+ parentThreadId?: string;
75
+ /** Topmost ancestor's conversation key: a grandchild resolves to the root's, not its parent's. */
76
+ rootSessionKey: string;
77
+ lastUsedAt: number;
78
+ }
79
+
80
+ interface CodexLineageScope {
81
+ /** threadId -> record, iterated oldest-first so TTL pruning and eviction stay amortised O(1). */
82
+ records: Map<string, CodexLineageRecord>;
83
+ /** conversationKey -> threadId, so cost attribution is a lookup instead of a scan. */
84
+ threadIdByConversationKey: Map<string, string>;
85
+ /** parentThreadId -> child thread ids, most recent first, capped. */
86
+ childThreadIdsByParent: Map<string, string[]>;
87
+ lastUsedAt: number;
88
+ }
89
+
90
+ /**
91
+ * What placement and cost attribution are allowed to see. `parentConversationKey` is the parent's
92
+ * OWN binding key -- either recorded, or derived from the shared session when the parent has not
93
+ * been seen yet -- never the raw header value the old affinity key returned.
94
+ */
95
+ export interface CodexThreadLineage {
96
+ readonly conversationKey: string;
97
+ readonly rootSessionKey: string;
98
+ readonly parentThreadId?: string;
99
+ readonly parentConversationKey?: string;
100
+ /**
101
+ * The key the pre-#4546 rule would have returned for this request -- the RAW parent id -- when
102
+ * that differs from the key it binds under now. Present so routing can adopt a binding left by
103
+ * the old rule exactly once; see the lifetime note at the top of this file.
104
+ */
105
+ readonly legacyConversationKey?: string;
106
+ /** Siblings under the same declared parent, most recently used first. */
107
+ readonly siblingConversationKeys: readonly string[];
108
+ }
109
+
110
+ /** The identity the pool affinity key and the lineage record are both derived from. */
111
+ export interface CodexConversationIdentity {
112
+ readonly conversationKey: string;
113
+ /** Thread id this request records under: its own, or the parent's on a parent-only request. */
114
+ readonly recordThreadId: string;
115
+ readonly sessionId?: string;
116
+ readonly parentThreadId?: string;
117
+ /** Raw parent id, i.e. the key the pre-#4546 rule returned for this request. */
118
+ readonly legacyConversationKey?: string;
119
+ /** True only when the request names a parent distinct from its own thread. */
120
+ readonly declaresParent: boolean;
121
+ }
122
+
123
+ const lineageByScope = new Map<string, CodexLineageScope>();
124
+
125
+ /** A record is only evidence while it is live; an idle-expired one answers like no record. */
126
+ function liveLineageRecord(
127
+ scope: CodexLineageScope | undefined,
128
+ threadId: string,
129
+ now: number,
130
+ ): CodexLineageRecord | undefined {
131
+ const record = scope?.records.get(threadId);
132
+ return record !== undefined && now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
133
+ ? record
134
+ : undefined;
135
+ }
136
+
137
+ function boundedLineageComponent(value: string | null): string | undefined {
138
+ const normalized = value?.trim();
139
+ if (!normalized) return undefined;
140
+ if (retainedUtf8Bytes(normalized) > CODEX_LINEAGE_COMPONENT_MAX_BYTES) return undefined;
141
+ return normalized;
142
+ }
143
+
144
+ /**
145
+ * The single derivation behind both the pool affinity key and lineage records. Keeping it here,
146
+ * rather than duplicated at the call site, is what guarantees a record's `conversationKey` is
147
+ * byte-identical to the key the thread actually binds under.
148
+ */
149
+ export function codexConversationKeyFor(familyId: string, threadId: string): string {
150
+ return `app:${createHmac("sha256", CODEX_LINEAGE_KEY)
151
+ .update("opencodex-app-pool-affinity-v1\0")
152
+ .update(familyId)
153
+ .update("\0")
154
+ .update(threadId)
155
+ .digest("base64url")}`;
156
+ }
157
+
158
+ /**
159
+ * Resolve a request's conversation identity, or undefined when it carries no bindable thread
160
+ * identity at all.
161
+ *
162
+ * The set of requests that produce NO key is deliberately unchanged from the pre-#4546 rule: a
163
+ * bare `thread-id` with neither a session nor a parent stays unbound, exactly as the Desktop
164
+ * fallback required both halves of its pair. Only the VALUE moves, and only for requests that
165
+ * name a parent:
166
+ *
167
+ * - root (`session-id` + `thread-id`) -> HMAC(session, thread), unchanged;
168
+ * - child (parent + own `thread-id`) -> HMAC(session ?? parent, thread), previously the raw parent
169
+ * id, which is what made siblings share one entry and made a child's first turn land on a key
170
+ * the root had never bound;
171
+ * - parent-only (no `thread-id`) -> the parent's OWN recorded key when this scope has one, and
172
+ * otherwise HMAC(session ?? parent, parent).
173
+ *
174
+ * That last case is the one with a trap in it. A parent-only turn belongs to the parent's
175
+ * conversation, so it has to land on the binding the parent is already using -- but the parent's
176
+ * key is HMAC(session, thread), and HMAC(parent, parent) reproduces it only when the session id
177
+ * and the thread id are the same string. Codex's own root happens to satisfy that, which is
178
+ * exactly why deriving the key looks correct until a caller whose session differs from its thread
179
+ * starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
180
+ * way through. So the recorded key wins, the session-derived key is the fallback that reproduces
181
+ * it when the parent has not been seen in this scope, and the raw parent id is never the answer.
182
+ */
183
+ export function codexConversationIdentity(
184
+ headers: Headers,
185
+ now = Date.now(),
186
+ ): CodexConversationIdentity | undefined {
187
+ const threadId = boundedLineageComponent(headers.get("thread-id"));
188
+ const sessionId = boundedLineageComponent(headers.get("session-id"));
189
+ const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
190
+
191
+ if (threadId === undefined) {
192
+ if (parentThreadId === undefined) return undefined;
193
+ const recorded = liveLineageRecord(
194
+ lineageByScope.get(codexLineageScopeKey(headers)),
195
+ parentThreadId,
196
+ now,
197
+ );
198
+ return {
199
+ conversationKey: recorded?.conversationKey
200
+ ?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
201
+ recordThreadId: parentThreadId,
202
+ ...(sessionId !== undefined ? { sessionId } : {}),
203
+ legacyConversationKey: parentThreadId,
204
+ declaresParent: false,
205
+ };
206
+ }
207
+ const familyId = sessionId ?? parentThreadId;
208
+ if (familyId === undefined) return undefined;
209
+ return {
210
+ conversationKey: codexConversationKeyFor(familyId, threadId),
211
+ recordThreadId: threadId,
212
+ ...(sessionId !== undefined ? { sessionId } : {}),
213
+ ...(parentThreadId !== undefined ? { parentThreadId } : {}),
214
+ ...(parentThreadId !== undefined ? { legacyConversationKey: parentThreadId } : {}),
215
+ declaresParent: parentThreadId !== undefined && parentThreadId !== threadId,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Which authenticated caller this request's lineage belongs to. The bearer is never stored; an
221
+ * unauthenticated (loopback-trusted) request lands in the single local scope.
222
+ */
223
+ export function codexLineageScopeKey(headers: Headers): string {
224
+ const authorization = headers.get("authorization")?.trim();
225
+ if (!authorization) return LOCAL_LINEAGE_SCOPE;
226
+ return `auth:${createHmac("sha256", CODEX_LINEAGE_KEY)
227
+ .update("opencodex-lineage-scope-v1\0")
228
+ .update(authorization)
229
+ .digest("base64url")}`;
230
+ }
231
+
232
+ function dropLineageRecord(scope: CodexLineageScope, threadId: string): void {
233
+ const record = scope.records.get(threadId);
234
+ if (record === undefined) return;
235
+ scope.records.delete(threadId);
236
+ if (scope.threadIdByConversationKey.get(record.conversationKey) === threadId) {
237
+ scope.threadIdByConversationKey.delete(record.conversationKey);
238
+ }
239
+ if (record.parentThreadId === undefined) return;
240
+ const siblings = scope.childThreadIdsByParent.get(record.parentThreadId);
241
+ if (siblings === undefined) return;
242
+ const remaining = siblings.filter(id => id !== threadId);
243
+ if (remaining.length === 0) scope.childThreadIdsByParent.delete(record.parentThreadId);
244
+ else scope.childThreadIdsByParent.set(record.parentThreadId, remaining);
245
+ }
246
+
247
+ /** Records are held in least-recently-used order, so the expired ones are a prefix. */
248
+ function pruneLineageScope(scope: CodexLineageScope, now: number): void {
249
+ for (const [threadId, record] of scope.records) {
250
+ if (now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
251
+ dropLineageRecord(scope, threadId);
252
+ }
253
+ while (scope.records.size > CODEX_LINEAGE_MAX_ENTRIES) {
254
+ const oldest = scope.records.keys().next();
255
+ if (oldest.done === true) break;
256
+ dropLineageRecord(scope, oldest.value);
257
+ }
258
+ }
259
+
260
+ function pruneLineageScopes(now: number): void {
261
+ for (const [scopeKey, scope] of lineageByScope) {
262
+ if (now - scope.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
263
+ lineageByScope.delete(scopeKey);
264
+ }
265
+ while (lineageByScope.size > CODEX_LINEAGE_MAX_SCOPES) {
266
+ const oldest = lineageByScope.keys().next();
267
+ if (oldest.done === true) break;
268
+ lineageByScope.delete(oldest.value);
269
+ }
270
+ }
271
+
272
+ function touchLineageScope(scopeKey: string, now: number): CodexLineageScope {
273
+ const existing = lineageByScope.get(scopeKey);
274
+ const scope: CodexLineageScope = existing ?? {
275
+ records: new Map<string, CodexLineageRecord>(),
276
+ threadIdByConversationKey: new Map<string, string>(),
277
+ childThreadIdsByParent: new Map<string, string[]>(),
278
+ lastUsedAt: now,
279
+ };
280
+ if (existing !== undefined) lineageByScope.delete(scopeKey);
281
+ scope.lastUsedAt = now;
282
+ lineageByScope.set(scopeKey, scope);
283
+ pruneLineageScopes(now);
284
+ pruneLineageScope(scope, now);
285
+ return scope;
286
+ }
287
+
288
+ /**
289
+ * The lineage view for one identity inside one scope, computed without writing anything.
290
+ *
291
+ * A parent seen for the first time through one of its children is derived rather than invented:
292
+ * the child knows the shared session, so HMAC(session, parent) reproduces the key the parent
293
+ * binds under. Once the parent has actually been recorded, its own key wins.
294
+ *
295
+ * Depth is transitive by construction -- a grandchild inherits its parent's resolved root instead
296
+ * of re-deriving one hop -- so a workflow never scatters across several roots.
297
+ */
298
+ function lineageFor(
299
+ scope: CodexLineageScope | undefined,
300
+ identity: CodexConversationIdentity,
301
+ now: number,
302
+ ): CodexThreadLineage {
303
+ const previous = liveLineageRecord(scope, identity.recordThreadId, now);
304
+ // A turn that omits the parent header does not orphan a thread whose parent is already known.
305
+ // That retention is the whole of the lineage-backed worker answer below.
306
+ const parentThreadId = identity.declaresParent
307
+ ? identity.parentThreadId
308
+ : previous?.parentThreadId;
309
+
310
+ const parentRecord = parentThreadId !== undefined
311
+ ? liveLineageRecord(scope, parentThreadId, now)
312
+ : undefined;
313
+ const parentConversationKey = parentThreadId === undefined
314
+ ? undefined
315
+ : parentRecord?.conversationKey
316
+ ?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
317
+ const rootSessionKey = parentConversationKey === undefined
318
+ ? identity.conversationKey
319
+ : parentRecord?.rootSessionKey ?? parentConversationKey;
320
+
321
+ const siblingConversationKeys: string[] = [];
322
+ if (parentThreadId !== undefined && scope !== undefined) {
323
+ for (const siblingThreadId of scope.childThreadIdsByParent.get(parentThreadId) ?? []) {
324
+ if (siblingThreadId === identity.recordThreadId) continue;
325
+ const sibling = liveLineageRecord(scope, siblingThreadId, now);
326
+ if (sibling !== undefined) siblingConversationKeys.push(sibling.conversationKey);
327
+ }
328
+ }
329
+
330
+ // Only a key the old rule would have produced AND that this request no longer uses is a
331
+ // migration candidate. A root's key is unchanged, so it never carries one.
332
+ const legacyConversationKey = identity.legacyConversationKey !== undefined
333
+ && identity.legacyConversationKey !== identity.conversationKey
334
+ ? identity.legacyConversationKey
335
+ : undefined;
336
+
337
+ return {
338
+ conversationKey: identity.conversationKey,
339
+ rootSessionKey,
340
+ ...(parentThreadId !== undefined ? { parentThreadId } : {}),
341
+ ...(parentConversationKey !== undefined ? { parentConversationKey } : {}),
342
+ ...(legacyConversationKey !== undefined ? { legacyConversationKey } : {}),
343
+ siblingConversationKeys,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Read this request's lineage without recording it.
349
+ *
350
+ * A preview must see what the final resolution will see, but it must not be the thing that
351
+ * creates the record: preview runs before auth has decided whether this request may hold Pool
352
+ * state at all, and a record written there would outlive a decision to hold none.
353
+ */
354
+ export function resolveCodexThreadLineage(
355
+ headers: Headers,
356
+ now = Date.now(),
357
+ ): CodexThreadLineage | undefined {
358
+ const identity = codexConversationIdentity(headers, now);
359
+ if (identity === undefined) return undefined;
360
+ return lineageFor(lineageByScope.get(codexLineageScopeKey(headers)), identity, now);
361
+ }
362
+
363
+ /** Record this request's thread relation and return the resolved lineage. */
364
+ export function recordCodexThreadLineage(
365
+ headers: Headers,
366
+ now = Date.now(),
367
+ ): CodexThreadLineage | undefined {
368
+ const identity = codexConversationIdentity(headers, now);
369
+ if (identity === undefined) return undefined;
370
+ const scope = touchLineageScope(codexLineageScopeKey(headers), now);
371
+ const lineage = lineageFor(scope, identity, now);
372
+ const parentThreadId = lineage.parentThreadId;
373
+
374
+ // Re-insert rather than mutate: the records map doubles as the LRU order.
375
+ dropLineageRecord(scope, identity.recordThreadId);
376
+ scope.records.set(identity.recordThreadId, {
377
+ threadId: identity.recordThreadId,
378
+ conversationKey: identity.conversationKey,
379
+ ...(parentThreadId !== undefined ? { parentThreadId } : {}),
380
+ rootSessionKey: lineage.rootSessionKey,
381
+ lastUsedAt: now,
382
+ });
383
+ scope.threadIdByConversationKey.set(identity.conversationKey, identity.recordThreadId);
384
+ if (parentThreadId !== undefined) {
385
+ const siblings = (scope.childThreadIdsByParent.get(parentThreadId) ?? [])
386
+ .filter(id => id !== identity.recordThreadId);
387
+ siblings.unshift(identity.recordThreadId);
388
+ scope.childThreadIdsByParent.set(parentThreadId, siblings.slice(0, CODEX_LINEAGE_MAX_SIBLINGS));
389
+ }
390
+ pruneLineageScope(scope, now);
391
+
392
+ return lineage;
393
+ }
394
+
395
+ /**
396
+ * Cost-attribution lookup for other layers: which root workflow owns this conversation key.
397
+ * Scoped like the records, so a caller can only ever resolve inside its own scope, and read-only
398
+ * -- reading a lineage for accounting must not extend its lifetime.
399
+ */
400
+ export function codexThreadLineageLookup(
401
+ conversationKey: string,
402
+ scopeKey: string,
403
+ now = Date.now(),
404
+ ): { conversationKey: string; rootSessionKey: string; parentThreadId?: string } | undefined {
405
+ const scope = lineageByScope.get(scopeKey);
406
+ if (scope === undefined) return undefined;
407
+ const threadId = scope.threadIdByConversationKey.get(conversationKey);
408
+ if (threadId === undefined) return undefined;
409
+ const record = scope.records.get(threadId);
410
+ if (record === undefined || now - record.lastUsedAt > CODEX_LINEAGE_IDLE_TTL_MS) return undefined;
411
+ return {
412
+ conversationKey: record.conversationKey,
413
+ rootSessionKey: record.rootSessionKey,
414
+ ...(record.parentThreadId !== undefined ? { parentThreadId: record.parentThreadId } : {}),
415
+ };
416
+ }
417
+
418
+ /**
419
+ * The root a request's spend belongs to, for a caller holding headers rather than a key. An
420
+ * unrecorded conversation is its own root, so this never answers null for a bindable request and
421
+ * an accounting layer has no reason to invent one.
422
+ */
423
+ export function codexLineageRootForRequest(headers: Headers, now = Date.now()): string | undefined {
424
+ const identity = codexConversationIdentity(headers, now);
425
+ if (identity === undefined) return undefined;
426
+ return codexThreadLineageLookup(identity.conversationKey, codexLineageScopeKey(headers), now)
427
+ ?.rootSessionKey
428
+ ?? identity.conversationKey;
429
+ }
430
+
431
+ /**
432
+ * The lineage-backed worker/interactive answer.
433
+ *
434
+ * Admission classifies header-only today: a request is worker traffic only when it names a parent
435
+ * AND a distinct `thread-id`, so a fan-out turn that stopped sending the parent header reads as
436
+ * interactive. This keeps that rule and adds what the headers could not say -- a thread already
437
+ * recorded with a parent is worker traffic. Nothing here changes admission; a later lane consumes
438
+ * it.
439
+ */
440
+ export function codexLineageWorkflowLane(headers: Headers, now = Date.now()): CodexWorkflowLane {
441
+ const threadId = boundedLineageComponent(headers.get("thread-id"));
442
+ const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
443
+ if (parentThreadId !== undefined && threadId !== undefined && threadId !== parentThreadId) {
444
+ return "worker";
445
+ }
446
+ if (threadId === undefined) return "interactive";
447
+ const record = lineageByScope.get(codexLineageScopeKey(headers))?.records.get(threadId);
448
+ return record !== undefined
449
+ && now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
450
+ && record.parentThreadId !== undefined
451
+ ? "worker"
452
+ : "interactive";
453
+ }
454
+
455
+ /** Test-only reset; production state is process-local and dies with the process. */
456
+ export function clearCodexThreadLineageForTests(): void {
457
+ lineageByScope.clear();
458
+ }
@@ -0,0 +1,152 @@
1
+
2
+ /**
3
+ * Per-account cooldown for a stored Codex pool credential whose forced refresh
4
+ * failed without proving the grant is dead.
5
+ *
6
+ * A token-endpoint 5xx, a generation CAS loss, or a network blip is transient
7
+ * (#2887): it must not quarantine the account or drop its binding. Retrying the
8
+ * same doomed refresh on every request, though, is how a single unhealthy
9
+ * account pinned the pool at 503 while healthy siblings sat idle. Consecutive
10
+ * non-terminal failures open a bounded growing cooldown; during that window no
11
+ * new forced refresh starts, and selection prefers a sibling. The first
12
+ * successful refresh clears it.
13
+ */
14
+
15
+ import { fallbackCodexAccountLogLabel } from "./account-label";
16
+
17
+ export const CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON = "codex_pool_refresh_incomplete";
18
+
19
+ /** Growing delays between forced-refresh attempts for one account. */
20
+ export const CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
21
+
22
+ export class CodexPoolRefreshCooldownError extends Error {
23
+ readonly retryable = true;
24
+ readonly code = "CODEX_REFRESH_COOLING";
25
+
26
+ constructor(message = "Codex credential refresh is cooling down") {
27
+ super(message);
28
+ this.name = "CodexPoolRefreshCooldownError";
29
+ }
30
+ }
31
+
32
+ type RefreshFailureBackoff = {
33
+ consecutiveFailures: number;
34
+ cooldownUntil: number;
35
+ reason: string;
36
+ };
37
+
38
+ const backoffByAccount = new Map<string, RefreshFailureBackoff>();
39
+ /**
40
+ * Bumped whenever an account's failures are cleared because something proved them obsolete — a
41
+ * successful refresh, or a replacement credential written by login/reauth. A refresh flight that
42
+ * started before that moment is reporting on a grant that no longer exists, and its late failure
43
+ * must not re-quarantine the credential that replaced it.
44
+ */
45
+ const fenceByAccount = new Map<string, number>();
46
+ let nowOverride: number | undefined;
47
+
48
+ export function setCodexPoolRefreshFailureNowForTests(now?: number): void {
49
+ nowOverride = now;
50
+ }
51
+
52
+ export function resetCodexPoolRefreshFailureBackoffForTests(): void {
53
+ backoffByAccount.clear();
54
+ fenceByAccount.clear();
55
+ nowOverride = undefined;
56
+ }
57
+
58
+ /** The value a refresh flight captures before it starts, to be handed back on failure. */
59
+ export function codexPoolRefreshFence(accountId: string): number {
60
+ return fenceByAccount.get(accountId) ?? 0;
61
+ }
62
+
63
+ export function clearCodexPoolRefreshFailure(accountId: string): void {
64
+ backoffByAccount.delete(accountId);
65
+ fenceByAccount.set(accountId, (fenceByAccount.get(accountId) ?? 0) + 1);
66
+ }
67
+
68
+ /**
69
+ * Drop every remembered failure. Called when the routing layer discards its per-account state,
70
+ * because a cooldown outliving the binding it was learned alongside would keep an account out of
71
+ * selection for a roster the operator has already replaced.
72
+ */
73
+ export function clearAllCodexPoolRefreshFailures(): void {
74
+ backoffByAccount.clear();
75
+ }
76
+
77
+ function currentNow(now?: number): number {
78
+ return now ?? nowOverride ?? Date.now();
79
+ }
80
+
81
+ function delayFor(consecutiveFailures: number): number {
82
+ const index = Math.min(Math.max(consecutiveFailures, 1), CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS.length) - 1;
83
+ return CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS[index]!;
84
+ }
85
+
86
+ /**
87
+ * How many consecutive non-terminal failures must land before a refresh is WITHHELD.
88
+ *
89
+ * Withholding on the first failure was wrong twice over. A single token-endpoint blip is the
90
+ * ordinary case that the very next attempt clears, and -- worse -- a withheld refresh never runs,
91
+ * so an account whose grant is actually revoked can no longer discover that: the terminal 401 it
92
+ * owes the operator turns into a retryable 503 that never resolves. The cooldown exists for the
93
+ * account that keeps failing, not for the one that failed once.
94
+ */
95
+ export const CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES = 3;
96
+
97
+ export function getCodexPoolRefreshCooldownUntil(accountId: string, now = currentNow()): number | null {
98
+ const entry = backoffByAccount.get(accountId);
99
+ if (!entry) return null;
100
+ if (entry.consecutiveFailures < CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES) return null;
101
+ return entry.cooldownUntil > now ? entry.cooldownUntil : null;
102
+ }
103
+
104
+ export function isCodexPoolRefreshCooling(accountId: string, now = currentNow()): boolean {
105
+ return getCodexPoolRefreshCooldownUntil(accountId, now) !== null;
106
+ }
107
+
108
+ /**
109
+ * Record a non-terminal forced-refresh failure. Already-cooling accounts do not
110
+ * grow the window: growth requires another real attempt after the previous one
111
+ * expired. Logs the classified reason once per account per window, with the
112
+ * durable hash label — never a token and never an email.
113
+ */
114
+ export function noteCodexPoolRefreshFailure(
115
+ accountId: string,
116
+ reason: string,
117
+ now = currentNow(),
118
+ fence?: number,
119
+ ): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } {
120
+ const existing = backoffByAccount.get(accountId);
121
+ // A flight that started before the account's failures were cleared is speaking for a grant
122
+ // that has since been replaced or proven healthy. Recording it would put the new credential
123
+ // back in the quarantine its predecessor earned.
124
+ if (fence !== undefined && fence !== codexPoolRefreshFence(accountId)) {
125
+ return {
126
+ consecutiveFailures: existing?.consecutiveFailures ?? 0,
127
+ cooldownUntil: existing?.cooldownUntil ?? 0,
128
+ openedWindow: false,
129
+ };
130
+ }
131
+ // The "do not grow inside an open window" rule applies only once the window is actually
132
+ // WITHHOLDING. Below the threshold no refresh is being withheld, so every failure is a real
133
+ // attempt that really failed and must count -- otherwise a client retrying the 503 once a
134
+ // second can never reach the threshold the cooldown is meant to protect against.
135
+ const withholding = existing !== undefined
136
+ && existing.consecutiveFailures >= CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES;
137
+ if (existing && withholding && existing.cooldownUntil > now) {
138
+ return {
139
+ consecutiveFailures: existing.consecutiveFailures,
140
+ cooldownUntil: existing.cooldownUntil,
141
+ openedWindow: false,
142
+ };
143
+ }
144
+ const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
145
+ const cooldownUntil = now + delayFor(consecutiveFailures);
146
+ backoffByAccount.set(accountId, { consecutiveFailures, cooldownUntil, reason });
147
+ const label = fallbackCodexAccountLogLabel(accountId);
148
+ console.warn(
149
+ `[codex-auth] Codex pool account ${label} credential refresh failed (${reason})`,
150
+ );
151
+ return { consecutiveFailures, cooldownUntil, openedWindow: true };
152
+ }