@bitkyc08/opencodex 2.56.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 (145) 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-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,153 @@
1
+ /** Bounds diagnostic retention, never the bytes delivered to the caller. */
2
+ export const MAX_RESPONSE_LOG_INSPECTION_BYTES = 32 * 1024 * 1024;
3
+ export const MAX_NON_JSON_ERROR_INSPECTION_BYTES = 8 * 1024;
4
+ const INSPECTION_BLOCK_BYTES = 64 * 1024;
5
+
6
+ export type ResponseLogBodyEnd = "eof" | "read_error" | "cancel";
7
+
8
+ export interface ResponseLogBodyOptions {
9
+ json: boolean;
10
+ inspect: (text: string) => void;
11
+ finalize: (reason: ResponseLogBodyEnd) => void;
12
+ /** Internal test seam; this is not a provider-controlled limit. */
13
+ maxInspectionBytes?: number;
14
+ }
15
+
16
+ /** Fixed-size blocks bound both retained bytes and per-chunk bookkeeping. */
17
+ class ResponseLogInspection {
18
+ private blocks: Uint8Array[] = [];
19
+ private bytes = 0;
20
+ private overflowed = false;
21
+
22
+ constructor(private readonly json: boolean, private readonly limit: number) {}
23
+
24
+ append(chunk: Uint8Array): void {
25
+ if (this.overflowed || chunk.byteLength === 0) return;
26
+ if (this.json && chunk.byteLength > this.limit - this.bytes) {
27
+ // A JSON prefix is not an authoritative response. Drop it immediately,
28
+ // without either truncating the delivery stream or retaining later bytes.
29
+ this.overflowed = true;
30
+ this.dispose();
31
+ return;
32
+ }
33
+ let remaining = Math.min(chunk.byteLength, this.limit - this.bytes);
34
+ let offset = 0;
35
+ while (remaining > 0) {
36
+ const blockOffset = this.bytes % INSPECTION_BLOCK_BYTES;
37
+ if (blockOffset === 0) {
38
+ this.blocks.push(new Uint8Array(Math.min(INSPECTION_BLOCK_BYTES, this.limit - this.bytes)));
39
+ }
40
+ const block = this.blocks[this.blocks.length - 1]!;
41
+ const length = Math.min(remaining, block.byteLength - blockOffset);
42
+ block.set(chunk.subarray(offset, offset + length), blockOffset);
43
+ this.bytes += length;
44
+ offset += length;
45
+ remaining -= length;
46
+ }
47
+ }
48
+
49
+ text(reason: ResponseLogBodyEnd): string | undefined {
50
+ // Even a syntactically valid JSON prefix must not update usage/model
51
+ // metadata when the transport did not reach EOF.
52
+ if (this.json && (reason !== "eof" || this.overflowed)) return undefined;
53
+ const combined = new Uint8Array(this.bytes);
54
+ let offset = 0;
55
+ for (const block of this.blocks) {
56
+ const length = Math.min(block.byteLength, this.bytes - offset);
57
+ combined.set(block.subarray(0, length), offset);
58
+ offset += length;
59
+ }
60
+ return new TextDecoder().decode(combined);
61
+ }
62
+
63
+ dispose(): void {
64
+ this.blocks.length = 0;
65
+ this.bytes = 0;
66
+ }
67
+ }
68
+
69
+ /** Optional diagnostics must not change the response's transport outcome. */
70
+ function bestEffort(callback: () => void): void {
71
+ try {
72
+ callback();
73
+ } catch {
74
+ return;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Forward one upstream read per downstream pull, with bounded side inspection.
80
+ * EOF, read failure and cancellation each finalize at most once. Cancellation
81
+ * does not await the upstream cancel promise: one branch of a tee can otherwise
82
+ * wait for a sibling that the same caller intends to consume or cancel later.
83
+ */
84
+ export function createBoundedResponseLogBody(
85
+ body: ReadableStream<Uint8Array>,
86
+ options: ResponseLogBodyOptions,
87
+ ): ReadableStream<Uint8Array> {
88
+ const limit = options.maxInspectionBytes ?? (options.json
89
+ ? MAX_RESPONSE_LOG_INSPECTION_BYTES
90
+ : MAX_NON_JSON_ERROR_INSPECTION_BYTES);
91
+ if (!Number.isSafeInteger(limit) || limit < 0) {
92
+ throw new RangeError("Response log inspection limit must be a non-negative safe integer");
93
+ }
94
+ const inspection = new ResponseLogInspection(options.json, limit);
95
+ const reader = body.getReader();
96
+ let ended = false;
97
+ let inspectionFailed = false;
98
+
99
+ const release = () => bestEffort(() => reader.releaseLock());
100
+ const finish = (reason: ResponseLogBodyEnd) => {
101
+ if (ended) return;
102
+ ended = true; // Set before callbacks or a pending read resumes.
103
+ try {
104
+ if (!inspectionFailed) {
105
+ bestEffort(() => {
106
+ const text = inspection.text(reason);
107
+ if (text !== undefined) options.inspect(text);
108
+ });
109
+ }
110
+ } finally {
111
+ inspection.dispose();
112
+ bestEffort(() => options.finalize(reason));
113
+ }
114
+ };
115
+
116
+ return new ReadableStream<Uint8Array>({
117
+ async pull(controller) {
118
+ if (ended) return;
119
+ let result: Awaited<ReturnType<typeof reader.read>>;
120
+ try {
121
+ result = await reader.read();
122
+ } catch (error) {
123
+ if (ended) return; // Cancellation owns its pending-read settlement.
124
+ finish("read_error");
125
+ release();
126
+ controller.error(error);
127
+ return;
128
+ }
129
+ if (ended) return;
130
+ if (result.done) {
131
+ finish("eof");
132
+ release();
133
+ controller.close();
134
+ return;
135
+ }
136
+ if (!inspectionFailed) {
137
+ try {
138
+ inspection.append(result.value);
139
+ } catch {
140
+ inspectionFailed = true;
141
+ inspection.dispose();
142
+ }
143
+ }
144
+ // Do not decode/re-encode transport bytes, including malformed UTF-8.
145
+ controller.enqueue(result.value);
146
+ },
147
+ cancel(reason) {
148
+ finish("cancel");
149
+ bestEffort(() => { void reader.cancel(reason).catch(() => undefined); });
150
+ release();
151
+ },
152
+ }, { highWaterMark: 0 });
153
+ }
@@ -17,9 +17,32 @@ import {
17
17
  } from "../../codex/routing";
18
18
  import type { OcxParsedRequest } from "../../types";
19
19
  import type { RequestLogContext } from "../request-log";
20
+ import { formatErrorResponse } from "../../bridge/errors";
20
21
 
21
22
  export type ConversationStateScrubReason = "account-change";
22
23
 
24
+ /**
25
+ * Cause AND remedy, because this refusal does not clear itself.
26
+ *
27
+ * Dropping a `previous_response_id` costs one cold turn and the conversation continues. This is
28
+ * not that. The file reference lives in the conversation's history, so once pool rotation has
29
+ * moved a conversation carrying an attachment, every following turn presents the same reference
30
+ * and is refused the same way. A caller told only that the reference is invalid will send the
31
+ * same request back and watch the conversation appear dead, which is the one outcome a refusal
32
+ * is supposed to prevent. So the text says what happened, that it will keep happening, and the
33
+ * two things that actually end it.
34
+ *
35
+ * Names no account id, no file id, and no conversation id.
36
+ */
37
+ export const ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE =
38
+ "This conversation is now being served by a different account than the one its uploaded files "
39
+ + "were sent to, and an uploaded file can only be read by the account that received it. The "
40
+ + "request was not sent upstream, and no file was removed from it. Because the references stay "
41
+ + "in this conversation's history, later turns will be refused the same way until this is "
42
+ + "resolved: re-upload the files so they are issued by the account now serving this "
43
+ + "conversation, or start a new conversation for them. Sending the same request again "
44
+ + "unchanged will not clear it.";
45
+
23
46
  export type PortabilityDenial =
24
47
  | "previous-response-id"
25
48
  | "provider-conversation-id"
@@ -144,6 +167,21 @@ export function collectConversationStateCarriers(body: unknown): ConversationSta
144
167
  };
145
168
  }
146
169
 
170
+ /**
171
+ * Does this body reference an uploaded file?
172
+ *
173
+ * Answerable from the body alone, which is what lets an alternate-account path ask BEFORE it
174
+ * resolves an alternate: the answer cannot depend on which account is chosen, because an
175
+ * uploaded file is readable only by the account it was sent to (#4710).
176
+ *
177
+ * `fileIds` is optional on the carrier type, so the emptiness test lives here rather than being
178
+ * rewritten at each caller. One of those rewrites already dereferenced it directly.
179
+ */
180
+ export function conversationCarriesUploadedFiles(body: unknown): boolean {
181
+ const fileIds = collectConversationStateCarriers(body).fileIds;
182
+ return fileIds !== undefined && fileIds.length > 0;
183
+ }
184
+
147
185
 
148
186
  /**
149
187
  * Drop account-bound continuation from a request body in place. Readable user
@@ -231,3 +269,39 @@ export function applyAccountChangeConversationStateScrub(
231
269
  }
232
270
  return true;
233
271
  }
272
+
273
+ /**
274
+ * Refuse an account change that would carry an uploaded-file reference to an account that
275
+ * cannot read it (#4710).
276
+ *
277
+ * The classifier has always called `file_id` account-bound, and the scrubber has always removed
278
+ * only `previous_response_id` and `conversation`. A body whose ONLY account-bound state was an
279
+ * uploaded file therefore reported nothing scrubbed and was replayed unchanged against the new
280
+ * account, which is the one case the safety fix was supposed to cover.
281
+ *
282
+ * Deleting the references is not the fix. A file reference is not continuation state the model
283
+ * can do without: it is content the caller attached, and silently dropping it answers a
284
+ * different question than the one that was asked, with no way for the caller to tell. Pinning
285
+ * the request to the issuing account is not available either — every call site resolves and
286
+ * materialises its credential before reaching here, and the retry sites are reached precisely
287
+ * because the issuing account just refused the request.
288
+ *
289
+ * So the move is refused, before dispatch, and the caller is told exactly what to do about it.
290
+ * A 400 rather than a 409: re-uploading is required, and a retryable status would invite the
291
+ * same request back unchanged.
292
+ */
293
+ export function accountChangeFileReferenceRefusal(
294
+ args: Pick<ApplyAccountChangeConversationStateScrubArgs, "body" | "bindingKey" | "servingAccountId" | "priorAccountId">,
295
+ ): Response | undefined {
296
+ const { body, bindingKey, servingAccountId, priorAccountId } = args;
297
+ if (!servingAccountId || !bindingKey) return undefined;
298
+ const issuer = peekConversationStateIssuer(bindingKey);
299
+ const accountChanged = (issuer != null && issuer !== servingAccountId)
300
+ || (priorAccountId != null && priorAccountId !== servingAccountId);
301
+ if (!accountChanged) return undefined;
302
+ // Checked against the carriers directly rather than through the portability verdict: that
303
+ // verdict reports the FIRST reason it finds, so a body carrying both a previous response id
304
+ // and a file reference reports only the former and the file would slip through the scrub.
305
+ if (!conversationCarriesUploadedFiles(body)) return undefined;
306
+ return formatErrorResponse(400, "invalid_request_error", ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE);
307
+ }
@@ -10,7 +10,6 @@ import type { AdapterRequest } from "../../adapters/base";
10
10
  import {
11
11
  recordAdapterReasoning,
12
12
  recordAdapterTier,
13
- noteAttemptSend,
14
13
  sealRequestAttemptIdentity,
15
14
  recordAttemptCredentialSource,
16
15
  } from "../request-log";
@@ -26,6 +25,7 @@ import {
26
25
  fetchWithTransientRetry,
27
26
  fetchWithResetRetry,
28
27
  applyUpstreamRecoveryInit,
28
+ isNonReplayableResponse,
29
29
  prepareSameTarget429Wait,
30
30
  } from "../../lib/upstream-retry";
31
31
  import { redactSecretString } from "../../lib/redact";
@@ -78,15 +78,18 @@ export function createAdapterContinuations(
78
78
  | "genericFailoverAccountId"
79
79
  | "genericFailovers"
80
80
  | "applyFailoverSnapshot"
81
+ | "noteRoutedAttemptSend"
81
82
  >,
82
83
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
83
84
  sendBudgetState: Pick<
84
85
  ResponsesSendBudget,
85
- | "adapterSendBudget"
86
+ | "adapterDispatchBudget"
86
87
  | "noteAdapterPhysicalSend"
87
88
  | "remainingTransientSendBudget"
88
89
  | "noteTransientSends"
89
90
  | "reserveCredentialHop"
91
+ | "pendingHopPermit"
92
+ | "sendBudgetExhausted"
90
93
  >,
91
94
  adapterExchange: Pick<
92
95
  AdapterExchange,
@@ -110,11 +113,12 @@ export function createAdapterContinuations(
110
113
  const { routedCompaction } = sidecarState;
111
114
  const { upstream, connectMs, rateLimitPolicy, stallTimeoutMs } = adapterExchange;
112
115
  const {
113
- adapterSendBudget,
116
+ adapterDispatchBudget,
114
117
  noteAdapterPhysicalSend,
115
118
  remainingTransientSendBudget,
116
119
  noteTransientSends,
117
120
  reserveCredentialHop,
121
+ sendBudgetExhausted,
118
122
  } = sendBudgetState;
119
123
 
120
124
 
@@ -182,15 +186,16 @@ export function createAdapterContinuations(
182
186
  const replayKind: AttemptRecoveryKind | undefined = recoveryKind;
183
187
  try {
184
188
  if (transportState.activeAdapter.fetchResponse) {
185
- noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind);
189
+ transportState.noteRoutedAttemptSend(continuationEstimate, replayKind);
186
190
  await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal);
187
191
  return await transportState.activeAdapter.fetchResponse(builtContinuationRequest, {
188
192
  abortSignal: upstream.signal,
189
193
  timeoutMs: connectMs,
190
- sendBudget: adapterSendBudget,
194
+ sendBudget: adapterDispatchBudget,
191
195
  onPhysicalSend: send => noteAdapterPhysicalSend(continuationEstimate, send),
192
196
  stream: nextParsed.stream,
193
197
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
198
+ pacingSlotAcquired: true,
194
199
  dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed),
195
200
  providerName: route.providerName,
196
201
  modelId: nextParsed.modelId,
@@ -205,7 +210,7 @@ export function createAdapterContinuations(
205
210
  : fetchWithResetRetry;
206
211
  return await fetchContinuationWithRetryPolicy(
207
212
  recovery => {
208
- noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind);
213
+ transportState.noteRoutedAttemptSend(continuationEstimate, recovery ?? replayKind);
209
214
  return fetchWithHeaderTimeout(
210
215
  builtContinuationRequest.url,
211
216
  applyUpstreamRecoveryInit({
@@ -260,8 +265,16 @@ export function createAdapterContinuations(
260
265
  // loop; only after the attempts are exhausted does the continuation fail over.
261
266
  while (
262
267
  response.status === 429
268
+ // A synthesized replay refusal is not a rate limit; replaying the continuation on
269
+ // it would re-send a turn whose first send may already have been processed.
270
+ && !isNonReplayableResponse(response)
263
271
  && rateLimitPolicy !== null
264
272
  && adapterExchange.rateLimitRetries < rateLimitPolicy.attempts
273
+ // The main recovery loop and the passthrough ladder both consult the shared remainder
274
+ // here; this loop did not, so a request whose budget was already spent could still
275
+ // same-key replay on a live stream. Checked BEFORE the wait below cancels the body, so
276
+ // a refusal keeps the real upstream 429 -- status, Retry-After, quota evidence -- intact.
277
+ && !sendBudgetExhausted()
265
278
  ) {
266
279
  adapterExchange.rateLimitRetries += 1;
267
280
  // Release unread body + heartbeat-fed wait via the shared same-target helper.
@@ -302,7 +315,7 @@ export function createAdapterContinuations(
302
315
  }
303
316
  }
304
317
 
305
- if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
318
+ if (response.status === 429 && !isNonReplayableResponse(response) && hasKeyPoolFailover(route.provider)) {
306
319
  const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
307
320
  retryAfter: response.headers.get("retry-after"),
308
321
  now: Date.now(),
@@ -337,6 +350,7 @@ export function createAdapterContinuations(
337
350
  }
338
351
  if (
339
352
  response.status === 429
353
+ && !isNonReplayableResponse(response)
340
354
  && transportState.anthropicPoolAccountId
341
355
  && transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
342
356
  ) {
@@ -378,6 +392,7 @@ export function createAdapterContinuations(
378
392
  // the per-request bound cannot be silently re-armed by reaching a different loop.
379
393
  if (
380
394
  response.status === 429
395
+ && !isNonReplayableResponse(response)
381
396
  && transportState.genericFailoverAccountId
382
397
  && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
383
398
  && isGenericOAuthFailoverEnabled(config, route.providerName)
@@ -385,9 +400,15 @@ export function createAdapterContinuations(
385
400
  // Intersection with the shared request budget. The continuation loop re-sends the
386
401
  // turn, so without this the per-request bound could be re-armed simply by reaching a
387
402
  // different loop -- which is the divergence the comment above already warns about.
403
+ //
404
+ // Who settles the reservation depends on who sends the replay (#4709). An adapter that
405
+ // owns its ladder reserves once per physical send and would charge this replay twice;
406
+ // the helper path reports it back instead, which is what `countedExternally` names.
407
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
388
408
  const hop = reserveCredentialHop(
389
409
  "auth-recovery",
390
410
  `${route.providerName}|${route.modelId}|continuation-oauth-429`,
411
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
391
412
  );
392
413
  const nextAccountId = hop.allowed
393
414
  ? rotateGenericOAuthAccountOn429(
@@ -417,6 +438,11 @@ export function createAdapterContinuations(
417
438
  );
418
439
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
419
440
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
441
+ // The replay goes out on the next iteration. An adapter that owns its ladder
442
+ // reserves for that send itself, so hand this reservation down rather than let it
443
+ // take a second one for the same replay. A helper-routed replay needs no handoff:
444
+ // its reporter settles the booking made above.
445
+ if (adapterOwnsDispatch) sendBudgetState.pendingHopPermit = hop.permit;
420
446
  nextContinuationRecoveryKind = "oauth-account-429";
421
447
  continue;
422
448
  }
@@ -26,7 +26,7 @@ export async function deliverAdapterResponse(
26
26
  | "rememberKiroDeliveredFinalAnswer"
27
27
  | "responseStateOptions"
28
28
  >,
29
- transportState: Pick<ResponsesTransport, "activeAdapter">,
29
+ transportState: Pick<ResponsesTransport, "activeAdapter" | "bindKeyUsageFromBridge">,
30
30
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
31
31
  responseEffects: Pick<
32
32
  ResponsesEffects,
@@ -99,6 +99,7 @@ export async function deliverAdapterResponse(
99
99
  stallTimeoutSec: config.stallTimeoutSec,
100
100
  hideThinkingSummary: parsed.options.hideThinkingSummary,
101
101
  declaredToolNames,
102
+ enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
102
103
  toolParameterSchemas,
103
104
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
104
105
  ...(routedCompaction ? { compaction: true } : {}),
@@ -106,11 +107,7 @@ export async function deliverAdapterResponse(
106
107
  ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}),
107
108
  onUsage: usage => {
108
109
  // Raw adapter usage, pre wire-normalization (see the runTurn branch above).
109
- logCtx.usageFromBridge = true;
110
- if (usage) {
111
- logCtx.usage = usage;
112
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
113
- }
110
+ transportState.bindKeyUsageFromBridge(usage);
114
111
  },
115
112
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
116
113
  commitReasoningReplayServingRoute();
@@ -178,17 +175,14 @@ export async function deliverAdapterResponse(
178
175
  hideThinkingSummary: parsed.options.hideThinkingSummary,
179
176
  toolNsMap,
180
177
  declaredToolNames,
178
+ enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
181
179
  toolParameterSchemas,
182
180
  freeformToolNames,
183
181
  toolSearchToolNames,
184
182
  ...(routedCompaction ? { compaction: true } : {}),
185
183
  onProviderState: state => { providerState = state; },
186
184
  onUsage: usage => {
187
- logCtx.usageFromBridge = true;
188
- if (usage) {
189
- logCtx.usage = usage;
190
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
191
- }
185
+ transportState.bindKeyUsageFromBridge(usage);
192
186
  },
193
187
  });
194
188
  // See the streaming branch: compaction turns skip the continuation cache.