@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
@@ -0,0 +1,259 @@
1
+ import type { ResponsesRequestContext } from "./core-options";
2
+ import { createRequestExecutionBudget, isRequestExecutionBudget } from "../../lib/request-execution-budget";
3
+ import { chargeWorkflowSends, workflowSendCeilingReached } from "../../lib/workflow-budget";
4
+ import { workflowRefusalResponse } from "../workflow-refusal";
5
+ import type { AttemptRecoveryKind } from "../../usage/log";
6
+ import { noteAttemptSend } from "../request-log";
7
+ import { TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry";
8
+ import type {
9
+ DispatchDecision,
10
+ DispatchIntent,
11
+ RequestExecutionBudget,
12
+ SendClass,
13
+ SingleUseDispatchPermit,
14
+ } from "../../lib/request-execution-budget";
15
+
16
+ /** Owns the shared request send counter and recovery permits. */
17
+ export function createResponsesSendBudget(
18
+ requestContext: Pick<ResponsesRequestContext, "options" | "req" | "logCtx">,
19
+ ) {
20
+ const { options, req, logCtx } = requestContext;
21
+
22
+
23
+ // One transient-retry budget for the whole LOGICAL request, read ABOVE the passthrough branch
24
+ // so that branch shares it too. It used to be a local declared below, which put it in the
25
+ // temporal dead zone for the passthrough sends and left each recovery leg taking the helper's
26
+ // fresh default of 3. It is now a holder carried on options, so a combo child inherits the
27
+ // parent's spend instead of starting over per target -- both halves of the measured
28
+ // amplification in #4546.
29
+ const sendBudget = options.sendBudget ?? createRequestExecutionBudget();
30
+ // The root workflow is the user-visible task. A per-request cap cannot bound a fan-out that
31
+ // sends once per child seven hundred times, so every send charged to the request is charged
32
+ // to the root as well (#4546).
33
+ const workflowRootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
34
+ const noteTransientSends = (used: number): void => {
35
+ const charged = Math.max(0, used);
36
+ sendBudget.used += charged;
37
+ chargeWorkflowSends(workflowRootId, charged);
38
+ };
39
+ // Refused before any dispatch, and deliberately not by evicting the root's ledger entry:
40
+ // dropping the record to make room would hand the fan-out a fresh allowance, which is the
41
+ // laundering this ceiling exists to stop. The client is told the task needs a new grant
42
+ // rather than being given a synthetic upstream error.
43
+ if (workflowSendCeilingReached(workflowRootId)) {
44
+ // A log context exists here, unlike at HTTP admission, so the row this request writes is
45
+ // marked synthetic rather than reading as a request that vanished with zero sends.
46
+ return workflowRefusalResponse("workflow-sends-exhausted", logCtx, undefined, workflowRootId);
47
+ }
48
+ // No floor. Math.max(1, ...) meant an exhausted request still funded one send on every
49
+ // recovery leg, so a bounded per-leg allowance never became a bounded per-request one.
50
+ const remainingTransientSendBudget = (budget: number): number =>
51
+ isRequestExecutionBudget(sendBudget)
52
+ ? sendBudget.remainingBaseSends(budget)
53
+ : Math.max(0, budget - sendBudget.used);
54
+ // The adapter contract needs the full budget, not just the counter. options.sendBudget is
55
+ // typed as the narrow holder so a caller that predates this can still pass one, so narrow it
56
+ // once here rather than asserting at each adapter call site.
57
+ const adapterSendBudget = isRequestExecutionBudget(sendBudget) ? sendBudget : undefined;
58
+ /**
59
+ * Records an adapter's OWN inner retries against this attempt.
60
+ *
61
+ * Ordinal 1 is the send each call site already recorded through `noteRoutedAttemptSend`, so only
62
+ * the extra physical sends are added here and an adapter that does not retry internally
63
+ * leaves its log byte-for-byte as it was. Kiro reaches roughly eighteen sends per call and
64
+ * Cursor re-sends a whole turn, and both reported one; a count that cannot be observed
65
+ * cannot be pinned by a regression, which is why the instrumentation precedes the cap.
66
+ */
67
+ const noteAdapterPhysicalSend = (
68
+ inputTokens: number | undefined,
69
+ send: { ordinal: number; recovery?: AttemptRecoveryKind },
70
+ ): void => {
71
+ if (send.ordinal <= 1) return;
72
+ noteAttemptSend(logCtx.activeAttempt, inputTokens, send.recovery);
73
+ };
74
+ const sendBudgetExhausted = (): boolean =>
75
+ remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS) === 0;
76
+ /**
77
+ * A credential hop reserves the send its own replay will make, and that replay is a recovery
78
+ * leg. The leg must SPEND the hop's reservation instead of taking a second one: the
79
+ * final-recovery reserve is single, so a rebuild that reserved on top of a hop would be
80
+ * refused and the request would answer with a synthetic 502 in place of the real 429 the hop
81
+ * was recovering from.
82
+ */
83
+ let pendingHopPermit: SingleUseDispatchPermit | undefined;
84
+ /**
85
+ * The budget an adapter's OWN dispatch ladder reserves against.
86
+ *
87
+ * Kiro and Cursor reserve once per physical send, and that is right: their ladders are the
88
+ * layer that actually sends, and counting one adapter call as one send hid up to eighteen
89
+ * upstream requests. But a credential hop has already booked the replay it is about to make,
90
+ * and a reservation IS the charge, so an adapter that reserves again turns one physical send
91
+ * into two charges -- and once the base allowance is spent, into a refusal that answers with
92
+ * a synthetic error in place of the 429 the hop was recovering from (#4709).
93
+ *
94
+ * The hop hands its reservation down through `pendingHopPermit`, the same seam the
95
+ * passthrough ladder already uses, and this view spends it on the adapter's FIRST
96
+ * reservation. Every later send in that ladder is a new physical send and is charged
97
+ * normally. A permit the adapter takes but never sends under is released through the same
98
+ * call it would have used for a reservation of its own, so an abandoned replay is refunded
99
+ * rather than left charged.
100
+ */
101
+ const adapterDispatchBudget: RequestExecutionBudget | undefined = adapterSendBudget === undefined
102
+ ? undefined
103
+ : adapterDispatchBudgetView(adapterSendBudget, {
104
+ claimHopPermit: () => {
105
+ const permit = pendingHopPermit;
106
+ pendingHopPermit = undefined;
107
+ return permit;
108
+ },
109
+ });
110
+ /**
111
+ * How many sends a recovery leg may make, and the permit that authorises the last one.
112
+ *
113
+ * The base allowance is spent first. Once it is gone a recovery class may still draw the
114
+ * single shared final-recovery reserve -- which is what keeps the validated sanitized rebuild
115
+ * after a 5xx streak alive at four total sends -- but an account move and a rebuild cannot
116
+ * each take one. `countedExternally` is set because these legs run through the retry helper,
117
+ * which reports the same send again through `onSendsConsumed`.
118
+ */
119
+ const recoverySendAllowance = (
120
+ cap: number,
121
+ sendClass: SendClass,
122
+ targetKey: string,
123
+ ): { attempts: number; permit?: SingleUseDispatchPermit } => {
124
+ const base = remainingTransientSendBudget(cap);
125
+ if (base > 0) return { attempts: base };
126
+ if (pendingHopPermit) {
127
+ const hopPermit = pendingHopPermit;
128
+ pendingHopPermit = undefined;
129
+ return { attempts: 1, permit: hopPermit };
130
+ }
131
+ if (!isRequestExecutionBudget(sendBudget)) return { attempts: 0 };
132
+ const decision = sendBudget.reserveDispatch({ sendClass, targetKey, countedExternally: true });
133
+ return decision.allowed ? { attempts: 1, permit: decision.permit } : { attempts: 0 };
134
+ };
135
+ /**
136
+ * One credential hop of this logical request, admitted by the INTERSECTION of two bounds.
137
+ *
138
+ * `GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST` and `ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST`
139
+ * stay exactly as they are: they bound rotation within one credential roster. What neither
140
+ * can see is everything else this request already sent, so three hops layered on a spent
141
+ * budget still reached upstream three more times. A hop now happens only when its own layer
142
+ * cap AND the shared budget both permit it, and the smaller of the two wins.
143
+ *
144
+ * `countedExternally` is for the hops whose replay goes out through the retry helper, which
145
+ * reports the same physical send through `onSendsConsumed`; the others are charged here and
146
+ * nowhere else. A refusal is not an error: the caller keeps the real upstream response --
147
+ * status, `Retry-After`, quota body -- because return-the-last-answer is the exhaustion
148
+ * contract this unit settled on.
149
+ */
150
+ /**
151
+ * A credential rotation inside ONE provider's roster is "auth-recovery", not
152
+ * "account-failover". The distinction is load-bearing: "account-failover" sets
153
+ * `isAlternateTarget` unconditionally, so under `maxAlternateTargetSends: 1` the first
154
+ * rotation would refuse every later one AND consume the single slot a genuine cross-pool
155
+ * move needs -- a roster whose first two accounts are both 429'd would return the 429
156
+ * while a free third account sat unused. The roster cap bounds how far rotation walks;
157
+ * the shared total bounds how many sends the request makes. Reserve "account-failover"
158
+ * for a real move between pools.
159
+ */
160
+ const reserveCredentialHop = (
161
+ sendClass: SendClass,
162
+ targetKey: string,
163
+ countedExternally = false,
164
+ ): { allowed: boolean; permit?: SingleUseDispatchPermit } => {
165
+ if (!isRequestExecutionBudget(sendBudget)) return { allowed: true };
166
+ const decision = sendBudget.reserveDispatch({ sendClass, targetKey, countedExternally });
167
+ return decision.allowed ? { allowed: true, permit: decision.permit } : { allowed: false };
168
+ };
169
+ /**
170
+ * Both classes share the one reserve, so this only changes what the decision is called --
171
+ * but a recovery event that says "repair" when a credential refresh drove it is the kind of
172
+ * mislabelled evidence #4592 existed to stop.
173
+ */
174
+ const recoveryClassFor = (recovery: AttemptRecoveryKind): SendClass =>
175
+ /401|429|oauth|rate-limit|key/.test(recovery) ? "auth-recovery" : "repair";
176
+
177
+ return {
178
+ workflowRootId,
179
+ noteTransientSends,
180
+ remainingTransientSendBudget,
181
+ adapterSendBudget,
182
+ adapterDispatchBudget,
183
+ noteAdapterPhysicalSend,
184
+ sendBudgetExhausted,
185
+ get pendingHopPermit(): SingleUseDispatchPermit | undefined {
186
+ return pendingHopPermit;
187
+ },
188
+ set pendingHopPermit(value: SingleUseDispatchPermit | undefined) {
189
+ pendingHopPermit = value;
190
+ },
191
+ recoverySendAllowance,
192
+ reserveCredentialHop,
193
+ recoveryClassFor,
194
+ };
195
+ }
196
+
197
+ export type ResponsesSendBudget = Exclude<ReturnType<typeof createResponsesSendBudget>, Response>;
198
+
199
+ /**
200
+ * A LIVE delegating view of one request's execution budget, with a credential hop's
201
+ * reservation spendable through it.
202
+ *
203
+ * Every member forwards rather than copying. A spread of the budget would freeze `used`,
204
+ * `reserveSpent` and the target counters at construction time, handing the adapter a budget
205
+ * that can never read as exhausted -- the same class of defect as the fresh per-layer
206
+ * allowances #4546 removed.
207
+ */
208
+ function adapterDispatchBudgetView(
209
+ budget: RequestExecutionBudget,
210
+ hop: { claimHopPermit: () => SingleUseDispatchPermit | undefined },
211
+ ): RequestExecutionBudget {
212
+ return {
213
+ get used(): number { return budget.used; },
214
+ set used(next: number) { budget.used = next; },
215
+ logicalRequestId: budget.logicalRequestId,
216
+ policyVersion: budget.policyVersion,
217
+ policy: budget.policy,
218
+ get reserveSpent(): boolean { return budget.reserveSpent; },
219
+ get alternateTargetSends(): number { return budget.alternateTargetSends; },
220
+ get targetTransitions(): number { return budget.targetTransitions; },
221
+ get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
222
+ remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
223
+ reserveDispatch(intent: DispatchIntent): DispatchDecision {
224
+ // A dispatch whose upstream state is unknown is refused on its own merits. A hop that
225
+ // already paid does not make an unsafe replay safe, so that check stays with the budget.
226
+ if (intent.replaySafe !== false) {
227
+ const hopPermit = hop.claimHopPermit();
228
+ // Confirmed here rather than in `use()`: the adapter reserves immediately before it
229
+ // opens the transport, which is the same boundary the hop's own confirmation uses.
230
+ // A permit some other leg already settled returns false, and this falls through to a
231
+ // real reservation rather than handing the adapter a dead permit -- an adapter whose
232
+ // `use()` fails treats the request as exhausted and stops sending entirely.
233
+ if (hopPermit !== undefined && hopPermit.assumeCharge()) {
234
+ let spent = false;
235
+ return {
236
+ allowed: true,
237
+ permit: {
238
+ sendClass: hopPermit.sendClass,
239
+ use: (): boolean => {
240
+ if (spent) return false;
241
+ spent = true;
242
+ return true;
243
+ },
244
+ assumeCharge: (): boolean => {
245
+ if (spent) return false;
246
+ spent = true;
247
+ return true;
248
+ },
249
+ // The hop's charge is already settled and belongs to the leg that asked for it,
250
+ // so there is nothing here to refund.
251
+ release: (): void => {},
252
+ },
253
+ };
254
+ }
255
+ }
256
+ return budget.reserveDispatch(intent);
257
+ },
258
+ };
259
+ }
@@ -0,0 +1,149 @@
1
+ import type { ResponsesRequestContext } from "./core-options";
2
+ import type { PreparedResponsesRequest } from "./request-prepare";
3
+ import type { ResponsesTransport } from "./request-transport";
4
+ import type { ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
5
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
6
+ import {
7
+ shouldResolveOpenAiVisionSidecar,
8
+ resolveOpenAiVisionModel,
9
+ planVisionSidecar,
10
+ describeImagesInPlace,
11
+ requiresVisionPreprocessing,
12
+ stripImagesInPlace,
13
+ } from "../../vision";
14
+ import { shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
15
+ import { shouldResolveOpenAiPassthroughWebSearchBridge } from "../../web-search/passthrough-bridge";
16
+ import {
17
+ listOpenAiForwardSidecarCandidates,
18
+ captureExplicitOpenAiCallerAuth,
19
+ resolveFirstUsableOpenAiSidecar,
20
+ } from "../../providers/openai-sidecar";
21
+ import {
22
+ tryClaimNativeMainProfileForTurn as tryClaimStoredSidecarMainProfile,
23
+ } from "../../codex/native-main-admission";
24
+ import { codexAccountSelectionForTurn } from "../lifecycle";
25
+ import {
26
+ CodexPoolAuthenticationError,
27
+ CodexAuthContextError,
28
+ CodexAccountCooldownError,
29
+ CodexThreadAffinityExpiredError,
30
+ CodexMainProfileDrainingError,
31
+ } from "../../codex/auth-context";
32
+
33
+ /** One responsibility of the Responses request pipeline; state owners are explicit. */
34
+ export async function prepareResponsesSidecarAuth(
35
+ requestContext: Pick<ResponsesRequestContext, "options" | "config" | "req">,
36
+ requestState: Pick<
37
+ PreparedResponsesRequest,
38
+ | "parsed"
39
+ | "route"
40
+ | "selectedForwardHeaders"
41
+ | "translatorBudget"
42
+ >,
43
+ transportState: Pick<ResponsesTransport, "adapter" | "isPassthrough">,
44
+ ) {
45
+ const { options, config, req } = requestContext;
46
+ const { parsed, route, translatorBudget } = requestState;
47
+ const { isPassthrough } = transportState;
48
+
49
+
50
+ let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
51
+ const visionDescribeTerminal = options.visionDescribeTerminal === true;
52
+ const routedCompaction = parsed._compactionRequest === true
53
+ && !isCanonicalOpenAiForwardProvider(route.provider);
54
+ const needsOpenAiVision = !visionDescribeTerminal
55
+ && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName);
56
+ const needsOpenAiSearch = !routedCompaction && !transportState.adapter.runTurn
57
+ && (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough)
58
+ || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough));
59
+ if (needsOpenAiVision || needsOpenAiSearch) {
60
+ try {
61
+ const candidates = listOpenAiForwardSidecarCandidates(config);
62
+ let sidecarAuth = options.openAiSidecarAuth;
63
+ if (!sidecarAuth && options.allowStoredOpenAiSidecarAuth === true
64
+ && route.codexAccountId === undefined
65
+ && candidates.some(candidate => candidate.accountMode === "direct")
66
+ && tryClaimStoredSidecarMainProfile(options.turnAdmissionLease)) {
67
+ // Request-local helper authority only: never promote this pair to caller, primary,
68
+ // or retry credentials. Claim before reading so profile switches remain fenced.
69
+ try {
70
+ const { getMainAccountToken } = await import("../../codex/main-account");
71
+ const token = getMainAccountToken();
72
+ if (token) sidecarAuth = captureExplicitOpenAiCallerAuth(new Headers({
73
+ authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId,
74
+ }), config);
75
+ } catch { /* stored enrichment is optional */ }
76
+ }
77
+ // Preserve explicit OpenAI helper auth across route changes without returning it to
78
+ // primary-provider headers or alternate-main retry. The resolver revalidates scope.
79
+ const sidecarHeaders = new Headers(req.headers);
80
+ sidecarHeaders.delete("authorization");
81
+ sidecarHeaders.delete("chatgpt-account-id");
82
+ if (sidecarAuth) {
83
+ sidecarHeaders.set("authorization", sidecarAuth.authorization);
84
+ sidecarHeaders.set("chatgpt-account-id", sidecarAuth.chatgptAccountId);
85
+ }
86
+ openAiSidecar = await resolveFirstUsableOpenAiSidecar(
87
+ candidates,
88
+ sidecarHeaders,
89
+ config,
90
+ {
91
+ admission: options.admission,
92
+ codexAuthPolicy: options.codexAuthPolicy,
93
+ // Account-qualified native routes are passthrough, so their in-turn helper is vision.
94
+ // Scope its cooldown and outcome to the helper model, not the routed text model.
95
+ ...(route.codexAccountId !== undefined
96
+ ? { exactAccount: { accountId: route.codexAccountId, modelId: resolveOpenAiVisionModel(config) } }
97
+ : {}),
98
+ beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
99
+ },
100
+ );
101
+ } catch (err) {
102
+ // Sidecars are optional helpers for an otherwise independent routed turn.
103
+ // An unavailable/cooling/expired Multi credential disables the helper; it
104
+ // must not turn a valid routed-provider request into a Codex-auth failure.
105
+ if (
106
+ !(err instanceof CodexPoolAuthenticationError)
107
+ && !(err instanceof CodexAuthContextError)
108
+ && !(err instanceof CodexAccountCooldownError)
109
+ && !(err instanceof CodexThreadAffinityExpiredError)
110
+ && !(err instanceof CodexMainProfileDrainingError)
111
+ ) throw err;
112
+ }
113
+ }
114
+
115
+ // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
116
+ // attached image through the selected sidecar backend and replace it with text BEFORE the main
117
+ // call, so the text-only model can reason about it.
118
+ // Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe
119
+ // call must never plan another describe. The flag arrives from the Chat
120
+ // surface (whose bridge rebuilds headers) or as the raw header for native
121
+ // Responses callers. Marked + text-only routed model → strip, depth cap 1.
122
+ const visionPlan = visionDescribeTerminal
123
+ ? undefined
124
+ : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, {
125
+ admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName,
126
+ });
127
+ const recordSidecarOutcome = openAiSidecar?.recordOutcome;
128
+ if (visionPlan) {
129
+ await describeImagesInPlace(
130
+ parsed,
131
+ visionPlan,
132
+ openAiSidecar?.headers ?? requestState.selectedForwardHeaders,
133
+ options.abortSignal,
134
+ recordSidecarOutcome,
135
+ translatorBudget,
136
+ );
137
+ } else if (requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName)) {
138
+ // Image capability is not positively proven but no sidecar plan is dispatchable: fail closed.
139
+ // Never forward raw image bytes to an unverified upstream.
140
+ stripImagesInPlace(parsed, translatorBudget);
141
+ }
142
+
143
+ return {
144
+ openAiSidecar,
145
+ routedCompaction,
146
+ };
147
+ }
148
+
149
+ export type ResponsesSidecarAuth = Exclude<Awaited<ReturnType<typeof prepareResponsesSidecarAuth>>, Response>;
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RequestSendObserver } from "../../lib/request-execution-budget";
3
+ import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
4
+ import type { RequestLogContext } from "../request-log";
5
+
6
+ /** The terminal usage a request reported, in the only two fields the ledger books. */
7
+ export interface TerminalSpendUsage {
8
+ inputTokens?: number;
9
+ outputTokens?: number;
10
+ }
11
+
12
+ /** Settles one request's durable spend entries once its terminal usage is known. */
13
+ export interface RequestSpendSettlement {
14
+ settle(usage: TerminalSpendUsage | undefined): void;
15
+ }
16
+
17
+ export interface RequestSpendTracker extends RequestSendObserver, RequestSpendSettlement {
18
+ /** Dispatches this request lost to a ledger ceiling. Zero on every ordinary request. */
19
+ readonly refusals: number;
20
+ }
21
+
22
+ /**
23
+ * One request's entries in the durable spend ledger (#4707).
24
+ *
25
+ * The ledger has had the whole reserve/dispatch/settle vocabulary since #4546 and no production
26
+ * caller: `spend-ledger.jsonl` was never created by ordinary traffic, and the ceilings the
27
+ * feature advertised stayed process-local and count-only, resetting on restart. This is the
28
+ * caller.
29
+ *
30
+ * It books one entry per physical send by observing the request's own send counter rather than
31
+ * by being called from each dispatch site. That counter moves exactly once per physical send,
32
+ * so one entry per increment is one entry per send -- and a dispatch path added later cannot
33
+ * forget to book, which is how the previous wiring attempt ended up with no caller at all.
34
+ *
35
+ * Settlement follows what the request actually learned. The terminal usage belongs to the LAST
36
+ * send that left, so that one settles with the real figure. Every earlier send failed without
37
+ * reporting usage of its own and may still have been billed, so it becomes unresolved spend
38
+ * rather than free. A request that ends with no usage at all -- a cancel, a lost stream --
39
+ * leaves all of them unresolved, which is the conservative answer this ledger exists to give.
40
+ */
41
+ export function createRequestSpendTracker(
42
+ logCtx: Pick<
43
+ RequestLogContext,
44
+ "provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
45
+ >,
46
+ rootId: string | undefined,
47
+ injected?: SpendReservationLedger,
48
+ ): RequestSpendTracker {
49
+ // Resolved on the first CHARGE, not when the request is built. The shared ledger opens a
50
+ // journal under the OpenCodex home, and a request that never dispatches -- refused at
51
+ // admission, answered locally, cancelled before its first send -- has no business creating
52
+ // one. It also means the home in effect at dispatch is the one that gets written.
53
+ let ledgerRef: SpendReservationLedger | undefined = injected;
54
+ const ledger = (): SpendReservationLedger => (ledgerRef ??= sharedSpendLedger());
55
+ // Every send this request still owes the ledger an answer for, oldest first.
56
+ const live: string[] = [];
57
+ let refusals = 0;
58
+ let resolved = false;
59
+ /**
60
+ * Confirm the sends this request has already moved past.
61
+ *
62
+ * A booking is only marked dispatched once a LATER send exists, because that later send
63
+ * proves the earlier one left. The newest booking stays open until it is settled, so a
64
+ * reservation the budget hands back -- a rotation that found no alternate, a rebuild
65
+ * abandoned before the wire -- can still be released for free while this process is alive.
66
+ * A crash resolves every surviving reservation as unresolved spend regardless of this mark,
67
+ * because a journal that lost its tail cannot prove a send never left.
68
+ */
69
+ const confirmOlderSends = (): void => {
70
+ for (let index = 0; index < live.length - 1; index += 1) ledger().markDispatched(live[index] as string);
71
+ };
72
+ return {
73
+ charge(): boolean {
74
+ const sendId = randomUUID();
75
+ const decision = ledger().reserve({
76
+ sendId,
77
+ scopes: {
78
+ ...(rootId !== undefined ? { rootId } : {}),
79
+ // Already the privacy-safe label the request log uses, and the ledger aliases it
80
+ // again on the way to disk. A raw credential never reaches either.
81
+ ...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
82
+ ...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
83
+ },
84
+ inputTokens: logCtx.usageLogInputTokens ?? 0,
85
+ outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
86
+ });
87
+ if (!decision.reserved) {
88
+ refusals += 1;
89
+ // Only an operator's configured ceiling refuses a dispatch. Every other denial --
90
+ // capacity, durability, a journal this process could not prove complete -- means the
91
+ // ledger cannot ACCOUNT for this send, which is not a reason to refuse one. An
92
+ // unconfigured install keeps the count caps it already had and is not newly refused,
93
+ // and a degraded ledger must not become an outage.
94
+ return decision.denial.reason !== "spend-limit-exceeded";
95
+ }
96
+ live.push(sendId);
97
+ confirmOlderSends();
98
+ return true;
99
+ },
100
+ refund(): void {
101
+ const sendId = live.pop();
102
+ if (sendId === undefined) return;
103
+ // Undispatched, so this returns the tokens. If the send was already confirmed by a later
104
+ // one, `abandon` refuses and unresolved is the only honest outcome left.
105
+ if (!ledger().abandon(sendId)) ledger().markLost(sendId);
106
+ },
107
+ settle(usage: TerminalSpendUsage | undefined): void {
108
+ if (resolved) return;
109
+ resolved = true;
110
+ const terminal = live.pop();
111
+ if (terminal !== undefined) {
112
+ const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
113
+ if (reported) {
114
+ ledger().settle(terminal, {
115
+ inputTokens: usage?.inputTokens ?? 0,
116
+ outputTokens: usage?.outputTokens ?? 0,
117
+ });
118
+ } else {
119
+ // The response never reported usage. It may still have been billed.
120
+ ledger().markLost(terminal);
121
+ }
122
+ }
123
+ for (const sendId of live.splice(0)) ledger().markLost(sendId);
124
+ },
125
+ get refusals(): number { return refusals; },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Give a request a spend tracker and hand back the observer its budget reports through.
131
+ *
132
+ * The tracker is parked on the log context because `addFinalRequestLog` is the one seam every
133
+ * request passes exactly once, whatever transport served it and however it ended, and it is
134
+ * where the terminal usage is already known.
135
+ */
136
+ export function attachRequestSpendTracker(
137
+ req: Pick<Request, "headers">,
138
+ logCtx: RequestLogContext,
139
+ ledger?: SpendReservationLedger,
140
+ ): RequestSendObserver {
141
+ const rootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
142
+ const tracker = ledger === undefined
143
+ ? createRequestSpendTracker(logCtx, rootId)
144
+ : createRequestSpendTracker(logCtx, rootId, ledger);
145
+ logCtx.spendTracker = tracker;
146
+ return tracker;
147
+ }