@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
@@ -615,7 +615,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
615
615
  signal: headerDeadline.signal,
616
616
  }, retryRecovery));
617
617
  },
618
- { abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
618
+ { replaySafe: true, abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
619
619
  );
620
620
  }
621
621
  } finally {
package/src/lib/errors.ts CHANGED
@@ -7,6 +7,15 @@ export interface OcxErrorPayload {
7
7
  export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION =
8
8
  "Encrypted function output content could not be decrypted or decoded.";
9
9
 
10
+ /**
11
+ * The error identity for a send this proxy declined to make (#4708).
12
+ *
13
+ * Declared here rather than only on the error class because the classifier is what decides
14
+ * whether the identity survives serialization, and every dispatch path has to name the same
15
+ * string for a client to be able to tell this apart from a provider rate limit.
16
+ */
17
+ export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted";
18
+
10
19
  /** Canonical human-readable message paths used by Responses upstream failures. */
11
20
  export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
12
21
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
@@ -253,6 +262,14 @@ export function classifyError(status: number, type: string, message: string): Oc
253
262
  ) {
254
263
  return { message, type: "insufficient_quota", code: "insufficient_quota" };
255
264
  }
265
+ // A refusal this proxy made itself, kept apart from the provider rate limits below. The HTTP
266
+ // semantics are identical -- 429, do not send this again now -- but the code is the only thing
267
+ // that tells an operator reading a log whether the provider throttled the request or whether
268
+ // this process declined to send it. Folding it into the generic rate-limit code sent them to
269
+ // the provider's dashboard to explain a decision that was never made there.
270
+ if (type === SEND_BUDGET_EXHAUSTED_CODE) {
271
+ return { message, type: "rate_limit_error", code: SEND_BUDGET_EXHAUSTED_CODE };
272
+ }
256
273
  if (
257
274
  status === 429 ||
258
275
  text.includes("rate limit") ||
@@ -56,6 +56,7 @@ export type BudgetDenial =
56
56
  | "final-recovery-spent"
57
57
  | "alternate-target-exhausted"
58
58
  | "target-transition-exhausted"
59
+ | "spend-exhausted"
59
60
  | "not-replay-safe";
60
61
 
61
62
  export interface DispatchIntent {
@@ -72,23 +73,67 @@ export interface DispatchIntent {
72
73
  readonly replaySafe?: boolean;
73
74
  /**
74
75
  * True when the physical send is already reported through another counter -- the retry
75
- * helpers' `onSendsConsumed` hook. The permit then books the reserve, alternate-target and
76
- * transition ledgers but leaves `used` to that reporter, because charging both is how a
77
- * four-send cap silently becomes a two-send cap.
76
+ * helpers' `onSendsConsumed` hook. The send is still booked at reservation time, because an
77
+ * advisory reservation cannot stop a concurrent leg; what changes is that the booking is
78
+ * PENDING, and the first send the external reporter names settles it instead of adding a
79
+ * second charge. Charging both is how a four-send cap silently becomes a two-send cap.
78
80
  */
79
81
  readonly countedExternally?: boolean;
80
82
  }
81
83
 
82
84
  export interface SingleUseDispatchPermit {
83
85
  readonly sendClass: SendClass;
84
- /** Consume exactly once. A second call returns false and charges nothing. */
86
+ /**
87
+ * Confirm the dispatch this permit already paid for. The reservation is the charge, so this
88
+ * charges nothing; it is how a leg proves it is the one that sent. A second call returns
89
+ * false, which is what keeps a retry thunk from sending twice on one permit.
90
+ */
85
91
  use(): boolean;
92
+ /**
93
+ * Hand back a reservation that never dispatched -- a credential move that found no alternate,
94
+ * a rebuild abandoned before the send. Idempotent, and a no-op once the permit was used or
95
+ * once an external send reporter already settled it.
96
+ */
97
+ release(): void;
98
+ /**
99
+ * Take over an externally counted booking, because the layer holding this permit is the one
100
+ * that physically sends.
101
+ *
102
+ * `countedExternally` promises that a retry helper will name this send through
103
+ * `onSendsConsumed`. An adapter that owns its own dispatch ladder -- Kiro's reset loop,
104
+ * Cursor's transport loop -- reserves per physical send instead, so no reporter ever arrives
105
+ * and the pending booking would sit there until it silently swallowed an unrelated later
106
+ * report. Confirming through this method settles the permit AND closes the booking, so the
107
+ * send stays charged exactly once (#4709). Returns false once the permit is settled, which is
108
+ * what keeps one permit from admitting two sends.
109
+ */
110
+ assumeCharge(): boolean;
86
111
  }
87
112
 
88
113
  export type DispatchDecision =
89
114
  | { allowed: true; permit: SingleUseDispatchPermit }
90
115
  | { allowed: false; reason: BudgetDenial };
91
116
 
117
+ /**
118
+ * Notified when this request's physical-send count moves.
119
+ *
120
+ * `spent` is the only number here that counts SENDS rather than intentions: a reservation
121
+ * increments it, a refund decrements it, and an externally reported send settles against a
122
+ * booking that was already counted. Anything that books one entry per increment therefore
123
+ * books exactly one entry per physical send -- which is what lets the durable spend ledger
124
+ * have a production caller without every dispatch site in the tree remembering to call it.
125
+ *
126
+ * `charge` may refuse, and a refusal denies the dispatch. That is deliberate: the ledger is
127
+ * the only bound here that survives a restart, so a limit it enforces has to be able to stop a
128
+ * send rather than merely describe one.
129
+ */
130
+ export interface RequestSendObserver {
131
+ /** Book one physical send. False refuses the dispatch before the budget charges it. */
132
+ charge(): boolean;
133
+ /** Give back a booking whose send never happened. */
134
+ refund(): void;
135
+ }
136
+
92
137
  /**
93
138
  * Carried on HandleResponsesOptions so a combo child, a rebuild and an alternate-account leg
94
139
  * all decrement the same holder. `used` is the existing #4605 counter and still counts every
@@ -103,6 +148,9 @@ export interface RequestExecutionBudget extends TransientSendBudget {
103
148
  * Sends still available from the base allowance, capped by a layer's own maximum.
104
149
  * Returns 0 when the allowance is gone -- it never floors to 1, because a floor of 1 is
105
150
  * what let every recovery leg send one more time forever.
151
+ *
152
+ * A reserved-but-unconfirmed send is spent for this purpose. The alternative -- counting only
153
+ * confirmed sends -- is what let two legs read the same remainder and both dispatch.
106
154
  */
107
155
  remainingBaseSends(cap: number): number;
108
156
  readonly reserveSpent: boolean;
@@ -120,17 +168,58 @@ const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
120
168
 
121
169
  let logicalRequestSeq = 0;
122
170
 
123
- export function createRequestExecutionBudget(
124
- policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY,
125
- logicalRequestId?: string,
171
+ /**
172
+ * One request's physical-send ledger, held apart from the budget object so a derived policy
173
+ * scope can share the exact same one.
174
+ *
175
+ * `spent` and `pendingExternalSends` belong together: a pending booking is a send that is
176
+ * already counted in `spent` and awaiting its reporter, so a scope that shared one without the
177
+ * other would either charge that send twice or never charge it at all.
178
+ *
179
+ * The durable-spend observer belongs here for the same reason. It books one entry per physical
180
+ * send by watching this counter move, so a derived scope that spent the counter without
181
+ * carrying the observer would move it without booking, and a combo child's sends would go
182
+ * missing from the ledger (#4707).
183
+ */
184
+ interface SharedSendLedger {
185
+ spent: number;
186
+ pendingExternalSends: number;
187
+ readonly observer?: RequestSendObserver;
188
+ }
189
+
190
+ const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>();
191
+
192
+ function createRequestExecutionBudgetWithLedger(
193
+ policy: RequestExecutionBudgetPolicy,
194
+ logicalRequestId: string | undefined,
195
+ counter: SharedSendLedger,
126
196
  ): RequestExecutionBudget {
197
+ const observer = counter.observer;
127
198
  let reserveSpent = false;
128
199
  let alternateTargetSends = 0;
129
200
  let targetTransitions = 0;
130
201
  let lastTargetKey: string | undefined;
131
202
 
132
203
  const budget: RequestExecutionBudget = {
133
- used: 0,
204
+ get used(): number { return counter.spent; },
205
+ set used(next: number) {
206
+ // The retry helpers report their real send count by assigning through this field. A
207
+ // reservation taken with `countedExternally` has already booked one of those sends, so
208
+ // the report settles the pending booking first and only the surplus is charged.
209
+ const delta = next - counter.spent;
210
+ if (delta <= 0) {
211
+ counter.spent = Math.max(0, next);
212
+ return;
213
+ }
214
+ const settled = Math.min(delta, counter.pendingExternalSends);
215
+ counter.pendingExternalSends -= settled;
216
+ const charged = delta - settled;
217
+ counter.spent += charged;
218
+ // These sends have already left. The ledger records them even past a ceiling it would
219
+ // have refused, because refusing after the fact only hides spend that was really
220
+ // incurred -- the refusal has to happen at the reservation below, or not at all.
221
+ for (let index = 0; index < charged; index += 1) observer?.charge();
222
+ },
134
223
  logicalRequestId: logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`,
135
224
  policyVersion: REQUEST_BUDGET_POLICY_VERSION,
136
225
  policy,
@@ -140,11 +229,11 @@ export function createRequestExecutionBudget(
140
229
  get lastTargetKey() { return lastTargetKey; },
141
230
  remainingBaseSends(cap: number): number {
142
231
  const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
143
- return Math.max(0, Math.min(capped, policy.baseSendAllowance - budget.used));
232
+ return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
144
233
  },
145
234
  reserveDispatch(intent: DispatchIntent): DispatchDecision {
146
235
  if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
147
- if (budget.used >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
236
+ if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
148
237
 
149
238
  const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey;
150
239
  const isAlternateTarget = changesTarget || intent.sendClass === "account-failover"
@@ -159,7 +248,7 @@ export function createRequestExecutionBudget(
159
248
  // The base allowance is spent first. Only once it is gone does a recovery class reach
160
249
  // for the single shared reserve -- an account move and a validated rebuild cannot each
161
250
  // take one.
162
- const drawsReserve = budget.remainingBaseSends(policy.baseSendAllowance) === 0;
251
+ const drawsReserve = policy.baseSendAllowance - counter.spent <= 0;
163
252
  if (drawsReserve) {
164
253
  if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) {
165
254
  return { allowed: false, reason: "base-allowance-exhausted" };
@@ -169,32 +258,118 @@ export function createRequestExecutionBudget(
169
258
  }
170
259
  }
171
260
 
172
- let consumed = false;
261
+ // Consulted last, because it is the only bound here that WRITES. A ledger entry booked
262
+ // for a dispatch a cheaper check above would have refused is spend this request never
263
+ // makes, and it would hold those tokens against the scope until retention expired.
264
+ if (observer && !observer.charge()) return { allowed: false, reason: "spend-exhausted" };
265
+
266
+ // THE RESERVATION IS THE CHARGE. Deciding here and charging in `use()` left a window in
267
+ // which two legs read the same remainder, both received a permit, and both dispatched:
268
+ // one remaining send admitted two physical sends, which is the per-request multiplication
269
+ // this budget exists to stop. Everything is booked now; `release()` is the way back.
270
+ const previousTargetKey = lastTargetKey;
271
+ counter.spent += 1;
272
+ if (intent.countedExternally === true) counter.pendingExternalSends += 1;
273
+ if (drawsReserve) reserveSpent = true;
274
+ if (isAlternateTarget) alternateTargetSends += 1;
275
+ if (changesTarget) targetTransitions += 1;
276
+ lastTargetKey = intent.targetKey;
277
+
278
+ let settled: "open" | "used" | "released" = "open";
173
279
  return {
174
280
  allowed: true,
175
281
  permit: {
176
282
  sendClass: intent.sendClass,
177
283
  use(): boolean {
178
- if (consumed) return false;
179
- consumed = true;
180
- // Charged here, immediately before the physical send, rather than reported after
181
- // the helper returns: a counter that is only reconciled afterwards cannot stop two
182
- // concurrent legs that both read the same remainder.
183
- if (intent.countedExternally !== true) budget.used += 1;
184
- if (drawsReserve) reserveSpent = true;
185
- if (isAlternateTarget) alternateTargetSends += 1;
186
- if (changesTarget) targetTransitions += 1;
187
- lastTargetKey = intent.targetKey;
284
+ if (settled !== "open") return false;
285
+ settled = "used";
188
286
  return true;
189
287
  },
288
+ assumeCharge(): boolean {
289
+ if (settled !== "open") return false;
290
+ settled = "used";
291
+ // The booking this reservation made for an external reporter is now owned by the
292
+ // caller. Leaving it pending is not harmless: the next `used` report of this request
293
+ // would settle against it and one real send would go uncharged.
294
+ if (intent.countedExternally === true && counter.pendingExternalSends > 0) {
295
+ counter.pendingExternalSends -= 1;
296
+ }
297
+ return true;
298
+ },
299
+ release(): void {
300
+ if (settled !== "open") return;
301
+ settled = "released";
302
+ // An externally counted reservation the reporter already settled paid for a send
303
+ // that physically happened. Refunding it would hand the request a free send back.
304
+ if (intent.countedExternally === true) {
305
+ if (counter.pendingExternalSends === 0) return;
306
+ counter.pendingExternalSends -= 1;
307
+ }
308
+ counter.spent -= 1;
309
+ observer?.refund();
310
+ if (drawsReserve) reserveSpent = false;
311
+ if (isAlternateTarget) alternateTargetSends -= 1;
312
+ if (changesTarget) targetTransitions -= 1;
313
+ lastTargetKey = previousTargetKey;
314
+ },
190
315
  },
191
316
  };
192
317
  },
193
318
  };
194
- if (lastTargetKey === undefined) lastTargetKey = undefined;
319
+ sharedSendLedgers.set(budget, counter);
195
320
  return budget;
196
321
  }
197
322
 
323
+ export function createRequestExecutionBudget(
324
+ policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY,
325
+ logicalRequestId?: string,
326
+ observer?: RequestSendObserver,
327
+ ): RequestExecutionBudget {
328
+ return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
329
+ spent: 0,
330
+ pendingExternalSends: 0,
331
+ ...(observer ? { observer } : {}),
332
+ });
333
+ }
334
+
335
+ /**
336
+ * A budget that applies its own policy and keeps its own recovery ledgers while spending the
337
+ * parent's exact physical-send ledger.
338
+ *
339
+ * Aliasing the public `used` property was not enough, and that is the whole defect. The factory
340
+ * reads its own private counter back in `remainingBaseSends`, in the total check, and in the
341
+ * reserve test, so an aliased scope answered every admission question from a counter that only
342
+ * ever saw its own reservations. A combo's per-target holdback is computed from
343
+ * `maxTotalModelSends` and is therefore unenforceable unless the scope actually observes what
344
+ * the request has already spent.
345
+ */
346
+ export function deriveRequestExecutionBudget(
347
+ parent: RequestExecutionBudget,
348
+ policy: RequestExecutionBudgetPolicy,
349
+ ): RequestExecutionBudget {
350
+ return createRequestExecutionBudgetWithLedger(policy, parent.logicalRequestId, ledgerFor(parent));
351
+ }
352
+
353
+ /**
354
+ * A budget that did not come from this factory still honors the public `used` contract, so
355
+ * bridge onto it rather than failing the request. `isRequestExecutionBudget` is a shape test,
356
+ * so a stub can reach here; turning that into a thrown error would convert a routing request
357
+ * into a 500 to report a condition production never produces. Only a factory-backed parent can
358
+ * share pending external bookings and a durable-spend observer, which are private by
359
+ * construction; a bridged scope keeps the parent's spend accurate and books nothing of its own.
360
+ */
361
+ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
362
+ const existing = sharedSendLedgers.get(parent);
363
+ if (existing) return existing;
364
+ let pendingExternalSends = 0;
365
+ return {
366
+ get spent(): number { return parent.used; },
367
+ set spent(next: number) { parent.used = next; },
368
+ get pendingExternalSends(): number { return pendingExternalSends; },
369
+ set pendingExternalSends(next: number) { pendingExternalSends = next; },
370
+ };
371
+ }
372
+
198
373
  export function isRequestExecutionBudget(
199
374
  value: TransientSendBudget | undefined,
200
375
  ): value is RequestExecutionBudget {