@bitkyc08/opencodex 2.58.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 (188) 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 +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -1,5 +1,5 @@
1
1
  import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types";
2
- import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
2
+ import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
3
3
  import { debugProviderDiagnostic } from "../../lib/debug";
4
4
  import { stripUnicodePropertyPatterns } from "../responses-tool-schema";
5
5
  import {
@@ -225,17 +225,29 @@ export function promoteClientLoadedTools(body: unknown): unknown {
225
225
  }
226
226
 
227
227
  /**
228
- * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
229
- * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
230
- * matches, keeping the common path allocation-free.
228
+ * Remove hosted tool entries the destination rejects, so the OAuth-passthrough body never
229
+ * carries a tool the upstream 400s on. Two sources of truth are consulted: the built-in
230
+ * table of known-broken native slugs and destinations, and the routed provider's own
231
+ * `unsupportedHostedTools` declaration. The declaration is what lets an OpenAI-compatible
232
+ * Responses gateway with a narrower capability set be described in config instead of
233
+ * requiring a hard-coded destination rule per vendor (#5002).
234
+ *
235
+ * No-op (returns the original reference) when nothing matches, keeping the common path
236
+ * allocation-free.
231
237
  */
232
- export function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxProviderConfig, "baseUrl">): unknown {
238
+ export function stripUnsupportedHostedTools(
239
+ body: unknown,
240
+ provider: Pick<OcxProviderConfig, "baseUrl" | "unsupportedHostedTools">,
241
+ ): unknown {
233
242
  if (!isPlainObject(body)) return body;
234
243
  const model = typeof body.model === "string" ? body.model : "";
244
+ // Expanded once per request rather than per tool: the alias walk is the only
245
+ // non-lookup work in this filter.
246
+ const declaredUnsupported = declaredUnsupportedHostedTools(provider);
235
247
  const filterTools = (tools: unknown[]): unknown[] => {
236
248
  const filtered = tools.filter(t => {
237
249
  const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
238
- return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl);
250
+ return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl, declaredUnsupported);
239
251
  });
240
252
  return filtered.length === tools.length ? tools : filtered;
241
253
  };
@@ -274,7 +286,7 @@ export function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxPro
274
286
  } else if (
275
287
  isPlainObject(toolChoice)
276
288
  && typeof toolChoice.type === "string"
277
- && isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl)
289
+ && isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl, declaredUnsupported)
278
290
  ) {
279
291
  next = { ...next, tool_choice: "none" };
280
292
  changed = true;
@@ -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> => {
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),
@@ -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, confirmedUsage?: Rec): 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, confirmedUsage?: Rec): Rec {
198
218
  model,
199
219
  stop_reason: null,
200
220
  stop_sequence: null,
201
- usage: confirmedUsage ?? { 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;
@@ -283,7 +311,10 @@ export function responsesSseToAnthropicSse(
283
311
  const ensureStarted = () => {
284
312
  if (started) return;
285
313
  started = true;
286
- emit("message_start", { type: "message_start", message: messageSnapshot(model, earlyAnthropicUsage) });
314
+ emit("message_start", {
315
+ type: "message_start",
316
+ message: messageSnapshot(model, earlyAnthropicUsage, opts.inputTokenFloor),
317
+ });
287
318
  emit("ping", { type: "ping" });
288
319
  };
289
320
  // Keepalive pings protect remote deployments behind LB/NAT idle timeouts even
@@ -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)
@@ -925,12 +925,32 @@ async function poolSetting(
925
925
  if (response.status !== 200) return apiError(response.json, `failed to read ${label}`, response.status);
926
926
  const strategy = response.json[transport.strategyKey];
927
927
  const sticky = response.json[transport.stickyKey];
928
+ const autoSwitchThreshold = typeof response.json.autoSwitchThreshold === "number"
929
+ ? response.json.autoSwitchThreshold
930
+ : undefined;
928
931
  if (wantsJson) {
929
932
  // Pool-neutral key names: the two routes spell the same two settings differently, and a
930
933
  // `--json` consumer should not have to branch on which pool answered.
931
- console.log(JSON.stringify({ ok: true, provider: name, strategy, stickyLimit: sticky }, null, 2));
934
+ const payload: Record<string, unknown> = { ok: true, provider: name, strategy, stickyLimit: sticky };
935
+ if (autoSwitchThreshold !== undefined) {
936
+ payload.autoSwitchThreshold = autoSwitchThreshold;
937
+ }
938
+ console.log(JSON.stringify(payload, null, 2));
932
939
  } else {
933
- console.log(`${name}: ${label} is ${String(field === "strategy" ? strategy : sticky)}`);
940
+ if (field === "strategy" && autoSwitchThreshold !== undefined) {
941
+ const thresholdSummary = strategy === "round-robin"
942
+ ? "threshold not used"
943
+ : autoSwitchThreshold > 0
944
+ ? (strategy === "fill-first"
945
+ ? `drain at ${autoSwitchThreshold}%`
946
+ : strategy === "reset-first"
947
+ ? `nearest reset below ${autoSwitchThreshold}%`
948
+ : `switch at ${autoSwitchThreshold}%`)
949
+ : "proactive switching off";
950
+ console.log(`${name}: ${label} is ${String(strategy)} (${thresholdSummary})`);
951
+ } else {
952
+ console.log(`${name}: ${label} is ${String(field === "strategy" ? strategy : sticky)}`);
953
+ }
934
954
  }
935
955
  return 0;
936
956
  }