@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -5,7 +5,13 @@ import { workflowRefusalResponse } from "../workflow-refusal";
5
5
  import type { AttemptRecoveryKind } from "../../usage/log";
6
6
  import { noteAttemptSend } from "../request-log";
7
7
  import { TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry";
8
- import type { SingleUseDispatchPermit, SendClass } from "../../lib/request-execution-budget";
8
+ import type {
9
+ DispatchDecision,
10
+ DispatchIntent,
11
+ RequestExecutionBudget,
12
+ SendClass,
13
+ SingleUseDispatchPermit,
14
+ } from "../../lib/request-execution-budget";
9
15
 
10
16
  /** Owns the shared request send counter and recovery permits. */
11
17
  export function createResponsesSendBudget(
@@ -52,7 +58,7 @@ export function createResponsesSendBudget(
52
58
  /**
53
59
  * Records an adapter's OWN inner retries against this attempt.
54
60
  *
55
- * Ordinal 1 is the send each call site already recorded through `noteAttemptSend`, so only
61
+ * Ordinal 1 is the send each call site already recorded through `noteRoutedAttemptSend`, so only
56
62
  * the extra physical sends are added here and an adapter that does not retry internally
57
63
  * leaves its log byte-for-byte as it was. Kiro reaches roughly eighteen sends per call and
58
64
  * Cursor re-sends a whole turn, and both reported one; a count that cannot be observed
@@ -75,6 +81,32 @@ export function createResponsesSendBudget(
75
81
  * was recovering from.
76
82
  */
77
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
+ });
78
110
  /**
79
111
  * How many sends a recovery leg may make, and the permit that authorises the last one.
80
112
  *
@@ -147,6 +179,7 @@ export function createResponsesSendBudget(
147
179
  noteTransientSends,
148
180
  remainingTransientSendBudget,
149
181
  adapterSendBudget,
182
+ adapterDispatchBudget,
150
183
  noteAdapterPhysicalSend,
151
184
  sendBudgetExhausted,
152
185
  get pendingHopPermit(): SingleUseDispatchPermit | undefined {
@@ -162,3 +195,65 @@ export function createResponsesSendBudget(
162
195
  }
163
196
 
164
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,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
+ }
@@ -8,7 +8,7 @@ import {
8
8
  credentialGeneration,
9
9
  } from "../../oauth/store";
10
10
  import type { ProviderAdapter, AdapterRequest } from "../../adapters/base";
11
- import type { OcxParsedRequest, OcxProviderConfig } from "../../types";
11
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types";
12
12
  import type { AnthropicAccountSelectionReason } from "../../oauth/anthropic-routing";
13
13
  import {
14
14
  isAnthropicAccountPoolEnabled,
@@ -33,7 +33,7 @@ import {
33
33
  preferredInitialAccount,
34
34
  noteGenericPoolSelection,
35
35
  } from "../../oauth/generic-account-failover";
36
- import { stampOAuthAccountLabel } from "../../providers/label";
36
+ import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label";
37
37
  import { resolveProviderTransport } from "../../providers/xai-transport";
38
38
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
39
39
  import {
@@ -59,7 +59,11 @@ import {
59
59
  sealRequestAttemptIdentity,
60
60
  recordAttemptCredentialSource,
61
61
  recordAdapterTierMetadata,
62
+ noteProviderAttemptSend,
63
+ recordKeyAttemptFailure,
64
+ recordKeyAttemptUsage,
62
65
  } from "../request-log";
66
+ import type { AttemptRecoveryKind } from "../../usage/log";
63
67
  import { resolvePassiveRouteSubjectId } from "../passive-route-linker";
64
68
 
65
69
  /** Owns live credential selection and adapter bindings for one request. */
@@ -241,6 +245,30 @@ export async function prepareResponsesTransport(
241
245
  replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation };
242
246
  return true;
243
247
  };
248
+ // Key sends may be rebuilt while queued. Keep metadata pending until the guarded
249
+ // physical dispatch binds it to the selection that actually reaches the upstream.
250
+ let pendingKeySend: { estimate: number | undefined; recovery?: AttemptRecoveryKind } | undefined;
251
+ const noteRoutedAttemptSend = (estimate: number | undefined, recovery?: AttemptRecoveryKind): void => {
252
+ if (usesApiKeyAccount(route.provider)) pendingKeySend = { estimate, recovery };
253
+ else noteProviderAttemptSend(logCtx, route.providerName, route.provider, estimate, recovery);
254
+ };
255
+ const commitKeyAttemptSend = (): void => {
256
+ if (!usesApiKeyAccount(route.provider)) return;
257
+ noteProviderAttemptSend(logCtx, route.providerName, route.provider,
258
+ pendingKeySend?.estimate ?? logCtx.usageLogInputTokens, pendingKeySend?.recovery);
259
+ pendingKeySend = undefined;
260
+ };
261
+ const bindKeyUsageFromBridge = (usage: OcxUsage | undefined): void => {
262
+ logCtx.usageFromBridge = true;
263
+ if (usesApiKeyAccount(route.provider)) {
264
+ logCtx.usage = logCtx.activeAttempt?.usage;
265
+ return;
266
+ }
267
+ if (usage) {
268
+ logCtx.usage = usage;
269
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
270
+ }
271
+ };
244
272
  const selectionIsCurrent = (binding: DispatchBinding | undefined): boolean => {
245
273
  if (route.provider.authMode === "forward") return true;
246
274
  if (!binding) return false;
@@ -260,6 +288,27 @@ export async function prepareResponsesTransport(
260
288
  : undefined
261
289
  : { kind: "api-key", provider: { ...route.provider } };
262
290
  if (binding) adapterBindings.set(resolved, binding);
291
+ // Observe terminals before search/image loops or continuation guards hide earlier rounds.
292
+ // Each adapter parser is called once per physical response; bridge totals are client-only.
293
+ const observedResponses = new WeakSet<object>();
294
+ const observeUsage = (event: AdapterEvent, response: object): void => {
295
+ if (usesApiKeyAccount(provider) && "usage" in event && event.usage && !observedResponses.has(response)) {
296
+ observedResponses.add(response);
297
+ recordKeyAttemptUsage(logCtx, event.usage);
298
+ }
299
+ };
300
+ const parseStream = resolved.parseStream.bind(resolved);
301
+ resolved.parseStream = async function* (...args) {
302
+ for await (const event of parseStream(...args)) { observeUsage(event, args[0]); yield event; }
303
+ };
304
+ if (resolved.parseResponse) {
305
+ const parseResponse = resolved.parseResponse.bind(resolved);
306
+ resolved.parseResponse = async (...args) => {
307
+ const events = await parseResponse(...args);
308
+ events.forEach(event => observeUsage(event, args[0]));
309
+ return events;
310
+ };
311
+ }
263
312
  const build = resolved.buildRequest.bind(resolved);
264
313
  resolved.buildRequest = async (requestParsed, incoming) => {
265
314
  const request = await build(requestParsed, incoming);
@@ -268,7 +317,11 @@ export async function prepareResponsesTransport(
268
317
  return request;
269
318
  };
270
319
  if (resolved.runTurn) {
271
- rawRunTurns.set(resolved, resolved.runTurn.bind(resolved));
320
+ const runTurn = resolved.runTurn.bind(resolved);
321
+ rawRunTurns.set(resolved, (requestParsed, incoming, emit) => {
322
+ const response = {};
323
+ return runTurn(requestParsed, incoming, event => { observeUsage(event, response); emit(event); });
324
+ });
272
325
  resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit);
273
326
  }
274
327
  return resolved;
@@ -319,6 +372,7 @@ export async function prepareResponsesTransport(
319
372
  refused = true;
320
373
  throw new Error("Account selection changed before the first turn dispatch");
321
374
  }
375
+ commitKeyAttemptSend();
322
376
  sent = true;
323
377
  },
324
378
  });
@@ -351,7 +405,9 @@ export async function prepareResponsesTransport(
351
405
  && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}`
352
406
  && !sentHeaders?.has("x-api-key");
353
407
  // Reselection can choose a provider override instead of the supplied executor.
408
+ commitKeyAttemptSend();
354
409
  const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" });
410
+ if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
355
411
  // Observe each physical response before retries replace it. The binding belongs to
356
412
  // this dispatch, so a manual switch cannot file A's headers against B. Header
357
413
  // overrides and credential replacement make ownership unprovable: skip those writes.
@@ -736,6 +792,9 @@ export async function prepareResponsesTransport(
736
792
  resolveSelectionAdapter,
737
793
  refreshRunTurnAdapter,
738
794
  oauthDispatch,
795
+ noteRoutedAttemptSend,
796
+ commitKeyAttemptSend,
797
+ bindKeyUsageFromBridge,
739
798
  anthropicSessionKey,
740
799
  isPassthrough,
741
800
  };
@@ -12,14 +12,15 @@ import {
12
12
  adapterNeedsForcedContinuation,
13
13
  adapterResponseReachedServingTerminal,
14
14
  } from "./core-replay";
15
- import { sealRequestAttemptIdentity, noteAttemptSend, recordAttemptCredentialSource } from "../request-log";
15
+ import { sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
16
16
  import { waitForProviderRequestSlot, RequestPacingQueueOverloadError } from "../../providers/request-pacing";
17
17
  import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
18
18
  import type { AttemptRecoveryKind } from "../../usage/log";
19
19
  import { providerFetch } from "./fetch-helpers";
20
20
  import { normalizeLogConversationId } from "../request-log-conversation";
21
21
  import type { AdapterEvent, OcxProviderContinuationState } from "../../types";
22
- import { adapterFailureFromMessage } from "../../lib/errors";
22
+ import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
23
+ import { SendBudgetExhaustedError } from "../../lib/upstream-retry";
23
24
  import {
24
25
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
25
26
  isGenericOAuthFailoverEnabled,
@@ -65,6 +66,8 @@ export async function executeResponsesRunTurn(
65
66
  | "applyFailoverSnapshot"
66
67
  | "resolveSelectionAdapter"
67
68
  | "adapter"
69
+ | "noteRoutedAttemptSend"
70
+ | "bindKeyUsageFromBridge"
68
71
  >,
69
72
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
70
73
  responseEffects: Pick<
@@ -74,7 +77,10 @@ export async function executeResponsesRunTurn(
74
77
  | "continuationStateForResponse"
75
78
  | "notifyResponseComplete"
76
79
  >,
77
- sendBudgetState: Pick<ResponsesSendBudget, "adapterSendBudget" | "reserveCredentialHop">,
80
+ sendBudgetState: Pick<
81
+ ResponsesSendBudget,
82
+ "adapterDispatchBudget" | "reserveCredentialHop" | "pendingHopPermit"
83
+ >,
78
84
  completionPolicy: Pick<ResponsesCompletionPolicy, "emptyCompletionGuardEnabled">,
79
85
  ): Promise<Response> {
80
86
  const { options, logCtx, config } = requestContext;
@@ -94,7 +100,7 @@ export async function executeResponsesRunTurn(
94
100
  rememberKiroDeliveredFinalAnswer,
95
101
  responseStateOptions,
96
102
  } = requestState;
97
- const { adapterSendBudget, reserveCredentialHop } = sendBudgetState;
103
+ const { adapterDispatchBudget, reserveCredentialHop } = sendBudgetState;
98
104
  const { emptyCompletionGuardEnabled } = completionPolicy;
99
105
  const {
100
106
  cancelResponseCompletion,
@@ -140,7 +146,7 @@ export async function executeResponsesRunTurn(
140
146
  await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
141
147
  }
142
148
  await refreshRunTurnSelection();
143
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery);
149
+ transportState.noteRoutedAttemptSend(logCtx.usageLogInputTokens, recovery);
144
150
  const runTurnProviderFetch = providerFetch(
145
151
  route.provider,
146
152
  options.codexWsRuntimeIdentity,
@@ -162,7 +168,7 @@ export async function executeResponsesRunTurn(
162
168
  providerFetch: runTurnProviderFetch,
163
169
  // The only way the request budget reaches a transport the adapter owns. Without it
164
170
  // a Cursor turn's inner ladder was three physical sends the cap read as one.
165
- ...(adapterSendBudget ? { sendBudget: adapterSendBudget } : {}),
171
+ ...(adapterDispatchBudget ? { sendBudget: adapterDispatchBudget } : {}),
166
172
  },
167
173
  targetQueue.push,
168
174
  );
@@ -175,10 +181,22 @@ export async function executeResponsesRunTurn(
175
181
  retryable: true,
176
182
  message: err.message,
177
183
  }
178
- : {
179
- type: "error",
180
- message: err instanceof Error ? err.message : String(err),
181
- });
184
+ : err instanceof SendBudgetExhaustedError
185
+ // A structured terminal, not a bare message. The turn is already committed to an
186
+ // SSE response by the time most of these arrive, so the only way to carry "this
187
+ // proxy refused" to the client is on the event itself -- an unstructured message
188
+ // is inferred back to 502, which the Codex client retries.
189
+ ? {
190
+ type: "error",
191
+ status: 429,
192
+ errorType: "rate_limit_error",
193
+ code: SEND_BUDGET_EXHAUSTED_CODE,
194
+ message: err.message,
195
+ }
196
+ : {
197
+ type: "error",
198
+ message: err instanceof Error ? err.message : String(err),
199
+ });
182
200
  } finally {
183
201
  // Cursor assigns a stable conversation id inside runTurn on the first headerless
184
202
  // turn; backfill so Logs can filter/total that opening request (#330 / #522).
@@ -192,6 +210,11 @@ export async function executeResponsesRunTurn(
192
210
  const rotateRunTurnAdapterOnPreflight429 = async (
193
211
  error: Extract<AdapterEvent, { type: "error" }>,
194
212
  ): Promise<boolean> => {
213
+ // Our own refusal wears a 429 now, and rotating on it would record a cooldown against an
214
+ // account that never rate-limited anything -- a fake quota signal that outlives the
215
+ // request and misroutes later ones. The passthrough path has never had this problem
216
+ // because it answers before any rotation arm is reached.
217
+ if (error.code === SEND_BUDGET_EXHAUSTED_CODE) return false;
195
218
  const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus;
196
219
  if (
197
220
  status !== 429
@@ -258,8 +281,11 @@ export async function executeResponsesRunTurn(
258
281
  });
259
282
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
260
283
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name);
261
- // The caller replays the turn on this rotation, so the reservation is now confirmed.
262
- hop.permit?.use();
284
+ // The caller replays the turn on this rotation, and a runTurn adapter dispatches through
285
+ // its own reservation ladder -- Cursor reserves once per physical send. Confirming here
286
+ // would leave that ladder to charge the same replay a second time (#4709), so hand the
287
+ // reservation down and let the send that actually happens spend it.
288
+ sendBudgetState.pendingHopPermit = hop.permit;
263
289
  return true;
264
290
  } catch {
265
291
  hop.permit?.release();
@@ -270,16 +296,24 @@ export async function executeResponsesRunTurn(
270
296
  firstSource: AsyncIterable<AdapterEvent>,
271
297
  ): Promise<AsyncIterable<AdapterEvent>> => {
272
298
  let source = firstSource;
273
- while (true) {
274
- const preflight = await preflightAdapterEvents(source);
275
- if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
276
- return preflight.stream;
299
+ try {
300
+ while (true) {
301
+ const preflight = await preflightAdapterEvents(source);
302
+ if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
303
+ return preflight.stream;
304
+ }
305
+ const retryQueue = createAdapterEventQueue({
306
+ onBacklogExceeded: () => runTurnAbort.abort(),
307
+ });
308
+ void runTurnAttempt(retryQueue, "oauth-account-429");
309
+ source = retryQueue.stream();
277
310
  }
278
- const retryQueue = createAdapterEventQueue({
279
- onBacklogExceeded: () => runTurnAbort.abort(),
280
- });
281
- void runTurnAttempt(retryQueue, "oauth-account-429");
282
- source = retryQueue.stream();
311
+ } finally {
312
+ // A handed-down hop reservation belongs to the replay this loop dispatched, and the
313
+ // loop only leaves after that replay's first event has arrived -- so the adapter has
314
+ // already reserved if it was ever going to. Dropping the reference here keeps an
315
+ // adapter that reserves nothing from leaving a free send for an unrelated later leg.
316
+ sendBudgetState.pendingHopPermit = undefined;
283
317
  }
284
318
  };
285
319
  // The empty-completion retry re-runs the turn against a fresh queue: the
@@ -340,6 +374,7 @@ export async function executeResponsesRunTurn(
340
374
  stallTimeoutSec: config.stallTimeoutSec,
341
375
  hideThinkingSummary: parsed.options.hideThinkingSummary,
342
376
  declaredToolNames,
377
+ enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic",
343
378
  toolParameterSchemas,
344
379
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
345
380
  ...(routedCompaction ? { compaction: true } : {}),
@@ -349,11 +384,7 @@ export async function executeResponsesRunTurn(
349
384
  onUsage: usage => {
350
385
  // Raw adapter usage, pre wire-normalization: the bridged SSE now always carries
351
386
  // zero-default detail objects, so provenance must come from here (cache_detail_missing).
352
- logCtx.usageFromBridge = true;
353
- if (usage) {
354
- logCtx.usage = usage;
355
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
356
- }
387
+ transportState.bindKeyUsageFromBridge(usage);
357
388
  },
358
389
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
359
390
  commitReasoningReplayServingRoute();
@@ -414,17 +445,14 @@ export async function executeResponsesRunTurn(
414
445
  hideThinkingSummary: parsed.options.hideThinkingSummary,
415
446
  toolNsMap,
416
447
  declaredToolNames,
448
+ enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic",
417
449
  toolParameterSchemas,
418
450
  freeformToolNames,
419
451
  toolSearchToolNames,
420
452
  ...(routedCompaction ? { compaction: true } : {}),
421
453
  onProviderState: state => { providerState = state; },
422
454
  onUsage: usage => {
423
- logCtx.usageFromBridge = true;
424
- if (usage) {
425
- logCtx.usage = usage;
426
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
427
- }
455
+ transportState.bindKeyUsageFromBridge(usage);
428
456
  },
429
457
  });
430
458
  if (!routedCompaction) {
@@ -35,7 +35,7 @@ import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation } from ".
35
35
  import { namespacedToolName } from "../../types";
36
36
  import { providerFetch } from "./fetch-helpers";
37
37
  import type { AttemptRecoveryKind } from "../../usage/log";
38
- import { noteAttemptSend, recordAdapterReasoning, recordAdapterTier } from "../request-log";
38
+ import { recordAdapterReasoning, recordAdapterTier } from "../request-log";
39
39
  import { normalizeLogConversationId } from "../request-log-conversation";
40
40
  import { rememberResponseState } from "../../responses/state";
41
41
  import { trackStreamLifetime } from "../lifecycle";
@@ -65,6 +65,8 @@ export async function executeResponsesSidecars(
65
65
  | "commitResolvedOAuthSelection"
66
66
  | "resolveSelectionAdapter"
67
67
  | "oauthDispatch"
68
+ | "noteRoutedAttemptSend"
69
+ | "bindKeyUsageFromBridge"
68
70
  >,
69
71
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction" | "openAiSidecar">,
70
72
  responseEffects: Pick<
@@ -322,7 +324,7 @@ export async function executeResponsesSidecars(
322
324
  ...(vidPlan ? { videoPlan: vidPlan } : {}),
323
325
  forwardHeaders: requestState.selectedForwardHeaders,
324
326
  onAttemptSend: (recovery?: AttemptRecoveryKind) =>
325
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
327
+ transportState.noteRoutedAttemptSend(logCtx.usageLogInputTokens, recovery),
326
328
  abortSignal: options.abortSignal,
327
329
  maxRounds: imgPlan && vidPlan
328
330
  ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
@@ -352,11 +354,7 @@ export async function executeResponsesSidecars(
352
354
  if (!logCtx.conversationId && parsed._cursorConversationId) {
353
355
  logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
354
356
  }
355
- logCtx.usageFromBridge = true;
356
- if (usage) {
357
- logCtx.usage = usage;
358
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
359
- }
357
+ transportState.bindKeyUsageFromBridge(usage);
360
358
  },
361
359
  on429: rotateSidecarProviderOn429,
362
360
  retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
@@ -432,13 +430,9 @@ export async function executeResponsesSidecars(
432
430
  recordAdapterTier(logCtx, request);
433
431
  },
434
432
  onAttemptSend: (recovery?: AttemptRecoveryKind) =>
435
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
433
+ transportState.noteRoutedAttemptSend(logCtx.usageLogInputTokens, recovery),
436
434
  onUsage: usage => {
437
- logCtx.usageFromBridge = true;
438
- if (usage) {
439
- logCtx.usage = usage;
440
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
441
- }
435
+ transportState.bindKeyUsageFromBridge(usage);
442
436
  },
443
437
  recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
444
438
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,