@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
@@ -57,7 +57,6 @@ const GOOGLE_BREVITY_INSTRUCTION = [
57
57
 
58
58
  const ANTIGRAVITY_REJECTED_CLAUDE_SDK_PARAGRAPH =
59
59
  "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
60
-
61
60
  /**
62
61
  * CCA Flash generations that reject the Claude-Agent identity paragraph.
63
62
  *
@@ -102,6 +101,20 @@ function stripAntigravityRejectedClaudeSdkParagraph(systemText: string): string
102
101
  .join("\n\n");
103
102
  }
104
103
 
104
+ /**
105
+ * Strips Claude Code CLI's internal billing header (`x-anthropic-billing-header: ...`)
106
+ * at the start of the system prompt, because Cloud Code Assist / Google Antigravity inspects
107
+ * `systemInstruction` and rejects requests containing Anthropic billing metadata with
108
+ * HTTP 429 RESOURCE_EXHAUSTED.
109
+ *
110
+ * Matching is restricted to the prompt start (`^` without the `/m` multiline flag) so that
111
+ * user prompts discussing billing headers in intermediate lines are never modified, and
112
+ * prompts without a billing header preserve their leading whitespace untouched.
113
+ */
114
+ function stripAntigravityBillingHeader(systemText: string): string {
115
+ return systemText.replace(/^x-anthropic-billing-header:[^\n]*\n*/, "");
116
+ }
117
+
105
118
  /**
106
119
  * Documented output ceiling for a Google-surface model, or `undefined` when the id is not
107
120
  * recognized.
@@ -276,6 +289,7 @@ function messagesToGeminiFormat(
276
289
  parsed: OcxParsedRequest,
277
290
  identityModelId: string,
278
291
  stripRejectedClaudeSdkParagraph = false,
292
+ isCloudCodeAssist = false,
279
293
  ): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } {
280
294
  // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
281
295
  // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
@@ -285,9 +299,12 @@ function messagesToGeminiFormat(
285
299
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
286
300
  GOOGLE_BREVITY_INSTRUCTION,
287
301
  ].join("\n\n"), identityModelId);
288
- const systemText = stripRejectedClaudeSdkParagraph
289
- ? stripAntigravityRejectedClaudeSdkParagraph(identifiedSystemText)
302
+ let systemText = isCloudCodeAssist
303
+ ? stripAntigravityBillingHeader(identifiedSystemText)
290
304
  : identifiedSystemText;
305
+ if (stripRejectedClaudeSdkParagraph) {
306
+ systemText = stripAntigravityRejectedClaudeSdkParagraph(systemText);
307
+ }
291
308
  const systemInstruction = { parts: [{ text: systemText }] };
292
309
 
293
310
  const contents: unknown[] = [];
@@ -833,12 +850,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
833
850
  && /^gemini-/.test(routedModelId) && !isImageCapableModel(parsed.modelId);
834
851
  // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation.
835
852
  const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId;
836
- const stripRejectedClaudeSdkParagraph = provider.googleMode === "cloud-code-assist"
853
+ const isCloudCodeAssist = provider.googleMode === "cloud-code-assist";
854
+ const stripRejectedClaudeSdkParagraph = isCloudCodeAssist
837
855
  && rejectsClaudeSdkParagraph(parsed.modelId, routedModelId);
838
856
  const { systemInstruction, contents, replayedCallIds } = messagesToGeminiFormat(
839
857
  parsed,
840
858
  identityModelId,
841
859
  stripRejectedClaudeSdkParagraph,
860
+ isCloudCodeAssist,
842
861
  );
843
862
  lastInjectedCallIds = [...replayedCallIds];
844
863
  lastReasoningReplayScope = parsed._reasoningReplayScope;
@@ -6,6 +6,8 @@ import { recordOwnedConfigPath } from "../lib/config-ownership";
6
6
  import type { OcxProviderConfig, OcxParsedRequest } from "../types";
7
7
  import { createOpenAIChatAdapter } from "./openai-chat";
8
8
  import type { ProviderAdapter, AdapterRequest, IncomingMeta } from "./base";
9
+ import { createAdapterPhysicalSend } from "./physical-send";
10
+ import { SendBudgetExhaustedError } from "../lib/upstream-retry";
9
11
 
10
12
  const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
11
13
  export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";
@@ -248,33 +250,46 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
248
250
  },
249
251
 
250
252
  async fetchResponse(request: AdapterRequest, ctx): Promise<Response> {
251
- const response = await fetch(request.url, {
253
+ const send = createAdapterPhysicalSend(ctx);
254
+ const response = await send({ url: request.url, dispatch: executor => executor(request.url, {
252
255
  method: request.method,
253
256
  redirect: "manual",
254
257
  headers: request.headers as Record<string, string>,
255
258
  body: request.body,
256
259
  signal: ctx?.abortSignal,
257
- });
260
+ }) });
258
261
 
259
262
  // Retry predicate: 401 (expired/invalid JWT) retries ONCE with a fresh token.
260
263
  // 403 is NOT retried — Xiaomi uses it for anti-abuse "Illegal access" and there is
261
264
  // no documented token-expiry signature that would mark a 403 as retryable.
262
265
  if (response.status === 401) {
263
- // Drain the first response body before issuing the retry.
264
- try { await response.body?.cancel(); } catch { /* already consumed */ }
265
- resetMimoJwtCache();
266
- const freshJwt = await getMimoJwt(ctx?.abortSignal);
267
- const retryHeaders = {
268
- ...(request.headers as Record<string, string>),
269
- "Authorization": `Bearer ${freshJwt}`,
270
- };
271
- return fetch(request.url, {
272
- method: request.method,
273
- redirect: "manual",
274
- headers: retryHeaders,
275
- body: request.body,
276
- signal: ctx?.abortSignal,
277
- });
266
+ let retryHeaders = request.headers;
267
+ try {
268
+ return await send({ url: request.url, sendClass: "auth-recovery", recovery: "oauth-401",
269
+ beforeDispatch: async () => {
270
+ // Drain the first response body and refresh the JWT only after admission: a
271
+ // refused replay still returns THIS response to the caller, body intact.
272
+ // Draining comes first within the block because getMimoJwt issues its own
273
+ // network call and may throw, and the 401 body would then never be released.
274
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already consumed */ }
275
+ resetMimoJwtCache();
276
+ const freshJwt = await getMimoJwt(ctx?.abortSignal);
277
+ retryHeaders = {
278
+ ...(request.headers as Record<string, string>),
279
+ "Authorization": `Bearer ${freshJwt}`,
280
+ };
281
+ },
282
+ dispatch: executor => executor(request.url, {
283
+ method: request.method,
284
+ redirect: "manual",
285
+ headers: retryHeaders,
286
+ body: request.body,
287
+ signal: ctx?.abortSignal,
288
+ }) });
289
+ } catch (error) {
290
+ if (error instanceof SendBudgetExhaustedError) return response;
291
+ throw error;
292
+ }
278
293
  }
279
294
 
280
295
  return response;
@@ -306,17 +306,37 @@ function buildNativeMessages(
306
306
  // owned by this adapter/request lifecycle rather than process-global state.
307
307
  reservedToolCallIds.clear();
308
308
  let pending: PendingToolBatch | undefined;
309
+ // Codex records mid-turn injections (a PostToolUse hook verdict, a context notice) between an
310
+ // assistant tool call and that call's own tool result. Native Ollama needs the call and its
311
+ // results adjacent, so those conversational messages wait here instead of closing the batch
312
+ // early. The openai-chat adapter defers them the same way; refusing the replay killed the turn.
313
+ let deferred: OllamaNativeMessage[] = [];
314
+
315
+ const releaseDeferred = (): void => {
316
+ if (deferred.length === 0) return;
317
+ messages.push(...deferred);
318
+ deferred = [];
319
+ };
309
320
 
310
321
  const flushPending = (): void => {
311
322
  if (!pending) return;
312
323
  for (const call of pending.calls) {
313
324
  if (!call.result) {
314
- throw new Error(`ollama-native tool call ${call.id} is missing its tool result; refusing interrupted replay`);
325
+ // No result exists anywhere in the replayed history: the turn was interrupted, or the
326
+ // result never reached it. State exactly that instead of inventing an outcome, and keep
327
+ // the conversation replayable.
328
+ messages.push({
329
+ role: "tool",
330
+ tool_call_id: call.id,
331
+ tool_name: call.wireName,
332
+ // Same marker text as the chat adapter (openai-chat/messages.ts), so both adapters read
333
+ // the same in an operator's log. The name is this wire's flattened tool name, which is
334
+ // what the assistant turn above it carries.
335
+ content: `[ocx] no tool result was recorded for "${call.wireName}"; execution status unknown — do not treat this as success, failure, or user-provided input.`,
336
+ });
337
+ continue;
315
338
  }
316
- }
317
- for (const call of pending.calls) {
318
- const result = call.result!;
319
- const translated = contentToNative(result.content, "tool result");
339
+ const translated = contentToNative(call.result.content, "tool result");
320
340
  messages.push({
321
341
  role: "tool",
322
342
  tool_call_id: call.id,
@@ -326,6 +346,7 @@ function buildNativeMessages(
326
346
  });
327
347
  }
328
348
  pending = undefined;
349
+ releaseDeferred();
329
350
  };
330
351
 
331
352
  for (const message of parsed.context.messages) {
@@ -347,9 +368,22 @@ function buildNativeMessages(
347
368
  continue;
348
369
  }
349
370
 
350
- // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A
351
- // new conversational message is a hard boundary; unresolved calls are never fabricated.
352
- if (pending) flushPending();
371
+ // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A
372
+ // conversational message that arrives while the batch is still open is held aside instead of
373
+ // closing it, so the call keeps its results adjacent; it is released right after the batch
374
+ // flushes. Anything else (a new assistant turn) settles the batch first.
375
+ if (pending) {
376
+ if (message.role === "user" || message.role === "developer") {
377
+ const translated = message.role === "user"
378
+ ? contentToNative(message.content, "user")
379
+ : contentToNative(message.content, "developer", false);
380
+ deferred.push(message.role === "user"
381
+ ? { role: "user", content: translated.content, ...(translated.images ? { images: translated.images } : {}) }
382
+ : { role: "system", content: translated.content });
383
+ continue;
384
+ }
385
+ flushPending();
386
+ }
353
387
 
354
388
  switch (message.role) {
355
389
  case "user": {
@@ -1,4 +1,5 @@
1
1
  import { diagnoseInvalidToolCalls, isRecord, type InvalidToolCallDiagnostic } from "./tool-call-validation";
2
+ import { TranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
2
3
  import type { AdapterEvent, OcxUsage } from "../../types";
3
4
 
4
5
  export function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined {
@@ -22,6 +23,9 @@ export interface ReasoningDetailSegment {
22
23
  text: string;
23
24
  }
24
25
 
26
+ const MAX_REASONING_DETAIL_KEY_BYTES = 1024;
27
+ const MAX_REASONING_DETAIL_SEGMENTS = 1024;
28
+
25
29
  /**
26
30
  * Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`).
27
31
  * Each segment's key scopes cumulative-snapshot tracking: upstream repeats the
@@ -35,6 +39,11 @@ export function reasoningDetailSegmentsFrom(record: Record<string, unknown>): Re
35
39
  const item: unknown = raw[i];
36
40
  if (!isRecord(item)) continue;
37
41
  if (typeof item.text !== "string" || item.text.length === 0) continue;
42
+ // Parsing retains nothing, so it rejects nothing. The non-streaming
43
+ // parseResponse path shares this function and reads only `text`; failing a
44
+ // whole valid response there because an opaque upstream id is long would be
45
+ // a new rejection unrelated to the retention bound. The key cap lives with
46
+ // the map that holds the key; see the tracker below.
38
47
  const key = typeof item.id === "string" && item.id.length > 0
39
48
  ? `id:${item.id}`
40
49
  : typeof item.index === "number"
@@ -45,6 +54,58 @@ export function reasoningDetailSegmentsFrom(record: Record<string, unknown>): Re
45
54
  return segments;
46
55
  }
47
56
 
57
+ /**
58
+ * Per-stream cumulative-snapshot store for structured `reasoning_details`. Each stream chunk
59
+ * repeats a detail's full text-so-far, so deltas are derived by prefix-diffing per segment key;
60
+ * a piece that does not extend the previous snapshot is appended whole, which keeps incremental
61
+ * senders parseable on the same path. Retained key+text bytes are charged to the translator
62
+ * budget under the `reasoning` kind, and both the key length and the segment count are capped
63
+ * here, where the map actually retains them, so a hostile upstream cannot grow it without bound.
64
+ */
65
+ export function createReasoningDetailSnapshotTracker(budget: TranslatorBudget): {
66
+ ingest(segment: ReasoningDetailSegment): string | null;
67
+ release(): void;
68
+ } {
69
+ const snapshots = new Map<string, string>();
70
+ const encoder = new TextEncoder();
71
+ let retainedBytes = 0;
72
+ return {
73
+ ingest(segment) {
74
+ const existing = snapshots.get(segment.key);
75
+ if (existing === undefined && encoder.encode(segment.key).byteLength > MAX_REASONING_DETAIL_KEY_BYTES) {
76
+ throw new TranslatorBudgetExceededError("reasoning", MAX_REASONING_DETAIL_KEY_BYTES);
77
+ }
78
+ if (existing === undefined && snapshots.size >= MAX_REASONING_DETAIL_SEGMENTS) {
79
+ throw new TranslatorBudgetExceededError("reasoning", MAX_REASONING_DETAIL_SEGMENTS);
80
+ }
81
+ const prev = existing ?? "";
82
+ if (segment.text === prev) return null;
83
+ const extendsPrev = segment.text.startsWith(prev);
84
+ const next = extendsPrev ? segment.text : prev + segment.text;
85
+ const previousBytes = existing === undefined
86
+ ? 0
87
+ : encoder.encode(segment.key).byteLength + encoder.encode(prev).byteLength;
88
+ const nextBytes = encoder.encode(segment.key).byteLength + encoder.encode(next).byteLength;
89
+ const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" });
90
+ try {
91
+ snapshots.set(segment.key, next);
92
+ reservation.commitRetained();
93
+ budget.releaseRetained(previousBytes, { kind: "reasoning" });
94
+ retainedBytes += nextBytes - previousBytes;
95
+ } catch (error) {
96
+ reservation.release();
97
+ throw error;
98
+ }
99
+ return extendsPrev ? segment.text.slice(prev.length) : segment.text;
100
+ },
101
+ release() {
102
+ budget.releaseRetained(retainedBytes, { kind: "reasoning" });
103
+ retainedBytes = 0;
104
+ snapshots.clear();
105
+ },
106
+ };
107
+ }
108
+
48
109
  /** Single-segment `reasoning_details` entry for replaying preserved reasoning (MiniMax wire shape). */
49
110
  export function reasoningDetailSegmentForWire(text: string): Record<string, unknown> {
50
111
  return { type: "reasoning.text", id: "reasoning-text-1", format: "MiniMax-response-v1", index: 0, text };
@@ -23,6 +23,7 @@ import {
23
23
  type InvalidToolCallDiagnostic,
24
24
  } from "./openai-chat/tool-call-validation";
25
25
  import {
26
+ createReasoningDetailSnapshotTracker,
26
27
  invalidChoicesEvent,
27
28
  invalidToolCallsEvent,
28
29
  reasoningDetailSegmentsFrom,
@@ -385,7 +386,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
385
386
  // full text-so-far, so deltas are derived by prefix-diffing per segment key.
386
387
  // A piece that does not extend the previous snapshot is appended whole, which
387
388
  // keeps incremental senders parseable on the same path.
388
- const reasoningDetailSnapshots = new Map<string, string>();
389
+ const reasoningDetailTracker = createReasoningDetailSnapshotTracker(budget);
389
390
  // Gate on the routed model, not list length: a mixed openai-chat provider
390
391
  // can list MiniMax ids without putting every sibling on MiniMax semantics.
391
392
  const reasoningDetailsOptIn = modelInList(provider.reasoningDetailsModels, lastRequestedModelId ?? "");
@@ -448,15 +449,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
448
449
  const detailSegments = reasoningDetailsOptIn ? reasoningDetailSegmentsFrom(delta) : [];
449
450
  if (detailSegments.length > 0) {
450
451
  for (const segment of detailSegments) {
451
- const prev = reasoningDetailSnapshots.get(segment.key) ?? "";
452
- if (segment.text === prev) continue;
453
- if (segment.text.startsWith(prev)) {
454
- reasoningDetailSnapshots.set(segment.key, segment.text);
455
- yield { type: "reasoning_raw_delta", text: segment.text.slice(prev.length) };
456
- } else {
457
- reasoningDetailSnapshots.set(segment.key, prev + segment.text);
458
- yield { type: "reasoning_raw_delta", text: segment.text };
459
- }
452
+ const reasoningDelta = reasoningDetailTracker.ingest(segment);
453
+ if (reasoningDelta !== null) yield { type: "reasoning_raw_delta", text: reasoningDelta };
460
454
  }
461
455
  } else {
462
456
  const reasoningText = reasoningTextFrom(delta);
@@ -692,6 +686,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
692
686
  throw error;
693
687
  } finally {
694
688
  budget.releaseRetained(bufferBytes, { kind: "live_transient" });
689
+ reasoningDetailTracker.release();
695
690
  closeToolCalls();
696
691
  reader.releaseLock();
697
692
  }
@@ -32,11 +32,12 @@ import {
32
32
  createAdapterTierMetadata,
33
33
  } from "../../providers/fastwire";
34
34
  import { mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning";
35
- import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips";
35
+ import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips";
36
36
  import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache";
37
37
  import { isPlainObject } from "./internal";
38
38
  import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema";
39
- import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems } from "./tool-output-recovery";
39
+ import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery";
40
+ import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache";
40
41
  import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward";
41
42
  import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen";
42
43
  import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search";
@@ -270,19 +271,31 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
270
271
  // tier write so a force-fast/default decision can never mutate parsed._rawBody.
271
272
  outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision);
272
273
  const stateless = provider.statelessResponses === true;
274
+ const adjacentToolResults = provider.requiresAdjacentResponsesToolResults === true;
275
+ // Adjacency reorders items the upstream would accept in some order. Pairing synthesizes an
276
+ // item the client never sent, which is a larger claim about the conversation, so it is its
277
+ // own capability: Kimi carries the adjacency flag but accepts a dangling call (#4726) and
278
+ // must not start receiving placeholders it never needed.
279
+ const pairedToolResults = provider.requiresPairedResponsesToolResults === true;
273
280
  if (stateless) outBody = stripStatefulResponsesParams(outBody);
274
281
  // A replay miss can leave a function_call_output whose paired function_call sat
275
282
  // in the prefix that was never expanded. A stateless upstream cannot resolve the
276
283
  // pair from its own storage either, so it needs the same repair the forward
277
284
  // backend gets — dropping previous_response_id is not much use if the body that
278
285
  // reaches the wire is unparseable.
286
+ // A parser can also 400 on a function_call with no matching output at all. DeepSeek gets
287
+ // that repair through statelessResponses. xAI cannot be marked stateless: its Responses API
288
+ // stores conversations for 30 days and documents previous_response_id. So it carries the
289
+ // pairing capability instead, which reuses the orphan-call placeholder without touching
290
+ // store or previous_response_id.
279
291
  if (provider.annotateEmptyToolOutputs === true) {
280
292
  outBody = annotateEmptyResponsesToolOutputs(outBody, true);
281
293
  }
282
- if (forward || stateless) {
283
- outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
294
+ const synthesizeMissingCallOutputs = !forward && (stateless || pairedToolResults);
295
+ if (forward || stateless || pairedToolResults) {
296
+ outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs);
284
297
  }
285
- if (provider.requiresAdjacentResponsesToolResults === true) {
298
+ if (adjacentToolResults) {
286
299
  outBody = normalizeResponsesToolResultAdjacency(outBody);
287
300
  }
288
301
  if (forward) {
@@ -309,6 +322,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
309
322
  outBody = repairOversizedReplayCallIds(outBody);
310
323
  }
311
324
  outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
325
+ // #4587: on a bridged provider, hand the destination back the search call and result the
326
+ // proxy executed on its behalf, in place of the hosted cell the caller replays. Scoped to
327
+ // this destination and recorded by the bridge itself, so a provider without the opt-in
328
+ // computes no identity and keeps the body reference it already had. This runs before the
329
+ // query backfill below because a restored cell is no longer a web_search_call to repair.
330
+ if (provider.webSearchBridge?.enabled === true) {
331
+ outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(provider.baseUrl));
332
+ }
312
333
  // Repair stored history from before the bridge emitted both keys, in either
313
334
  // direction: a conversation that already recorded a web_search_call replays it
314
335
  // every turn, and a strict parser rejects the whole request over the missing key —
@@ -316,6 +337,20 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
316
337
  outBody = backfillWebSearchQueries(outBody);
317
338
  if (!isCanonicalOpenAiForwardProvider(provider)) {
318
339
  outBody = stripInternalChatMessageMetadataPassthrough(outBody);
340
+ // The same class of private field, one level up, but keyed on the DESTINATION rather than
341
+ // on the canonical surface alone. `src/server/responses/compact.ts` spreads the caller's
342
+ // raw body into the native `/responses/compact` request without passing through this
343
+ // adapter, and that endpoint is offered only to OpenAI-operated destinations
344
+ // (supportsNativeResponsesCompactEndpoint). Stripping on the canonical predicate here
345
+ // would make the two paths disagree for `openai-apikey`; stripping on the destination
346
+ // keeps every OpenAI-operated route byte-identical and removes the field exactly where it
347
+ // is known to break, which is a gateway this proxy does not operate.
348
+ //
349
+ // Placed before the routed compaction body is built and before serialization, so the HTTP,
350
+ // routed-compaction and WebSocket outbounds are all covered by this one call.
351
+ if (!isOpenAiOperatedResponsesDestination(provider)) {
352
+ outBody = stripCanonicalOnlyTopLevelFields(outBody);
353
+ }
319
354
  outBody = promoteClientLoadedTools(outBody);
320
355
  }
321
356
  if (!isCanonicalOpenAiForwardProvider(provider)) {
@@ -120,6 +120,49 @@ export function stripInternalChatMessageMetadataPassthrough(body: unknown): unkn
120
120
  return changed ? { ...body, input } : body;
121
121
  }
122
122
 
123
+ /**
124
+ * OpenAI-private TOP-LEVEL request keys, the sibling of `CANONICAL_ONLY_TOOL_FIELDS` one level up.
125
+ *
126
+ * Codex attaches these on the request body itself rather than on a tool or an input item, and gates
127
+ * them on its own auth rather than on the destination URL. Loopback injection keeps Codex pointed at
128
+ * its built-in `openai` provider, so the client still believes it is addressing the canonical
129
+ * ChatGPT backend and keeps the key no matter where this proxy routes the turn. A Responses gateway
130
+ * that validates its top-level schema then rejects the whole request before inference.
131
+ *
132
+ * Keep this a table, and keep it to keys a client is OBSERVED to send. It is not an unknown-field
133
+ * sanitizer: a top-level key nobody has traced to a client is forwarded untouched, because deleting
134
+ * it would silently drop a parameter some other caller means.
135
+ */
136
+ const CANONICAL_ONLY_TOP_LEVEL_FIELDS: readonly string[] = [
137
+ // Cyber access program selector, new in Codex 0.155. codex-rs mints it from
138
+ // `cyber_access_program::for_auth`, which filters on ChatGPT auth alone and never on the
139
+ // destination base URL, and serializes it on the Responses request, the compaction input and the
140
+ // WebSocket `response.create` envelope. No public specification defines it, so a strict
141
+ // third-party gateway answers with an unknown-parameter error naming it, and every turn of that
142
+ // thread fails (#4853).
143
+ //
144
+ // `codex_output_schema` is deliberately NOT here. In codex-rs it is the `name` of the JSON-schema
145
+ // `text.format` object, not a top-level key, so listing it would delete a field this client never
146
+ // sends and discard it for any client that does send it meaningfully.
147
+ "access_programs",
148
+ ];
149
+
150
+ /**
151
+ * Remove the OpenAI-private top-level keys.
152
+ *
153
+ * The caller decides the boundary; see the call site in `passthrough.ts`, which applies this only
154
+ * to a destination OpenCodex does not operate. Returns the input unchanged when no listed key is
155
+ * present, so the common path allocates nothing and the caller-owned raw body is never mutated.
156
+ */
157
+ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown {
158
+ if (!isPlainObject(body)) return body;
159
+ if (!CANONICAL_ONLY_TOP_LEVEL_FIELDS.some(field => Object.hasOwn(body, field))) return body;
160
+
161
+ const next = { ...body };
162
+ for (const field of CANONICAL_ONLY_TOP_LEVEL_FIELDS) delete next[field];
163
+ return next;
164
+ }
165
+
123
166
  /**
124
167
  * When `store` is false, the upstream API does not persist response items. Any item ID
125
168
  * forwarded in `input` is then interpreted as a reference to a stored item that does not
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation";
3
3
  import { isPlainObject } from "./internal";
4
+ import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache";
4
5
 
5
6
  const MAX_RESPONSES_CALL_ID_LENGTH = 64;
6
7
 
@@ -265,6 +266,80 @@ export function backfillWebSearchQueries(body: unknown): unknown {
265
266
  return changed ? { ...body, input } : body;
266
267
  }
267
268
 
269
+ /**
270
+ * Give a bridged destination back its own search call and result (issue #4587).
271
+ *
272
+ * When `providers.<name>.webSearchBridge` is armed, the proxy intercepts the destination's
273
+ * `function_call` named `web_search`, runs the search, and shows the CALLER a hosted
274
+ * `web_search_call` cell. The caller stores that cell and replays it on every later turn, so the
275
+ * destination receives an item type it never produced, carrying a query and sources but no result.
276
+ * It typically responds by searching again.
277
+ *
278
+ * This restores the exchange the destination actually had: the cell becomes the destination's own
279
+ * `function_call`, immediately followed by the `function_call_output` the bridge produced for
280
+ * it, in the cell's original position. It runs before the first leg of the next turn is
281
+ * dispatched, which is the only place it can run — by the time the bridge wraps a turn, that
282
+ * turn's first leg is already on the wire.
283
+ *
284
+ * Three things it deliberately does not do:
285
+ * - It never re-runs a search. A missing memo entry means the result is gone, and paying for a
286
+ * second search would answer the model with a different search than its history claims.
287
+ * - It never invents result text. A miss leaves the item exactly as the caller sent it, which is
288
+ * the behaviour every unbridged conversation already has.
289
+ * - It never restores a call id the body already carries. If the history somehow holds that
290
+ * `function_call` too, emitting a second one would be a duplicate the upstream must reject.
291
+ *
292
+ * Entries are scoped to the upstream destination, so a history replayed against a different
293
+ * provider cannot resurrect a call that provider never made. Callers pass `undefined` for any
294
+ * provider without the bridge armed, and the common path then returns the original reference.
295
+ */
296
+ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: string | undefined): unknown {
297
+ if (destinationScope === undefined) return body;
298
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
299
+ const input = body.input;
300
+
301
+ // Cheap pre-check: nothing to do for a conversation that carries no hosted search cell at all,
302
+ // which is every turn before the model's first bridged search.
303
+ let hasCell = false;
304
+ for (const item of input) {
305
+ if (isPlainObject(item) && item.type === "web_search_call" && typeof item.id === "string") {
306
+ hasCell = true;
307
+ break;
308
+ }
309
+ }
310
+ if (!hasCell) return body;
311
+
312
+ const occupiedCallIds = new Set<string>();
313
+ for (const item of input) {
314
+ if (isPlainObject(item) && typeof item.call_id === "string") occupiedCallIds.add(item.call_id);
315
+ }
316
+
317
+ let changed = false;
318
+ const restored: unknown[] = [];
319
+ for (const item of input) {
320
+ if (isPlainObject(item) && item.type === "web_search_call" && typeof item.id === "string") {
321
+ const memo = peekBridgeSearchReplay(destinationScope, item.id);
322
+ if (memo && !occupiedCallIds.has(memo.callId)) {
323
+ changed = true;
324
+ occupiedCallIds.add(memo.callId);
325
+ restored.push({
326
+ type: "function_call",
327
+ ...(memo.sourceItemId ? { id: memo.sourceItemId } : {}),
328
+ call_id: memo.callId,
329
+ name: memo.name,
330
+ // The bridge records the complete arguments text from the call's own done frame; the
331
+ // empty-object fallback matches what a continuation leg would have sent.
332
+ arguments: memo.argumentsText.length > 0 ? memo.argumentsText : "{}",
333
+ });
334
+ restored.push({ type: "function_call_output", call_id: memo.callId, output: memo.output });
335
+ continue;
336
+ }
337
+ }
338
+ restored.push(item);
339
+ }
340
+ return changed ? { ...body, input: restored } : body;
341
+ }
342
+
268
343
  export function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown {
269
344
  if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
270
345
  const input = body.input;
@@ -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;