@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -0,0 +1,50 @@
1
+ import type { AdapterFetchContext } from "./base";
2
+ import type { SendClass } from "../lib/request-execution-budget";
3
+ import type { AttemptRecoveryKind } from "../usage/log";
4
+ import { abortError, SendBudgetExhaustedError } from "../lib/upstream-retry";
5
+
6
+ type PacedFetch = typeof globalThis.fetch & {
7
+ waitForPacing?: (signal?: AbortSignal) => Promise<void>;
8
+ unpacedFetch?: typeof globalThis.fetch;
9
+ };
10
+
11
+ /** One ordinal sequence per adapter fetchResponse call, across all of its inference retries.
12
+ * Consumption starts at underlying executor invocation; its own later preflight may still fail. */
13
+ export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallback = globalThis.fetch) {
14
+ const executor = (ctx.executor ?? fallback) as PacedFetch;
15
+ let ordinal = 0;
16
+ return async (options: {
17
+ url: string;
18
+ sendClass?: SendClass;
19
+ recovery?: AttemptRecoveryKind;
20
+ /** Runs only after admission, e.g. backoff and cancellation of a superseded response. */
21
+ beforeDispatch?: () => void | Promise<void>;
22
+ dispatch: (executor: typeof globalThis.fetch) => Promise<Response>;
23
+ }): Promise<Response> => {
24
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
25
+ const decision = ctx.sendBudget?.reserveDispatch({
26
+ sendClass: options.sendClass ?? "transient", targetKey: options.url,
27
+ });
28
+ if (decision && !decision.allowed) throw new SendBudgetExhaustedError(options.url);
29
+ const permit = decision?.allowed ? decision.permit : undefined;
30
+ let dispatched = false;
31
+ const physicalExecutor = (async (input, init) => {
32
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
33
+ if (init?.signal?.aborted) throw abortError(init.signal);
34
+ if (dispatched || (permit && !permit.use())) throw new SendBudgetExhaustedError(options.url);
35
+ dispatched = true;
36
+ ordinal += 1;
37
+ ctx.onPhysicalSend?.({ ordinal, ...(options.recovery ? { recovery: options.recovery } : {}) });
38
+ return (executor.unpacedFetch ?? executor)(input, init);
39
+ }) as typeof globalThis.fetch;
40
+ try {
41
+ await executor.waitForPacing?.(ctx.abortSignal);
42
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
43
+ await options.beforeDispatch?.();
44
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
45
+ return await options.dispatch(physicalExecutor);
46
+ } finally {
47
+ permit?.release();
48
+ }
49
+ };
50
+ }
@@ -107,66 +107,96 @@ function usesUnicodePropertyEscape(pattern: string): boolean {
107
107
  * preserved: loosening a nested constraint can instead reject an input in those contexts.
108
108
  * Unsupported patterns there remain the destination's validation responsibility.
109
109
  *
110
- * Returns `node` itself when nothing was dropped. Uses an explicit stack for caller-controlled
111
- * nesting depth; the separate Responses-only encrypted-marker normalization is unchanged.
110
+ * Returns `node` itself when nothing was dropped. Traversal keeps only the active path and clones
111
+ * only ancestors of a removed constraint, so a broad no-op schema does not create an output tree
112
+ * or one pending closure per sibling. The explicit stack still handles caller-controlled nesting
113
+ * depth; the separate Responses-only encrypted-marker normalization is unchanged.
112
114
  */
113
115
  export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false): unknown {
114
- type Assign = (value: unknown) => void;
115
- interface Frame { node: unknown; inNameBag: boolean; assign: Assign }
116
-
117
- let result: unknown;
118
- let dropped = 0;
119
- const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }];
116
+ interface Frame {
117
+ node: unknown[] | Record<string, unknown>;
118
+ inNameBag: boolean;
119
+ parent?: Frame;
120
+ parentKey?: string | number;
121
+ output?: unknown[] | Record<string, unknown>;
122
+ index?: number;
123
+ entries?: IterableIterator<[string, unknown]>;
124
+ }
120
125
 
121
- while (stack.length > 0) {
122
- const frame = stack.pop()!;
123
- const current = frame.node;
126
+ function * ownEntries(value: Record<string, unknown>): IterableIterator<[string, unknown]> {
127
+ // Unlike Object.entries(), this does not materialize every key/value pair before traversal.
128
+ for (const key in value) {
129
+ if (Object.prototype.hasOwnProperty.call(value, key)) yield [key, value[key]];
130
+ }
131
+ }
124
132
 
125
- if (Array.isArray(current)) {
126
- const out: unknown[] = new Array(current.length);
127
- frame.assign(out);
128
- // Array items are schemas in their own right, never a name bag.
129
- for (let i = current.length - 1; i >= 0; i--) {
130
- stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } });
131
- }
132
- continue;
133
+ function cloneContainer(frame: Frame): unknown[] | Record<string, unknown> {
134
+ if (frame.output) return frame.output;
135
+ if (Array.isArray(frame.node)) {
136
+ frame.output = frame.node.slice();
137
+ return frame.output;
133
138
  }
134
- if (!current || typeof current !== "object") {
135
- frame.assign(current);
136
- continue;
139
+ const output: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
140
+ for (const key in frame.node) {
141
+ if (Object.prototype.hasOwnProperty.call(frame.node, key)) output[key] = frame.node[key];
137
142
  }
143
+ frame.output = output;
144
+ return output;
145
+ }
138
146
 
139
- // A schema name may be `__proto__`; a null-prototype record keeps it as data.
140
- const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
141
- frame.assign(out);
147
+ function finish(frame: Frame): void {
148
+ if (!frame.output || !frame.parent) return;
149
+ const parent = cloneContainer(frame.parent);
150
+ if (Array.isArray(parent)) parent[frame.parentKey as number] = frame.output;
151
+ else parent[frame.parentKey as string] = frame.output;
152
+ }
142
153
 
143
- for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
144
- if (frame.inNameBag) {
145
- // Inside a name bag every key is a caller-chosen name, so `pattern` here is a property
146
- // name; its value is still a schema and is walked as one.
147
- stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } });
148
- continue;
149
- }
150
- if (PRESERVED_PATTERN_SUBTREES.has(key)) {
151
- out[key] = value;
152
- continue;
153
- }
154
- if (key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) {
155
- dropped++;
154
+ if (!node || typeof node !== "object") return node;
155
+ const root: Frame = { node: node as unknown[] | Record<string, unknown>, inNameBag };
156
+ const stack: Frame[] = [root];
157
+
158
+ while (stack.length > 0) {
159
+ const frame = stack[stack.length - 1]!;
160
+
161
+ if (Array.isArray(frame.node)) {
162
+ const index = frame.index ?? 0;
163
+ if (index >= frame.node.length) {
164
+ stack.pop();
165
+ finish(frame);
156
166
  continue;
157
167
  }
158
- if (SCHEMA_LITERAL_VALUE_KEYS.has(key)) {
159
- // Literal payloads are values, not schemas: a `pattern` key inside them is data.
160
- out[key] = value;
161
- continue;
168
+ frame.index = index + 1;
169
+ const child = frame.node[index];
170
+ if (child && typeof child === "object") {
171
+ stack.push({ node: child as unknown[] | Record<string, unknown>, inNameBag: false, parent: frame, parentKey: index });
162
172
  }
173
+ continue;
174
+ }
175
+
176
+ frame.entries ??= ownEntries(frame.node);
177
+ const next = frame.entries.next();
178
+ if (next.done) {
179
+ stack.pop();
180
+ finish(frame);
181
+ continue;
182
+ }
183
+ const [key, value] = next.value;
184
+ if (!frame.inNameBag && key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) {
185
+ delete (cloneContainer(frame) as Record<string, unknown>)[key];
186
+ continue;
187
+ }
188
+ if (!frame.inNameBag && (PRESERVED_PATTERN_SUBTREES.has(key) || SCHEMA_LITERAL_VALUE_KEYS.has(key))) {
189
+ continue;
190
+ }
191
+ if (value && typeof value === "object") {
163
192
  stack.push({
164
- node: value,
165
- inNameBag: SCHEMA_NAME_BAG_KEYS.has(key),
166
- assign: v => { out[key] = v; },
193
+ node: value as unknown[] | Record<string, unknown>,
194
+ inNameBag: !frame.inNameBag && SCHEMA_NAME_BAG_KEYS.has(key),
195
+ parent: frame,
196
+ parentKey: key,
167
197
  });
168
198
  }
169
199
  }
170
200
 
171
- return dropped === 0 ? node : result;
201
+ return root.output ?? node;
172
202
  }
@@ -28,6 +28,7 @@ export interface AdapterEventPreflight {
28
28
  stream: AsyncIterable<AdapterEvent>;
29
29
  error?: Extract<AdapterEvent, { type: "error" }>;
30
30
  empty: boolean;
31
+ replayUnsafe: boolean;
31
32
  }
32
33
 
33
34
  async function* replay(
@@ -51,10 +52,12 @@ export async function preflightAdapterEvents(
51
52
  ): Promise<AdapterEventPreflight> {
52
53
  const iterator = source[Symbol.asyncIterator]();
53
54
  const buffered: AdapterEvent[] = [];
55
+ let replayUnsafe = false;
54
56
  while (true) {
55
57
  const next = await iterator.next();
56
- if (next.done) return { stream: replay(buffered, iterator), empty: true };
58
+ if (next.done) return { stream: replay(buffered, iterator), empty: true, replayUnsafe };
57
59
  if (next.value.type === "heartbeat") {
60
+ replayUnsafe ||= next.value.replayUnsafe === true;
58
61
  buffered.push(next.value);
59
62
  if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift();
60
63
  continue;
@@ -62,9 +65,9 @@ export async function preflightAdapterEvents(
62
65
  buffered.push(next.value);
63
66
  if (next.value.type === "error") {
64
67
  await iterator.return?.();
65
- return { stream: replay(buffered, iterator), error: next.value, empty: false };
68
+ return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe };
66
69
  }
67
- return { stream: replay(buffered, iterator), empty: false };
70
+ return { stream: replay(buffered, iterator), empty: false, replayUnsafe };
68
71
  }
69
72
  }
70
73
 
@@ -90,7 +93,17 @@ export function createAdapterEventQueue(opts?: {
90
93
  const coalesceIntoTail = (event: AdapterEvent): boolean => {
91
94
  const tail = queued[queued.length - 1];
92
95
  if (!tail) return false;
93
- if (event.type === "heartbeat") return tail.type === "heartbeat";
96
+ if (event.type === "heartbeat") {
97
+ if (tail.type !== "heartbeat") return false;
98
+ // Heartbeats carry no ordering between themselves, but the replay-unsafe
99
+ // marker is not ordering — it is a latch. Dropping the incoming event
100
+ // would discard the only record that Cursor already performed a local
101
+ // side effect, and preflight would then permit an OAuth replay of it.
102
+ if (event.replayUnsafe === true && tail.replayUnsafe !== true) {
103
+ queued[queued.length - 1] = { type: "heartbeat", replayUnsafe: true };
104
+ }
105
+ return true;
106
+ }
94
107
  if (event.type === "text_delta" && tail.type === "text_delta" && tail.phase === event.phase) {
95
108
  if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return false;
96
109
  queued[queued.length - 1] = { type: "text_delta", text: tail.text + event.text, phase: tail.phase };
@@ -203,7 +203,7 @@ function buildResponseJSONWithBudget(
203
203
  ): string => {
204
204
  const helper = resolveCodeModeHelperName(codeModeHelperName, toolName, args, namespace, options?.declaredToolNames);
205
205
  return helper
206
- ? compileCodeModeHelperInput(args, helper)
206
+ ? compileCodeModeHelperInput(args, helper, codeModeHelperName ?? toolName)
207
207
  : repairFreeformToolInput(args, toolName, namespace);
208
208
  };
209
209
  const parseArgsObj = (args: string): Record<string, unknown> => {
@@ -527,7 +527,7 @@ function buildResponseJSONWithBudget(
527
527
  compactionEncryptedContent = e.compactionEncryptedContent;
528
528
  sawTerminal = true;
529
529
  endTurn = e.endTurn;
530
- cleanDone = e.stopReason === undefined;
530
+ cleanDone = !isTruncatedStopReason(e.stopReason);
531
531
  rawStopReason = e.stopReason;
532
532
  if (e.providerState) options?.onProviderState?.(e.providerState);
533
533
  // Match streaming: max_tokens and content_filter both terminate as incomplete.
package/src/bridge/sse.ts CHANGED
@@ -16,7 +16,12 @@ import {
16
16
  type OcxErrorPayload,
17
17
  } from "../lib/errors";
18
18
  import { redactSecretString } from "../lib/redact";
19
- import { mayBecomePatchEnvelope, repairFreeformToolInput } from "../responses/apply-patch-envelope";
19
+ import {
20
+ freeformFallbackKeys,
21
+ mayBecomePatchEnvelope,
22
+ repairFreeformToolInput,
23
+ unwrapFreeformToolInput,
24
+ } from "../responses/apply-patch-envelope";
20
25
  import { encodeCompactionSummary } from "../responses/compaction";
21
26
  import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
22
27
  import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason";
@@ -145,37 +150,156 @@ export function bridgeToResponsesSSE(
145
150
  ): string => {
146
151
  const helper = resolveCodeModeHelperName(codeModeHelperName, toolName, args, namespace, options?.declaredToolNames);
147
152
  return helper
148
- ? compileCodeModeHelperInput(args, helper)
153
+ ? compileCodeModeHelperInput(args, helper, codeModeHelperName ?? toolName)
149
154
  : repairFreeformToolInput(args, toolName, namespace);
150
155
  };
151
156
  // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming
152
157
  // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only;
153
158
  // the completed custom_tool_call item stays authoritative). Compact `{"input":"...`
154
159
  // buffers get their string value progressively unescaped; anything else streams raw.
155
- const FREEFORM_WRAP_PREFIX = '{"input":"';
156
- const freeformPartialInput = (args: string): string => {
157
- if (!args.startsWith(FREEFORM_WRAP_PREFIX)) return args;
158
- const body = args.slice(FREEFORM_WRAP_PREFIX.length);
160
+ const JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
161
+ type WrapperOpening =
162
+ | { state: "none" }
163
+ | { state: "prefix" }
164
+ | { state: "open"; valueStart: number };
165
+ /**
166
+ * Where the string value of `{"<key>":"` begins, tolerating the insignificant whitespace
167
+ * `JSON.parse` accepts.
168
+ *
169
+ * The earlier form of this compared the buffer against the compact literal `{"key":"`, so a
170
+ * wrapper written with spaces or newlines matched no prefix at all, streamed as raw JSON
171
+ * deltas and then completed as the unwrapped body. That is the same delta/completion
172
+ * disagreement #5047 closed for compact wrappers, reached through a different spelling:
173
+ * `unwrapFreeformToolInput` reads the completed text with `JSON.parse`, which does not care
174
+ * how the object is laid out, so neither can the streaming side.
175
+ */
176
+ const wrapperOpening = (args: string, key: string): WrapperOpening => {
177
+ let index = 0;
178
+ for (const token of ["{", `"${key}"`, ":", '"']) {
179
+ while (index < args.length && JSON_WHITESPACE.has(args[index]!)) index++;
180
+ if (index >= args.length) return { state: "prefix" };
181
+ for (const expected of token) {
182
+ if (index >= args.length) return { state: "prefix" };
183
+ if (args[index] !== expected) return { state: "none" };
184
+ index++;
185
+ }
186
+ }
187
+ return { state: "open", valueStart: index };
188
+ };
189
+ /**
190
+ * Whether a body could still grow into one complete outer Markdown fence.
191
+ *
192
+ * `stripMarkdownCodeFence` removes such a fence at completion for exactly the two tools that
193
+ * own the grammar, so a fenced body's streamed bytes and its completed input disagree unless
194
+ * the stream holds. A buffer that does not open with a fence can never acquire one, so
195
+ * ordinary bodies are unaffected; a buffer that does keeps its preview suppressed for the
196
+ * whole call, because a closing fence can still be followed by more text that withdraws it.
197
+ */
198
+ const mayBecomeFencedBody = (text: string, toolName: string): boolean => {
199
+ if (toolName !== "exec" && toolName !== "apply_patch") return false;
200
+ const head = text.trimStart();
201
+ if (head === "") return true;
202
+ return head.startsWith("```") || "```".startsWith(head);
203
+ };
204
+ /** The two-character escapes JSON defines, and nothing else. */
205
+ const JSON_ESCAPES = new Map<string, string>([
206
+ ['"', '"'], ["\\", "\\"], ["/", "/"],
207
+ ["b", "\b"], ["f", "\f"], ["n", "\n"], ["r", "\r"], ["t", "\t"],
208
+ ]);
209
+ const LOW_SURROGATE_ESCAPE = /^\\u[dD][c-fC-F][0-9a-fA-F]{2}$/;
210
+ /**
211
+ * The decoded prefix of a JSON string body, stopping at the first byte it cannot resolve.
212
+ *
213
+ * Every stop is a hold rather than a guess, because `JSON.parse` decides the completed value
214
+ * and anything invented here would be retracted. Three of them are not obvious:
215
+ *
216
+ * An escape JSON does not define makes the whole wrapper unparseable no matter what arrives
217
+ * next, so completion falls back to the raw text. Returning `null` for that case stops the
218
+ * preview rather than continuing to decode a value the completed item will never carry.
219
+ * `\\b` and `\\f` are defined, and were previously decoded to the letters b and f.
220
+ *
221
+ * A lone high surrogate is not a character. Emitting one alone puts an unpaired code unit in a
222
+ * delta the client has to decode by itself, so the pair is emitted together or not at all.
223
+ */
224
+ const decodeJsonStringPrefix = (body: string): string | null => {
159
225
  let out = "";
160
226
  for (let i = 0; i < body.length; i++) {
161
227
  const c = body[i];
162
228
  if (c === '"') break; // unescaped closing quote: value complete
163
- if (c === "\\") {
164
- const n = body[i + 1];
165
- if (n === undefined) break; // escape split across chunks: wait for more
166
- i++;
167
- if (n === "n") out += "\n";
168
- else if (n === "t") out += "\t";
169
- else if (n === "r") out += "\r";
170
- else if (n === "u") {
171
- const hex = body.slice(i + 1, i + 5);
172
- if (hex.length === 4 && /^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 4; }
173
- else break; // incomplete \uXXXX: wait for more
174
- } else out += n; // \" \\ \/ etc.
175
- } else out += c;
229
+ if (c !== "\\") { out += c; continue; }
230
+ const n = body[i + 1];
231
+ if (n === undefined) break; // escape split across chunks: wait for more
232
+ if (n === "u") {
233
+ const hex = body.slice(i + 2, i + 6);
234
+ if (hex.length < 4) break; // split across chunks: wait for more
235
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null; // never parses
236
+ const code = parseInt(hex, 16);
237
+ if (code >= 0xd800 && code <= 0xdbff) {
238
+ const low = body.slice(i + 6, i + 12);
239
+ if (!LOW_SURROGATE_ESCAPE.test(low)) break;
240
+ out += String.fromCharCode(code, parseInt(low.slice(2), 16));
241
+ i += 11;
242
+ continue;
243
+ }
244
+ out += String.fromCharCode(code);
245
+ i += 5;
246
+ continue;
247
+ }
248
+ const escaped = JSON_ESCAPES.get(n);
249
+ if (escaped === undefined) return null; // never parses
250
+ out += escaped;
251
+ i += 1;
176
252
  }
177
253
  return out;
178
254
  };
255
+ /**
256
+ * The value to stream so far, or `null` to HOLD because nothing can be decided yet.
257
+ *
258
+ * `input` is decidable from its prefix: `unwrapFreeformToolInput` returns it whenever the
259
+ * key is present, whatever else the object carries, so its value can be unescaped
260
+ * progressively and never retracted.
261
+ *
262
+ * One case escapes that claim and is accepted rather than fixed: a duplicate `input` key.
263
+ * `JSON.parse` keeps the last one, so `{"input":"a","input":"b"}` streams a and completes with
264
+ * b. Closing it means holding every canonical wrapper until its object parses, which is the
265
+ * progressive streaming this path exists to provide. The completed item stays authoritative,
266
+ * and no model emits a duplicate key in practice.
267
+ *
268
+ * A wrapper that turns invalid AFTER streaming has committed has the same shape and the same
269
+ * answer. `{"input":"a` followed by `\\qb"}` has already published a when the undefined escape
270
+ * arrives, and completion returns the raw text because nothing parses. The preview stops
271
+ * there: no rewind, and no decoded text the completed item does not contain. Bounding the
272
+ * damage is what is available without giving up progressive streaming, and the args are
273
+ * unusable in that case whichever representation wins.
274
+ *
275
+ * A fallback key is not. It only unwraps when it is the SINGLE string field, and a second
276
+ * key can still arrive — so a value emitted early would have to be taken back. That is the
277
+ * rewind this holds instead: stream nothing until the object closes, then publish the one
278
+ * repaired body. The routed passthrough in `responses-custom-tool-repair.ts` already holds
279
+ * any object prefix for the same reason (#5047).
280
+ */
281
+ const freeformPartialInput = (args: string, toolName: string): string | null => {
282
+ const openings = ["input", ...freeformFallbackKeys(toolName)]
283
+ .map(key => ({ key, opening: wrapperOpening(args, key) }));
284
+ const canonical = openings[0]!.opening;
285
+ if (canonical.state === "open") {
286
+ const decoded = decodeJsonStringPrefix(args.slice(canonical.valueStart));
287
+ if (decoded === null) return null;
288
+ return mayBecomeFencedBody(decoded, toolName) ? null : decoded;
289
+ }
290
+ if (openings.some(entry => entry.opening.state === "open")) {
291
+ // Committed to a fallback wrapper. Undecidable until the object is complete.
292
+ try {
293
+ JSON.parse(args);
294
+ } catch {
295
+ return null;
296
+ }
297
+ return unwrapFreeformToolInput(args, toolName);
298
+ }
299
+ // Still an ambiguous prefix of some wrapper: which wrapper, if any, is not known yet.
300
+ if (openings.some(entry => entry.opening.state === "prefix")) return null;
301
+ return mayBecomeFencedBody(args, toolName) ? null : args;
302
+ };
179
303
  // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string.
180
304
  const parseArgsObj = (args: string): Record<string, unknown> => {
181
305
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
@@ -1075,10 +1199,21 @@ export function bridgeToResponsesSSE(
1075
1199
  });
1076
1200
  }
1077
1201
  if (currentToolCall.freeform && !currentToolCall.codeModeHelperName) {
1078
- // Hold while the buffer is still an ambiguous prefix of the JSON wrapper,
1079
- // then stream only the unwrapped input suffix (never rewind on mode flips).
1080
- if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) {
1081
- const full = freeformPartialInput(currentToolCall.args);
1202
+ // `freeformPartialInput` holds while the buffer is still an ambiguous prefix
1203
+ // of a JSON wrapper; otherwise stream only the unwrapped input suffix, never
1204
+ // rewinding on a mode flip.
1205
+ //
1206
+ // The name is dropped for a namespaced tool that does not own the apply-patch
1207
+ // grammar, because `repairFreeformToolInput` drops it at completion for the
1208
+ // same reason. Streaming under a vocabulary the completed item does not use
1209
+ // is the same disagreement in the other direction.
1210
+ const ownsFreeformGrammar = currentToolCall.namespace === undefined
1211
+ || currentToolCall.namespace === "functions";
1212
+ const full = freeformPartialInput(
1213
+ currentToolCall.args,
1214
+ ownsFreeformGrammar ? currentToolCall.name : "",
1215
+ );
1216
+ if (full !== null) {
1082
1217
  const emitted = currentToolCall.inputEmitted ?? "";
1083
1218
  // Also hold a buffer that could still become a complete patch envelope:
1084
1219
  // at completion such a body is recompiled into an apply_patch helper call,
@@ -1086,7 +1221,13 @@ export function bridgeToResponsesSSE(
1086
1221
  const mayCompile = declaresCodeModeExec(options?.declaredToolNames)
1087
1222
  && !currentToolCall.namespace
1088
1223
  && currentToolCall.name === "exec";
1089
- if (!(mayCompile && mayBecomePatchEnvelope(full)) && full.startsWith(emitted) && full.length > emitted.length) {
1224
+ // `apply_patch` holds for a different reason with the same shape:
1225
+ // `normalizeApplyPatchDelimiters` rewrites a decorated `*** Begin Patch ***`
1226
+ // envelope at completion, so streaming the decorated markers would be
1227
+ // replaced by the normalized ones.
1228
+ const mayNormalize = ownsFreeformGrammar && currentToolCall.name === "apply_patch";
1229
+ if (!((mayCompile || mayNormalize) && mayBecomePatchEnvelope(full))
1230
+ && full.startsWith(emitted) && full.length > emitted.length) {
1090
1231
  emit("response.custom_tool_call_input.delta", {
1091
1232
  item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
1092
1233
  delta: full.slice(emitted.length),
@@ -1171,7 +1312,7 @@ export function bridgeToResponsesSSE(
1171
1312
  break;
1172
1313
  }
1173
1314
  case "done": {
1174
- if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer");
1315
+ if (currentMsg) closeCurrentMessage(isTruncatedStopReason(event.stopReason) ? undefined : "final_answer");
1175
1316
  if (currentReasoning) closeCurrentReasoning();
1176
1317
  if (currentRawReasoning) closeCurrentRawReasoning();
1177
1318
  flushHiddenRawReasoning();
@@ -86,6 +86,28 @@ export function resolveAutoContext(claudeCode: AutoContextConfigSlice | undefine
86
86
  return { enabled: true, compactWindow };
87
87
  }
88
88
 
89
+ /**
90
+ * Config value -> ENABLE_TOOL_SEARCH wire value (#4838).
91
+ *
92
+ * Claude Code parses this variable itself and accepts more than a boolean:
93
+ * "true", "auto", "auto:N" (N is a percentage floor for non-deferred tools; its
94
+ * parser rejects anything under 100) and "force", which also overrides an
95
+ * OS-level managed-settings suppression. Passing a string through verbatim keeps
96
+ * that whole vocabulary reachable instead of flattening it to a boolean that
97
+ * would have to be re-expanded here later.
98
+ *
99
+ * `false`, absent and blank inject nothing rather than injecting "false". Every
100
+ * caller injects with user-wins semantics, so an operator's own export survives
101
+ * either way, and writing "false" would be the one path that could quietly
102
+ * disagree with it.
103
+ */
104
+ export function claudeToolSearchEnv(value: boolean | string | undefined): string | undefined {
105
+ if (value === true) return "true";
106
+ if (typeof value !== "string") return undefined;
107
+ const trimmed = value.trim();
108
+ return trimmed.length > 0 ? trimmed : undefined;
109
+ }
110
+
89
111
  /**
90
112
  * [1m]-marking predicate. Windows >= 1M always mark (CLI accounts exactly 1M).
91
113
  * Auto-context additionally marks windows > 200k that can safely host the compact
@@ -189,7 +189,27 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
189
189
  return { id, input, resultContent, completed };
190
190
  }
191
191
 
192
- function messageSnapshot(model: string): Rec {
192
+ /**
193
+ * `inputTokenFloor` is this proxy's own count of the prompt it forwarded, used only when the
194
+ * upstream sent no confirmed usage before the first frame.
195
+ *
196
+ * Real Anthropic fills `message_start.message.usage.input_tokens` with the turn's prompt size,
197
+ * and third-party clients read it there — Paseo's context meter takes input from this frame and
198
+ * output from `message_delta`, so a hardcoded zero showed a nearly-empty ring for a turn whose
199
+ * `/context` reported ~97k (#4857). #4891 fixed the destinations that report usage up front;
200
+ * the internal bridge attaches `usage: null` to its lifecycle frames, so those paths had
201
+ * nothing to report and kept sending zero.
202
+ *
203
+ * A floor is a measurement, which is what makes it publishable here: it counts the prompt this
204
+ * proxy actually sent, the same estimate `claude-messages.ts` already trusts as a log floor. It
205
+ * is not a claim about upstream's tokenizer, and it is not final — `message_delta` carries the
206
+ * authoritative count for every reader that waits for it, exactly as before.
207
+ */
208
+ function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?: number): Rec {
209
+ const usage = confirmedUsage
210
+ ?? (typeof inputTokenFloor === "number" && Number.isFinite(inputTokenFloor) && inputTokenFloor > 0
211
+ ? { input_tokens: Math.trunc(inputTokenFloor), output_tokens: 0 }
212
+ : { input_tokens: 0, output_tokens: 0 });
193
213
  return {
194
214
  id: `msg_${uuid()}`,
195
215
  type: "message",
@@ -198,7 +218,7 @@ function messageSnapshot(model: string): Rec {
198
218
  model,
199
219
  stop_reason: null,
200
220
  stop_sequence: null,
201
- usage: { input_tokens: 0, output_tokens: 0 },
221
+ usage,
202
222
  };
203
223
  }
204
224
 
@@ -228,7 +248,15 @@ interface OpenBlock {
228
248
  export function responsesSseToAnthropicSse(
229
249
  upstream: ReadableStream<Uint8Array>,
230
250
  model: string,
231
- opts: { pingIntervalMs?: number; translatorBudget: TranslatorBudget },
251
+ opts: {
252
+ pingIntervalMs?: number;
253
+ translatorBudget: TranslatorBudget;
254
+ /**
255
+ * This proxy's count of the prompt it forwarded, published on `message_start` only when the
256
+ * upstream sent no confirmed usage before the first frame. See `messageSnapshot` (#4857).
257
+ */
258
+ inputTokenFloor?: number;
259
+ },
232
260
  ): ReadableStream<Uint8Array> {
233
261
  const translatorBudget = opts.translatorBudget;
234
262
  const pingIntervalMs = opts?.pingIntervalMs ?? 20_000;
@@ -246,6 +274,7 @@ export function responsesSseToAnthropicSse(
246
274
  let open: OpenBlock | null = null;
247
275
  let sawToolUse = false;
248
276
  let webSearchRequests = 0;
277
+ let earlyAnthropicUsage: Rec | undefined;
249
278
  let pingTimer: ReturnType<typeof setInterval> | undefined;
250
279
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
251
280
  const utf8SliceBytes = (value: string, start: number, end: number): number => {
@@ -282,7 +311,10 @@ export function responsesSseToAnthropicSse(
282
311
  const ensureStarted = () => {
283
312
  if (started) return;
284
313
  started = true;
285
- emit("message_start", { type: "message_start", message: messageSnapshot(model) });
314
+ emit("message_start", {
315
+ type: "message_start",
316
+ message: messageSnapshot(model, earlyAnthropicUsage, opts.inputTokenFloor),
317
+ });
286
318
  emit("ping", { type: "ping" });
287
319
  };
288
320
  // Keepalive pings protect remote deployments behind LB/NAT idle timeouts even
@@ -405,8 +437,17 @@ export function responsesSseToAnthropicSse(
405
437
  const handleFrame = (eventName: string, data: Rec) => {
406
438
  switch (eventName) {
407
439
  case "response.created":
408
- // Transport prelude only. Start Anthropic framing on semantic output or completion.
440
+ case "response.in_progress": {
441
+ // Lifecycle preludes do not start Anthropic framing, but some upstreams attach
442
+ // confirmed input usage before semantic output. Retain only its bounded Anthropic
443
+ // projection so message_start can report measurements that already arrived.
444
+ const response = isRec(data.response) ? data.response : {};
445
+ const usage = isRec(response.usage) ? response.usage : undefined;
446
+ if (!started && usage && typeof usage.input_tokens === "number") {
447
+ earlyAnthropicUsage = anthropicUsage(usage);
448
+ }
409
449
  break;
450
+ }
410
451
  case "response.heartbeat":
411
452
  if ((controller.desiredSize ?? 0) > 0) emit("ping", { type: "ping" });
412
453
  break;
@@ -370,9 +370,10 @@ async function fetchOAuthRows(
370
370
  email: a.email,
371
371
  active: a.active ?? a.id === activeId,
372
372
  needsReauth: a.needsReauth,
373
- // Forward the server's answer verbatim, including `null`. Collapsing null to "absent" here
374
- // would destroy the one distinction this field exists to make.
375
- plan: a.plan ?? null,
373
+ // Forward the server's answer verbatim. An absent key means the proxy predates tier
374
+ // reporting while `null` means it checked and found no tier — collapsing either
375
+ // direction would destroy the one distinction this field exists to make.
376
+ ...(Object.hasOwn(a, "plan") ? { plan: a.plan } : {}),
376
377
  ...(a.quota !== undefined ? { quota: a.quota } : {}),
377
378
  ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}),
378
379
  ...(a.quotaUnavailable === true && parseQuotaFailureCode(a.quotaFailure)