@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
3
  import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
4
4
  import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
5
+ import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../codex/forward-transport-headers";
5
6
  import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction";
6
7
  import { collectResponsesToolGroups } from "../responses/tool-groups";
7
8
  import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";
@@ -20,6 +21,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co
20
21
  import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
21
22
  import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
22
23
  import { openaiResponsesUrl } from "./openai-responses-url";
24
+ import { normalizeResponsesCodeMode } from "./responses-code-mode";
23
25
  import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search";
24
26
  import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
25
27
  import {
@@ -51,6 +53,7 @@ export const FORWARD_HEADERS = [
51
53
  "x-oai-attestation",
52
54
  "x-openai-subagent",
53
55
  "x-responsesapi-include-timing-metrics",
56
+ CODEX_RESPONSES_LITE_HEADER,
54
57
  ];
55
58
 
56
59
  /**
@@ -187,7 +190,7 @@ const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: Readonly
187
190
  // OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so
188
191
  // it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields
189
192
  // === false), matching stripOpenAiOnlyWebSearchFields; see
190
- // tests/responses-routed-web-search-fields.test.ts.
193
+ // tests/responses/responses-routed-web-search-fields.test.ts.
191
194
  { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true },
192
195
  // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output`
193
196
  // already loaded, so a still-deferred declaration — including one promoted out of a namespace
@@ -906,6 +909,61 @@ function toolOutputText(output: unknown): string {
906
909
  }).filter(Boolean).join("\n");
907
910
  }
908
911
 
912
+ /** True when an output can be losslessly represented as user-message content. */
913
+ function isRepairableToolOutput(output: unknown): output is string | Record<string, unknown>[] {
914
+ if (typeof output === "string") return true;
915
+ if (!Array.isArray(output)) return false;
916
+ return output.every(part => {
917
+ if (!isPlainObject(part)) return false;
918
+ if (typeof part.type !== "string") return false;
919
+ if (["output_text", "text", "input_text"].includes(part.type)) {
920
+ return typeof part.text === "string";
921
+ }
922
+ if (part.type === "refusal") return typeof part.refusal === "string";
923
+ if (part.type === "encrypted_content") return typeof part.encrypted_content === "string";
924
+ if (part.type !== "input_image") return false;
925
+ const imageUrl = part.image_url;
926
+ const fileId = part.file_id;
927
+ const imageUrlIsString = typeof imageUrl === "string";
928
+ const fileIdIsString = typeof fileId === "string";
929
+ const hasUsableSource = (imageUrlIsString && imageUrl.length > 0)
930
+ || (fileIdIsString && fileId.length > 0);
931
+ const validSource = hasUsableSource
932
+ && (part.image_url === undefined || imageUrlIsString)
933
+ && (part.file_id === undefined || fileIdIsString);
934
+ const validDetail = part.detail === undefined
935
+ || (typeof part.detail === "string"
936
+ && ["auto", "low", "high", "original"].includes(part.detail));
937
+ return validSource && validDetail;
938
+ });
939
+ }
940
+
941
+ /** Convert orphaned tool output to user-message content without discarding valid images. */
942
+ function orphanedToolOutputContent(output: unknown, callId = ""): Record<string, unknown>[] {
943
+ const marker = `[tool output for ${callId || "unknown call"}]`;
944
+ if (typeof output !== "string" && !Array.isArray(output)) {
945
+ return [{ type: "input_text", text: marker }];
946
+ }
947
+ if (!Array.isArray(output)) {
948
+ return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }];
949
+ }
950
+
951
+ const content: Record<string, unknown>[] = [{ type: "input_text", text: marker }];
952
+ for (const part of output) {
953
+ if (!isPlainObject(part)) continue;
954
+ if (part.type === "input_image") {
955
+ content.push(part);
956
+ } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") {
957
+ content.push({ type: "input_text", text: "[encrypted content omitted]" });
958
+ } else if (typeof part.text === "string") {
959
+ content.push({ type: "input_text", text: part.text });
960
+ } else if (part.type === "refusal" && typeof part.refusal === "string") {
961
+ content.push({ type: "input_text", text: `[refusal] ${part.refusal}` });
962
+ }
963
+ }
964
+ return content;
965
+ }
966
+
909
967
  /** True when a Responses tool output item is present but carries no usable content. */
910
968
  function isToolOutputEmpty(output: unknown): boolean {
911
969
  if (typeof output === "string") return output.trim() === "";
@@ -940,6 +998,32 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk
940
998
  return changed ? { ...body, input } : body;
941
999
  }
942
1000
 
1001
+ /**
1002
+ * Preserve the text of structurally invalid tool-output items before they reach a strict
1003
+ * Responses parser. Stateful destinations may legitimately receive an output whose matching
1004
+ * call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally.
1005
+ * A missing or empty `call_id`, however, cannot identify stored state on any destination.
1006
+ */
1007
+ function repairUnidentifiedToolOutputItems(body: unknown): unknown {
1008
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
1009
+ let changed = false;
1010
+ const input = body.input.map(item => {
1011
+ if (!isPlainObject(item)
1012
+ || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")
1013
+ || (typeof item.call_id === "string" && item.call_id.length > 0)) {
1014
+ return item;
1015
+ }
1016
+ if (!isRepairableToolOutput(item.output)) return item;
1017
+ changed = true;
1018
+ return {
1019
+ type: "message",
1020
+ role: "user",
1021
+ content: orphanedToolOutputContent(item.output),
1022
+ };
1023
+ });
1024
+ return changed ? { ...body, input } : body;
1025
+ }
1026
+
943
1027
  /**
944
1028
  * Repair a forward-mode input array whose continuation context was lost. When the replay
945
1029
  * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
@@ -1060,12 +1144,17 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes
1060
1144
  flushPendingSyntheticOutputs();
1061
1145
  const callId = typeof item.call_id === "string" ? item.call_id : "";
1062
1146
  const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId);
1063
- if (!paired) {
1147
+ const usableOutput = isRepairableToolOutput(item.output);
1148
+ // A known orphan call is still useful as a labeled user message even when its output is
1149
+ // incomplete. With no call id and no output, preserve the invalid item so validation fails
1150
+ // closed rather than pretending any tool result exists.
1151
+ const knownNullOutput = callId.length > 0 && item.output == null;
1152
+ if (!paired && (knownNullOutput || usableOutput)) {
1064
1153
  changed = true;
1065
1154
  repaired.push({
1066
1155
  type: "message",
1067
1156
  role: "user",
1068
- content: [{ type: "input_text", text: `[tool output for ${callId || "unknown call"}]\n${toolOutputText(item.output)}` }],
1157
+ content: orphanedToolOutputContent(item.output, callId),
1069
1158
  });
1070
1159
  continue;
1071
1160
  }
@@ -1893,14 +1982,67 @@ function normalizeImageGenClientTools(body: unknown): unknown {
1893
1982
  * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
1894
1983
  * matches, keeping the common path allocation-free.
1895
1984
  */
1896
- function stripUnsupportedHostedTools(body: unknown): unknown {
1897
- if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
1985
+ function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxProviderConfig, "baseUrl">): unknown {
1986
+ if (!isPlainObject(body)) return body;
1898
1987
  const model = typeof body.model === "string" ? body.model : "";
1899
- const tools = body.tools.filter(t => {
1900
- const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
1901
- return !type || !isHostedToolUnsupportedForModel(model, type);
1902
- });
1903
- return tools.length === body.tools.length ? body : { ...body, tools };
1988
+ const filterTools = (tools: unknown[]): unknown[] => {
1989
+ const filtered = tools.filter(t => {
1990
+ const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
1991
+ return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl);
1992
+ });
1993
+ return filtered.length === tools.length ? tools : filtered;
1994
+ };
1995
+
1996
+ let next: Record<string, unknown> = body;
1997
+ let changed = false;
1998
+ if (Array.isArray(body.tools)) {
1999
+ const tools = filterTools(body.tools);
2000
+ if (tools !== body.tools) {
2001
+ next = { ...next, tools };
2002
+ changed = true;
2003
+ }
2004
+ }
2005
+ if (Array.isArray(body.input)) {
2006
+ let inputChanged = false;
2007
+ const input = body.input.map(item => {
2008
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
2009
+ const tools = filterTools(item.tools);
2010
+ if (tools === item.tools) return item;
2011
+ inputChanged = true;
2012
+ return { ...item, tools };
2013
+ });
2014
+ if (inputChanged) {
2015
+ next = { ...next, input };
2016
+ changed = true;
2017
+ }
2018
+ }
2019
+
2020
+ const toolChoice = next.tool_choice;
2021
+ if (isPlainObject(toolChoice) && toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
2022
+ const tools = filterTools(toolChoice.tools);
2023
+ if (tools !== toolChoice.tools) {
2024
+ next = { ...next, tool_choice: tools.length > 0 ? { ...toolChoice, tools } : "none" };
2025
+ changed = true;
2026
+ }
2027
+ } else if (
2028
+ isPlainObject(toolChoice)
2029
+ && typeof toolChoice.type === "string"
2030
+ && isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl)
2031
+ ) {
2032
+ next = { ...next, tool_choice: "none" };
2033
+ changed = true;
2034
+ } else if (changed && toolChoice === "required") {
2035
+ const hasDeclaredTools = (Array.isArray(next.tools) && next.tools.length > 0)
2036
+ || (Array.isArray(next.input) && next.input.some(item =>
2037
+ isPlainObject(item)
2038
+ && item.type === "additional_tools"
2039
+ && Array.isArray(item.tools)
2040
+ && item.tools.length > 0));
2041
+ if (!hasDeclaredTools) {
2042
+ next = { ...next, tool_choice: "none" };
2043
+ }
2044
+ }
2045
+ return changed ? next : body;
1904
2046
  }
1905
2047
 
1906
2048
  /**
@@ -1966,10 +2108,10 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
1966
2108
  }
1967
2109
 
1968
2110
  /**
1969
- * Muse Spark ids whose Responses gateway refuses `search_content_types` on a plain
2111
+ * Muse Spark ids whose Responses gateway refuses provider-specific fields on a plain
1970
2112
  * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the
1971
2113
  * same-shaped successor to 1.2 on the same Zen wire, and an equality check would
1972
- * have let a Codex-emitted `web_search` + `search_content_types` body reach the
2114
+ * have let a Codex-emitted `web_search` body reach the
1973
2115
  * gateway and come back 400 for every request the moment 1.3 was selected.
1974
2116
  */
1975
2117
  const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([
@@ -1977,26 +2119,51 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([
1977
2119
  "muse-spark-1.2-contributor",
1978
2120
  ]);
1979
2121
 
2122
+ const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([
2123
+ "https://opencode.ai/zen/v1/responses",
2124
+ "https://opencode.ai/zen/go/v1/responses",
2125
+ ]);
2126
+
2127
+ const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [
2128
+ "search_content_types",
2129
+ "indexed_web_access",
2130
+ ] as const;
2131
+
1980
2132
  /**
1981
- * OpenCode Zen / Go Muse Spark Responses gateway refuses `search_content_types`
1982
- * on a plain `web_search` tool (400) but accepts it on `web_search_preview`; a
1983
- * plain `web_search` is also accepted. Probed directly against the gateway on
1984
- * 2026-08-26: `web_search` + `search_content_types` -> 400, `web_search_preview`
1985
- * + `search_content_types` -> 200, plain `web_search` -> 200. Luna accepts every
1986
- * shape, so this is Muse-only. Drop only the field the gateway refuses while
1987
- * keeping the tool type and every other accepted option intact.
2133
+ * OpenCode Zen / Go Muse Spark Responses gateway refuses a short list of Codex
2134
+ * `web_search` fields. `web_search_preview` keeps its accepted shape, and Luna
2135
+ * remains untouched. Match the exact effective request URL; malformed, credentialed,
2136
+ * or parameterized destinations keep their original body instead of assuming this
2137
+ * gateway contract. Keep the rejected names together so a newly identified field is
2138
+ * a one-line compatibility update rather than another bespoke rewrite.
1988
2139
  */
1989
- function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown {
2140
+ function stripMuseSparkUnsupportedWebSearchFields(
2141
+ body: unknown,
2142
+ modelId: unknown,
2143
+ responseUrl: string,
2144
+ ): unknown {
1990
2145
  if (!isPlainObject(body)) return body;
1991
2146
  if (typeof modelId !== "string") return body;
1992
2147
  if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;
2148
+ let destination: string;
2149
+ try {
2150
+ const url = new URL(responseUrl);
2151
+ if (url.username || url.password || url.search || url.hash) return body;
2152
+ destination = `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
2153
+ } catch {
2154
+ return body;
2155
+ }
2156
+ if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body;
1993
2157
 
1994
2158
  const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
1995
2159
  let changed = false;
1996
2160
  const rewritten = tools.map(tool => {
1997
2161
  if (!isPlainObject(tool) || tool.type !== "web_search") return tool;
1998
- if (!Object.hasOwn(tool, "search_content_types")) return tool;
1999
- const { search_content_types: _dropped, ...rest } = tool;
2162
+ if (!MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) {
2163
+ return tool;
2164
+ }
2165
+ const rest = { ...tool };
2166
+ for (const field of MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS) delete rest[field];
2000
2167
  changed = true;
2001
2168
  return rest;
2002
2169
  });
@@ -2152,7 +2319,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2152
2319
  if (mayForwardCallerCredentials) {
2153
2320
  for (const h of FORWARD_HEADERS) {
2154
2321
  const v = incoming?.headers.get(h);
2155
- if (v) headers[h] = v; // …so forwarded auth always wins.
2322
+ if (v) {
2323
+ if (h === CODEX_RESPONSES_LITE_HEADER) {
2324
+ for (const name of Object.keys(headers)) {
2325
+ if (name.toLowerCase() === h) delete headers[name];
2326
+ }
2327
+ }
2328
+ headers[h] = v; // …so genuine forwarded fields win.
2329
+ }
2156
2330
  }
2157
2331
  }
2158
2332
  const override = runtimeProvider._codexAccountOverride;
@@ -2265,18 +2439,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2265
2439
  if (provider.supportsOpenAiWebSearchToolFields === false) {
2266
2440
  outBody = stripOpenAiOnlyWebSearchFields(outBody);
2267
2441
  }
2268
- outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId);
2442
+ outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url);
2269
2443
  // Last, so promoted namespace children are also cleared of Codex-private fields.
2270
2444
  outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
2271
2445
  }
2272
2446
  // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would
2273
2447
  // let a noncanonical custom forward provider skip this rewrite while the server still routes
2274
2448
  // it as a summarizer turn (#422). The compaction body build removes the tool surface and must
2275
- // therefore be the last routed transform: anything before it may depend on the declarations;
2276
- // anything after it cannot.
2449
+ // therefore be the last routed transform that may depend on those declarations. Structural
2450
+ // sanitizers below can still run after it.
2451
+ outBody = normalizeResponsesCodeMode(outBody, parsed, provider);
2277
2452
  if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
2278
2453
  outBody = buildRoutedCompactionBody(outBody);
2279
2454
  }
2455
+ // Run after routed compaction so nested input_image parts are replaced before a malformed
2456
+ // tool output is flattened to text and can no longer be inspected structurally.
2457
+ outBody = repairUnidentifiedToolOutputItems(outBody);
2280
2458
  const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
2281
2459
  const sanitizedBody = normalizeToolSchemas(
2282
2460
  stripSparkCompatibility(
@@ -2296,6 +2474,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2296
2474
  stripEncryptedContent: threadServingIdentityChanged,
2297
2475
  },
2298
2476
  ),
2477
+ provider,
2299
2478
  ),
2300
2479
  ),
2301
2480
  ),
@@ -2312,6 +2491,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2312
2491
  provider,
2313
2492
  parsed.modelId,
2314
2493
  );
2494
+ if (isCanonicalOpenAiForwardProvider(provider)) {
2495
+ const routingHeaders = new Headers(headers);
2496
+ applyCodexRoutingHint(routingHeaders, finalBody);
2497
+ // Static headers may use mixed casing. Remove every stale spelling
2498
+ // without normalizing unrelated headers returned by this adapter.
2499
+ for (const name of Object.keys(headers)) {
2500
+ if (name.toLowerCase() === CODEX_ROUTING_HINT_HEADER) delete headers[name];
2501
+ }
2502
+ const hint = routingHeaders.get(CODEX_ROUTING_HINT_HEADER);
2503
+ if (hint !== null) headers[CODEX_ROUTING_HINT_HEADER] = hint;
2504
+ }
2315
2505
  const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string"
2316
2506
  ? finalBody.service_tier
2317
2507
  : null;
@@ -0,0 +1,59 @@
1
+ import { toolChoiceToolPredicate, type OcxParsedRequest, type OcxProviderConfig } from "../types";
2
+ import { isOpenAiOperatedResponsesDestination } from "../providers/openai-tiers";
3
+ import { CODE_MODE_RESULT_ECHO_SENTENCE, normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
4
+ import { isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
5
+
6
+ function record(value: unknown): value is Record<string, unknown> {
7
+ return !!value && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+
10
+ /** Inspect the whole result, not just its empty header: later text or media is real output. */
11
+ function textOnlyOutput(output: unknown): string | undefined {
12
+ if (typeof output === "string") return output;
13
+ if (!Array.isArray(output)) return undefined;
14
+ if (!output.every(part => record(part)
15
+ && ["text", "input_text", "output_text"].includes(String(part.type))
16
+ && typeof part.text === "string")) return undefined;
17
+ return output.map(part => part.text).join("\n");
18
+ }
19
+
20
+ function withExecInputGuidance(tool: unknown): unknown {
21
+ if (!record(tool) || tool.type !== "function" || tool.name !== "exec" || tool.namespace !== undefined) return tool;
22
+ if (!record(tool.parameters) || !record(tool.parameters.properties) || !record(tool.parameters.properties.input)) return tool;
23
+ return { ...tool, parameters: { ...tool.parameters, properties: {
24
+ ...tool.parameters.properties,
25
+ input: {
26
+ ...tool.parameters.properties.input,
27
+ description: `JavaScript source for unified exec; do not provide a bare shell command. ${CODE_MODE_RESULT_ECHO_SENTENCE}`,
28
+ },
29
+ } } };
30
+ }
31
+
32
+ /** Native routed Responses needs the same first-call/output contract as translated adapters. */
33
+ export function normalizeResponsesCodeMode(body: unknown, parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown {
34
+ if (!record(body) || parsed._compactionRequest || isOpenAiOperatedResponsesDestination(provider)) return body;
35
+ const visible = parsed.context.tools?.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
36
+ if (!visible?.some(isCodexCodeModeExecTool) || visible.some(isBareShellBridgeTool)) return body;
37
+ const instructions = typeof body.instructions === "string" ? body.instructions : "";
38
+ const input = Array.isArray(body.input) ? body.input : undefined;
39
+ const execCalls = new Set(input?.filter(item => record(item)
40
+ && (item.type === "function_call" || item.type === "custom_tool_call")
41
+ && item.name === "exec" && item.namespace === undefined && typeof item.call_id === "string")
42
+ .map(item => item.call_id));
43
+ return {
44
+ ...body,
45
+ instructions: instructions.includes(CODE_MODE_RESULT_ECHO_SENTENCE)
46
+ ? instructions : [instructions, CODE_MODE_RESULT_ECHO_SENTENCE].filter(Boolean).join("\n\n"),
47
+ ...(Array.isArray(body.tools) ? { tools: body.tools.map(withExecInputGuidance) } : {}),
48
+ ...(input ? { input: input.map(item => {
49
+ if (!record(item)) return item;
50
+ if (item.type === "additional_tools" && Array.isArray(item.tools)) {
51
+ return { ...item, tools: item.tools.map(withExecInputGuidance) };
52
+ }
53
+ if ((item.type !== "function_call_output" && item.type !== "custom_tool_call_output") || !execCalls.has(item.call_id)) return item;
54
+ const text = textOnlyOutput(item.output);
55
+ const normalized = text === undefined ? undefined : normalizeEmptyExecToolResultText(text, { toolName: "exec" });
56
+ return normalized === undefined ? item : { ...item, output: normalized };
57
+ }) } : {}),
58
+ };
59
+ }
@@ -121,7 +121,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
121
121
  "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
122
122
  "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.",
123
123
  verifiedCodeModeExecName
124
- ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
124
+ ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch`, each marker line being three asterisks, one space, the two words, then end of line with no further asterisks. OpenCodex does not rewrite JavaScript inside exec, so extra asterisks on a marker line are rejected by Codex before the file is touched."
125
125
  : "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.",
126
126
  unavailableNeighborNames.length > 0
127
127
  ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names."
@@ -0,0 +1,86 @@
1
+ export function isSchemaObject(value: unknown): value is Record<string, unknown> {
2
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ function decodeJsonPointerToken(token: string): string {
6
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
7
+ }
8
+
9
+ /** Resolve a local `#/`-rooted JSON Pointer against `root`; undefined when it does not resolve. */
10
+ export function lookupLocalJsonPointer(root: unknown, ref: string): unknown {
11
+ if (ref === "#" || ref === "#/") return root;
12
+ if (!ref.startsWith("#/")) return undefined;
13
+ let current: unknown = root;
14
+ for (const token of ref.slice(2).split("/").map(decodeJsonPointerToken)) {
15
+ if (!isSchemaObject(current) || !Object.hasOwn(current, token)) return undefined;
16
+ current = current[token];
17
+ }
18
+ return current;
19
+ }
20
+
21
+ /** Values a schema pins through `const`/`enum`, or undefined when it pins none. */
22
+ function xaiLiteralValues(schema: unknown): unknown[] | undefined {
23
+ if (!isSchemaObject(schema)) return undefined;
24
+ if (Object.hasOwn(schema, "const")) return [schema.const];
25
+ if (Array.isArray(schema.enum)) return schema.enum;
26
+ return undefined;
27
+ }
28
+
29
+ /** JSON type name for a literal, so it can be compared against a `type` keyword. */
30
+ function xaiJsonTypeOf(value: unknown): string {
31
+ if (value === null) return "null";
32
+ if (Array.isArray(value)) return "array";
33
+ if (typeof value === "string") return "string";
34
+ if (typeof value === "boolean") return "boolean";
35
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
36
+ return "object";
37
+ }
38
+
39
+ /** Types a schema declares, or undefined when it constrains none. */
40
+ function xaiDeclaredTypes(schema: unknown): Set<string> | undefined {
41
+ if (!isSchemaObject(schema)) return undefined;
42
+ const type = schema.type;
43
+ if (typeof type === "string") return new Set([type]);
44
+ if (Array.isArray(type) && type.every(item => typeof item === "string")) return new Set(type as string[]);
45
+ return undefined;
46
+ }
47
+
48
+ /** `integer` is a subset of `number`, so those two names overlap rather than exclude. */
49
+ function xaiTypesOverlap(left: string, right: string): boolean {
50
+ if (left === right) return true;
51
+ return (left === "integer" && right === "number") || (left === "number" && right === "integer");
52
+ }
53
+
54
+ /**
55
+ * Conservative mutual-exclusion test: true only when no instance can satisfy both schemas.
56
+ * Proof comes from disjoint literal sets or disjoint declared types; anything it cannot prove
57
+ * is reported as overlapping so the caller refuses the merge instead of widening the schema.
58
+ */
59
+ function xaiSchemasAreProvablyDisjoint(left: unknown, right: unknown): boolean {
60
+ const leftValues = xaiLiteralValues(left);
61
+ const rightValues = xaiLiteralValues(right);
62
+ if (leftValues && rightValues) {
63
+ const seen = new Set(rightValues.map(value => JSON.stringify(value)));
64
+ return leftValues.every(value => !seen.has(JSON.stringify(value)));
65
+ }
66
+ const leftTypes = xaiDeclaredTypes(left);
67
+ const rightTypes = xaiDeclaredTypes(right);
68
+ const literalsExcludedByTypes = (values: unknown[], types: Set<string>): boolean =>
69
+ values.every(value => ![...types].some(type => xaiTypesOverlap(xaiJsonTypeOf(value), type)));
70
+ if (leftValues && rightTypes) return literalsExcludedByTypes(leftValues, rightTypes);
71
+ if (rightValues && leftTypes) return literalsExcludedByTypes(rightValues, leftTypes);
72
+ if (leftTypes && rightTypes) {
73
+ return ![...leftTypes].some(leftType => [...rightTypes].some(rightType => xaiTypesOverlap(leftType, rightType)));
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /** Every pair provably disjoint, so a union over them accepts each instance exactly once. */
79
+ export function xaiSchemasArePairwiseDisjoint(schemas: unknown[]): boolean {
80
+ for (let i = 0; i < schemas.length; i += 1) {
81
+ for (let j = i + 1; j < schemas.length; j += 1) {
82
+ if (!xaiSchemasAreProvablyDisjoint(schemas[i], schemas[j])) return false;
83
+ }
84
+ }
85
+ return true;
86
+ }
@@ -1,8 +1,6 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
-
3
- function isSchemaObject(value: unknown): value is Record<string, unknown> {
4
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5
- }
2
+ export { lookupLocalJsonPointer } from "./xai-schema-analysis";
3
+ import { isSchemaObject, lookupLocalJsonPointer, xaiSchemasArePairwiseDisjoint } from "./xai-schema-analysis";
6
4
 
7
5
  export function isXaiSchemaTarget(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
8
6
  try {
@@ -58,22 +56,6 @@ function createXaiSchemaBudget(): XaiSchemaBudget {
58
56
  return { remainingNodes: XAI_MAX_SCHEMA_NODES, remainingVariants: XAI_MAX_ROOT_VARIANTS };
59
57
  }
60
58
 
61
- function decodeJsonPointerToken(token: string): string {
62
- return token.replace(/~1/g, "/").replace(/~0/g, "~");
63
- }
64
-
65
- /** Resolve a local `#/`-rooted JSON Pointer against `root`; undefined when it does not resolve. */
66
- export function lookupLocalJsonPointer(root: unknown, ref: string): unknown {
67
- if (ref === "#" || ref === "#/") return root;
68
- if (!ref.startsWith("#/")) return undefined;
69
- let current: unknown = root;
70
- for (const token of ref.slice(2).split("/").map(decodeJsonPointerToken)) {
71
- if (!isSchemaObject(current) || !Object.hasOwn(current, token)) return undefined;
72
- current = current[token];
73
- }
74
- return current;
75
- }
76
-
77
59
  /** Resolve local `#/` `$ref`s. Unresolvable, cyclic, or over-budget refs return undefined. */
78
60
  function resolveXaiSchemaRefs(
79
61
  schema: unknown,
@@ -168,73 +150,6 @@ function xaiRequiredSetsMatch(variants: Record<string, unknown>[]): boolean {
168
150
  return serialized.every(value => value === serialized[0]);
169
151
  }
170
152
 
171
- /** Values a schema pins through `const`/`enum`, or undefined when it pins none. */
172
- function xaiLiteralValues(schema: unknown): unknown[] | undefined {
173
- if (!isSchemaObject(schema)) return undefined;
174
- if (Object.hasOwn(schema, "const")) return [schema.const];
175
- if (Array.isArray(schema.enum)) return schema.enum;
176
- return undefined;
177
- }
178
-
179
- /** JSON type name for a literal, so it can be compared against a `type` keyword. */
180
- function xaiJsonTypeOf(value: unknown): string {
181
- if (value === null) return "null";
182
- if (Array.isArray(value)) return "array";
183
- if (typeof value === "string") return "string";
184
- if (typeof value === "boolean") return "boolean";
185
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
186
- return "object";
187
- }
188
-
189
- /** Types a schema declares, or undefined when it constrains none. */
190
- function xaiDeclaredTypes(schema: unknown): Set<string> | undefined {
191
- if (!isSchemaObject(schema)) return undefined;
192
- const type = schema.type;
193
- if (typeof type === "string") return new Set([type]);
194
- if (Array.isArray(type) && type.every(item => typeof item === "string")) return new Set(type as string[]);
195
- return undefined;
196
- }
197
-
198
- /** `integer` is a subset of `number`, so those two names overlap rather than exclude. */
199
- function xaiTypesOverlap(left: string, right: string): boolean {
200
- if (left === right) return true;
201
- return (left === "integer" && right === "number") || (left === "number" && right === "integer");
202
- }
203
-
204
- /**
205
- * Conservative mutual-exclusion test: true only when no instance can satisfy both schemas.
206
- * Proof comes from disjoint literal sets or disjoint declared types; anything it cannot prove
207
- * is reported as overlapping so the caller refuses the merge instead of widening the schema.
208
- */
209
- function xaiSchemasAreProvablyDisjoint(left: unknown, right: unknown): boolean {
210
- const leftValues = xaiLiteralValues(left);
211
- const rightValues = xaiLiteralValues(right);
212
- if (leftValues && rightValues) {
213
- const seen = new Set(rightValues.map(value => JSON.stringify(value)));
214
- return leftValues.every(value => !seen.has(JSON.stringify(value)));
215
- }
216
- const leftTypes = xaiDeclaredTypes(left);
217
- const rightTypes = xaiDeclaredTypes(right);
218
- const literalsExcludedByTypes = (values: unknown[], types: Set<string>): boolean =>
219
- values.every(value => ![...types].some(type => xaiTypesOverlap(xaiJsonTypeOf(value), type)));
220
- if (leftValues && rightTypes) return literalsExcludedByTypes(leftValues, rightTypes);
221
- if (rightValues && leftTypes) return literalsExcludedByTypes(rightValues, leftTypes);
222
- if (leftTypes && rightTypes) {
223
- return ![...leftTypes].some(leftType => [...rightTypes].some(rightType => xaiTypesOverlap(leftType, rightType)));
224
- }
225
- return false;
226
- }
227
-
228
- /** Every pair provably disjoint, so a union over them accepts each instance exactly once. */
229
- function xaiSchemasArePairwiseDisjoint(schemas: unknown[]): boolean {
230
- for (let i = 0; i < schemas.length; i += 1) {
231
- for (let j = i + 1; j < schemas.length; j += 1) {
232
- if (!xaiSchemasAreProvablyDisjoint(schemas[i], schemas[j])) return false;
233
- }
234
- }
235
- return true;
236
- }
237
-
238
153
  /** Deduplicate schemas by serialized shape, preserving first-seen order. */
239
154
  function uniqueXaiSchemas(values: unknown[]): unknown[] {
240
155
  const unique: unknown[] = [];
@@ -55,7 +55,7 @@ function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite {
55
55
  // `search_context_size` 400s, while `user_location`, `search_content_types`, `filters` and
56
56
  // `enable_image_search` are all accepted. Deleting the accepted ones was a silent capability
57
57
  // loss, and it contradicted the sibling layer, whose own probe note already records
58
- // user_location/filters as accepted (tests/responses-routed-web-search-fields.test.ts).
58
+ // user_location/filters as accepted (tests/responses/responses-routed-web-search-fields.test.ts).
59
59
  delete next.external_web_access;
60
60
  delete next.search_context_size;
61
61
  if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) {