@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -114,3 +114,30 @@ export function renderCompactionSearchHistoryItems(items: readonly unknown[], re
114
114
  if (omissionIndex >= 0) out[omissionIndex] = omissionNote(omitted);
115
115
  return out;
116
116
  }
117
+
118
+ function isHostedSearchTool(tool: unknown): boolean {
119
+ return isPlainObject(tool) && typeof tool.type === "string" && tool.type.startsWith("web_search");
120
+ }
121
+
122
+ /**
123
+ * True when the outbound body declares a hosted search tool, either top-level or inside a Lite
124
+ * `additional_tools` input wrapper. Read on the final body, after unsupported hosted tools were
125
+ * stripped, so a declaration the destination never sees does not count.
126
+ */
127
+ export function declaresHostedWebSearch(body: Record<string, unknown>): boolean {
128
+ if (Array.isArray(body.tools) && body.tools.some(isHostedSearchTool)) return true;
129
+ return Array.isArray(body.input) && body.input.some(item => isPlainObject(item)
130
+ && item.type === "additional_tools" && Array.isArray(item.tools) && item.tools.some(isHostedSearchTool));
131
+ }
132
+
133
+ /**
134
+ * Project unreferenced hosted `web_search_call` history into reference notes. A same-input
135
+ * `item_reference` to a hosted cell keeps that cell structural. Returns the original body when
136
+ * there is nothing to project.
137
+ */
138
+ export function projectHostedSearchHistory(body: Record<string, unknown>): Record<string, unknown> {
139
+ if (!Array.isArray(body.input) || !body.input.some(item => isPlainObject(item) && item.type === "web_search_call")) return body;
140
+ const referencedIds = new Set(body.input.flatMap(item => isPlainObject(item)
141
+ && item.type === "item_reference" && typeof item.id === "string" ? [item.id] : []));
142
+ return { ...body, input: renderCompactionSearchHistoryItems(body.input, referencedIds) };
143
+ }
@@ -0,0 +1,139 @@
1
+ import { isPlainObject } from "./internal";
2
+ import { normalizeResponsesToolResultAdjacency } from "./tool-output-recovery";
3
+
4
+ export class OpenGatewayInputCompatibilityError extends Error {
5
+ constructor() {
6
+ super("OpenGateway cannot preserve this tool result; use text or valid input_image parts with a call_id.");
7
+ this.name = "OpenGatewayInputCompatibilityError";
8
+ }
9
+ }
10
+
11
+ const TOOL_ROUND_TYPES = new Set([
12
+ "function_call",
13
+ "function_call_output",
14
+ "custom_tool_call",
15
+ "custom_tool_call_output",
16
+ "local_shell_call",
17
+ "local_shell_call_output",
18
+ ]);
19
+
20
+ function normalizeImageDetail(part: unknown): unknown {
21
+ return isPlainObject(part) && part.type === "input_image" && part.detail === "original"
22
+ ? { ...part, detail: "high" }
23
+ : part;
24
+ }
25
+
26
+ function isToolRound(item: unknown): boolean {
27
+ return isPlainObject(item) && typeof item.type === "string" && TOOL_ROUND_TYPES.has(item.type);
28
+ }
29
+
30
+ function pairedCallId(item: Record<string, unknown>): string | undefined {
31
+ return typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : undefined;
32
+ }
33
+
34
+ type ProjectedInput = { readonly changed: boolean; readonly input: unknown[] };
35
+
36
+ function projectOpenGatewayInput(input: readonly unknown[]): ProjectedInput {
37
+ let changed = false;
38
+ const projected: unknown[] = [];
39
+ const pendingImages: unknown[] = [];
40
+ const flushImages = (): void => {
41
+ if (pendingImages.length === 0) return;
42
+ projected.push(...pendingImages);
43
+ pendingImages.length = 0;
44
+ };
45
+ for (const item of input) {
46
+ if (!isToolRound(item)) flushImages();
47
+ if (!isPlainObject(item)) {
48
+ projected.push(item);
49
+ continue;
50
+ }
51
+ if (item.type === "function_call_output" && Array.isArray(item.output)) {
52
+ const callId = pairedCallId(item);
53
+ const content: Record<string, unknown>[] = [];
54
+ const text: string[] = [];
55
+ let supported = callId !== undefined;
56
+ let hasImage = false;
57
+ if (supported) {
58
+ for (const part of item.output) {
59
+ if (!isPlainObject(part)) {
60
+ supported = false;
61
+ break;
62
+ }
63
+ switch (part.type) {
64
+ case "text":
65
+ case "input_text":
66
+ case "output_text":
67
+ if (typeof part.text !== "string") {
68
+ supported = false;
69
+ break;
70
+ }
71
+ text.push(part.text);
72
+ content.push({ type: "input_text", text: part.text });
73
+ break;
74
+ case "input_image":
75
+ if (!((typeof part.image_url === "string" && part.image_url.length > 0)
76
+ || (typeof part.file_id === "string" && part.file_id.length > 0))
77
+ || (part.image_url !== undefined && typeof part.image_url !== "string")
78
+ || (part.file_id !== undefined && typeof part.file_id !== "string")
79
+ || (part.detail !== undefined && (typeof part.detail !== "string"
80
+ || !["auto", "low", "high", "original"].includes(part.detail)))) {
81
+ supported = false;
82
+ break;
83
+ }
84
+ content.push({ ...part, ...(part.detail === "original" ? { detail: "high" } : {}) });
85
+ hasImage = true;
86
+ break;
87
+ default:
88
+ supported = false;
89
+ }
90
+ if (!supported) break;
91
+ }
92
+ }
93
+ if (supported && callId !== undefined) {
94
+ changed = true;
95
+ const marker = `[tool output for ${callId}]`;
96
+ projected.push({
97
+ ...item,
98
+ output: text.length > 0 ? text.join("\n") : hasImage ? marker : "",
99
+ });
100
+ if (hasImage) {
101
+ pendingImages.push({
102
+ type: "message",
103
+ role: "user",
104
+ content: [{ type: "input_text", text: marker }, ...content],
105
+ });
106
+ }
107
+ continue;
108
+ }
109
+ throw new OpenGatewayInputCompatibilityError();
110
+ }
111
+ if (Array.isArray(item.content)) {
112
+ const originalContent = item.content;
113
+ const content = originalContent.map(normalizeImageDetail);
114
+ if (content.some((part, index) => part !== originalContent[index])) {
115
+ changed = true;
116
+ projected.push({ ...item, content });
117
+ continue;
118
+ }
119
+ }
120
+ projected.push(item);
121
+ }
122
+ flushImages();
123
+ return { changed, input: projected };
124
+ }
125
+
126
+ /** OpenGateway requires string tool results and auto/low/high image detail. */
127
+ export function normalizeOpenGatewayResponsesInput(body: unknown, url: string): unknown {
128
+ // Same exact-URL boundary as completion recovery; a query-routed variant is a different destination.
129
+ if (url !== "https://apis.opengateway.ai/v1/responses" || !isPlainObject(body) || !Array.isArray(body.input)) return body;
130
+
131
+ const direct = projectOpenGatewayInput(body.input);
132
+ if (!direct.changed) return body;
133
+ const aligned = normalizeResponsesToolResultAdjacency(body);
134
+ if (!isPlainObject(aligned) || !Array.isArray(aligned.input) || aligned === body) {
135
+ return { ...body, input: direct.input };
136
+ }
137
+ const paired = projectOpenGatewayInput(aligned.input);
138
+ return { ...aligned, input: paired.changed ? paired.input : direct.input };
139
+ }
@@ -53,8 +53,9 @@ import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchField
53
53
  import { observeOutbound } from "../../usage/cache-diagnostic";
54
54
 
55
55
  import { normalizeMuseToolChoice } from "./muse-tool-choice";
56
- import { renderCompactionSearchHistoryItems } from "./compaction-search-history";
56
+ import { declaresHostedWebSearch, projectHostedSearchHistory, renderCompactionSearchHistoryItems } from "./compaction-search-history";
57
57
  import { stripKimiEmptyAssistantMessages } from "./empty-assistant";
58
+ import { normalizeOpenGatewayResponsesInput } from "./opengateway-input";
58
59
 
59
60
  /**
60
61
  * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
@@ -135,7 +136,8 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
135
136
  const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
136
137
  const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
137
138
  || (isPlainObject(usage.input_tokens_details)
138
- && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"))
139
+ && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"
140
+ && key !== "cache_write_1h_tokens"))
139
141
  || (isPlainObject(usage.output_tokens_details)
140
142
  && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
141
143
  if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
@@ -147,6 +149,8 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
147
149
  ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
148
150
  ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
149
151
  ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
152
+ ...(typeof inputDetails?.cache_write_1h_tokens === "number"
153
+ ? { cacheCreation1hInputTokens: inputDetails.cache_write_1h_tokens } : {}),
150
154
  ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
151
155
  ...(hasExtras ? { rawUsage: { ...usage } } : {}),
152
156
  };
@@ -492,22 +496,26 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
492
496
  // here, on the serialized body, not on the parsed selector. One place covers both the
493
497
  // HTTP and the WebSocket outbound, because the WS path transports this same request
494
498
  // instead of rebuilding it.
495
- // Opt-in Codex Lite history policy: replay remaining hosted actions as untrusted metadata,
496
- // after bridge restoration/tool validation. Tool declarations and other input stay intact.
497
- if (provider.projectResponsesLiteHostedSearchHistory === true && url === `${CODEX_FORWARD_BASE_URL}/responses`
498
- && !parsed._compactionRequest && !parsed._portableCompaction
499
- && codexResponsesLiteSelected(headers, finalBody)
500
- && isPlainObject(finalBody) && Array.isArray(finalBody.input)
501
- && finalBody.input.some(item => isPlainObject(item) && item.type === "web_search_call")) {
502
- const referencedIds = new Set(finalBody.input.flatMap(item => isPlainObject(item)
503
- && item.type === "item_reference" && typeof item.id === "string" ? [item.id] : []));
504
- finalBody = { ...finalBody, input: renderCompactionSearchHistoryItems(finalBody.input, referencedIds) };
499
+ // Hosted-search history projection, after bridge restoration and tool validation. Two
500
+ // triggers share one bounded renderer, and tool declarations and other input stay intact:
501
+ // 1. The opt-in Codex Lite policy covers ordinary turns and native v2 compaction triggers
502
+ // (#7268). Portable compaction was already projected by the summary builder above.
503
+ // 2. Any request whose final tool surface declares no hosted search. Native Sol answers a
504
+ // replayed web_search_call without a matching declaration with a generic server_error
505
+ // (#7250), so the cells become untrusted reference notes instead of a doomed request.
506
+ if (isPlainObject(finalBody)
507
+ && ((provider.projectResponsesLiteHostedSearchHistory === true && url === `${CODEX_FORWARD_BASE_URL}/responses`
508
+ && !parsed._portableCompaction && codexResponsesLiteSelected(headers, finalBody))
509
+ || !declaresHostedWebSearch(finalBody))) {
510
+ finalBody = projectHostedSearchHistory(finalBody);
505
511
  }
512
+ finalBody = normalizeOpenGatewayResponsesInput(finalBody, url);
506
513
  observeOutbound(parsed._rawBody, finalBody, headers);
507
514
  if (!isCanonicalOpenAiForwardProvider(provider)) {
508
515
  validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools);
509
516
  }
510
- const body = JSON.stringify(applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName));
517
+ const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName);
518
+ const body = JSON.stringify(wireBody);
511
519
  const releaseBodyObservation = translatorBudget.observeExternallyCapped(
512
520
  "passthrough_serialization",
513
521
  Buffer.byteLength(body, "utf8"),
@@ -517,6 +525,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
517
525
  method: "POST",
518
526
  headers,
519
527
  body,
528
+ ...(isPlainObject(wireBody) && typeof wireBody.model === "string" ? { outboundModelId: wireBody.model } : {}),
520
529
  releaseBodyObservation,
521
530
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
522
531
  ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}),
@@ -163,7 +163,74 @@ export function sanitizeReasoningInputContent(
163
163
  });
164
164
 
165
165
  if (blankedPlaintext) opts?.onPlaintextReasoningBlanked?.();
166
- return changed ? { ...raw, input } : body;
166
+ // Runs after the per-item pass so an item it emptied has already been backfilled, and a
167
+ // placeholder is only invented for a turn that carries no reasoning item at all.
168
+ const withTurnReasoning = opts?.requirePlaintextReasoning === true
169
+ ? backfillBareToolCallTurnReasoning(input)
170
+ : input;
171
+ if (withTurnReasoning !== input) changed = true;
172
+ return changed ? { ...raw, input: withTurnReasoning } : body;
173
+ }
174
+
175
+ /** Assistant-authored history items: what DeepSeek merges into one assistant message. */
176
+ function isAssistantTurnItem(item: unknown): boolean {
177
+ if (!isPlainObject(item) || typeof item.type !== "string") return false;
178
+ if (item.type === "reasoning") return true;
179
+ if (item.type === "message") return item.role === "assistant";
180
+ return item.type.endsWith("_call");
181
+ }
182
+
183
+ function isClientToolCallItem(item: unknown): boolean {
184
+ return isPlainObject(item)
185
+ && (item.type === "function_call" || item.type === "custom_tool_call" || item.type === "local_shell_call");
186
+ }
187
+
188
+ /**
189
+ * Give every assistant tool-call turn that has NO reasoning item a minimal plaintext one (#7298).
190
+ *
191
+ * A Combo can serve the turn that issued a tool call on one model and the continuation on
192
+ * another. When the issuing model was a GPT turn that produced no reasoning item (or the item
193
+ * was dropped upstream), the strict destination sees a bare `function_call` and DeepSeek's
194
+ * thinking mode refuses the whole request with `The reasoning_text in the thinking mode must be
195
+ * passed back to the API`. The per-item backfill above cannot help, because there is no item to
196
+ * backfill. This is the Responses-wire twin of the Chat adapter's `reasoning_content: " "`
197
+ * placeholder (#5421): the same single-space text, never fabricated reasoning, inserted at the
198
+ * head of the turn so it precedes the call exactly where a native turn's reasoning would be.
199
+ *
200
+ * A turn is a maximal run of assistant-authored items; any user message or tool output ends it.
201
+ * Turns that already carry a reasoning item, and turns with no client tool call, are untouched.
202
+ */
203
+ export function backfillBareToolCallTurnReasoning(input: unknown[]): unknown[] {
204
+ const bareTurnStarts = new Set<number>();
205
+ let turnStart = -1;
206
+ let turnHasReasoning = false;
207
+ let turnHasToolCall = false;
208
+ const closeTurn = (): void => {
209
+ if (turnStart >= 0 && turnHasToolCall && !turnHasReasoning) bareTurnStarts.add(turnStart);
210
+ turnStart = -1;
211
+ turnHasReasoning = false;
212
+ turnHasToolCall = false;
213
+ };
214
+ for (let index = 0; index < input.length; index++) {
215
+ const item = input[index];
216
+ if (!isAssistantTurnItem(item)) {
217
+ closeTurn();
218
+ continue;
219
+ }
220
+ if (turnStart < 0) turnStart = index;
221
+ if ((item as Record<string, unknown>).type === "reasoning") turnHasReasoning = true;
222
+ if (isClientToolCallItem(item)) turnHasToolCall = true;
223
+ }
224
+ closeTurn();
225
+ if (bareTurnStarts.size === 0) return input;
226
+ const result: unknown[] = [];
227
+ for (let index = 0; index < input.length; index++) {
228
+ if (bareTurnStarts.has(index)) {
229
+ result.push({ type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: " " }] });
230
+ }
231
+ result.push(input[index]);
232
+ }
233
+ return result;
167
234
  }
168
235
 
169
236
  /**
@@ -23,6 +23,18 @@ export function copyResponsesClientMetadata(target: Headers, incoming: Headers,
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Strip trailing slashes with a linear scan rather than a regex.
28
+ *
29
+ * `/\/+$/` retries candidate end positions when a long slash run is not at the end of the
30
+ * string; the scan walks each character once and produces the same result on every input.
31
+ */
32
+ function stripTrailingSlashes(value: string): string {
33
+ let end = value.length;
34
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
35
+ return end === value.length ? value : value.slice(0, end);
36
+ }
37
+
26
38
  /** Shared Responses URL/auth/header construction; capabilities never grant auth authority. */
27
39
  export function buildResponsesTransport(provider: OcxProviderConfig, incoming: Pick<IncomingMeta, "headers">): {
28
40
  url: string; headers: Record<string, string>;
@@ -31,7 +43,7 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
31
43
  let url: string;
32
44
  if (provider.authMode === "forward") {
33
45
  const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
34
- const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : provider.baseUrl.replace(/\/+$/, "");
46
+ const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : stripTrailingSlashes(provider.baseUrl);
35
47
  url = `${baseUrl}/responses`;
36
48
  if (provider.headers) Object.assign(headers, provider.headers);
37
49
  const runtimeProvider = provider as {
@@ -138,7 +138,7 @@ export const ADAPTER_REGISTRY = {
138
138
  zed: {
139
139
  wire: "zed",
140
140
  mutation: "codex-owned",
141
- create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createZedAdapter(provider),
141
+ create: (provider: OcxProviderConfig, context: AdapterFactoryContext) => createZedAdapter(provider, context.cacheRetention),
142
142
  },
143
143
  "mimo-free": {
144
144
  contractParent: "openai-chat",
@@ -1,3 +1,4 @@
1
+ import { inheritAccountAutoPauseResponse } from "../lib/account-auto-pause-response";
1
2
  import { readBoundedResponseBody } from "../lib/bounded-body";
2
3
  import { redactSecretString } from "../lib/redact";
3
4
 
@@ -40,9 +41,9 @@ export async function normalizeUpstreamHttpErrorResponse(
40
41
  const headers = new Headers(res.headers);
41
42
  headers.delete("content-encoding");
42
43
  headers.delete("content-length");
43
- return new Response(await opts.formatMessage(payloadText), {
44
+ return inheritAccountAutoPauseResponse(res, new Response(await opts.formatMessage(payloadText), {
44
45
  status: res.status,
45
46
  statusText: res.statusText,
46
47
  headers,
47
- });
48
+ }));
48
49
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base";
3
3
  import { createAnthropicAdapter } from "./anthropic";
4
+ import type { AnthropicCacheRetention } from "./anthropic/cache-retention";
4
5
  import { createGoogleAdapter } from "./google";
5
6
  import { createOpenAIChatAdapter } from "./openai-chat";
6
7
  import { createResponsesPassthroughAdapter } from "./openai-responses";
@@ -218,15 +219,17 @@ export function zedEventStream(
218
219
  return body.pipeThrough(output);
219
220
  }
220
221
 
221
- function createDelegate(provider: OcxProviderConfig, zedProvider: ZedProvider): ProviderAdapter {
222
+ function createDelegate(provider: OcxProviderConfig, zedProvider: ZedProvider, cacheRetention?: AnthropicCacheRetention): ProviderAdapter {
222
223
  const config = delegateProvider(provider, zedProvider);
223
- if (zedProvider === "anthropic") return createAnthropicAdapter(config);
224
+ // The delegate only borrows the api.anthropic.com body builder; the wire goes to Zed's
225
+ // gateway, which does not document the 1h TTL, so an omitted retention stays 5m here.
226
+ if (zedProvider === "anthropic") return createAnthropicAdapter(config, cacheRetention ?? "short");
224
227
  if (zedProvider === "google") return createGoogleAdapter(config);
225
228
  if (zedProvider === "open_ai") return createResponsesPassthroughAdapter(config);
226
229
  return createOpenAIChatAdapter(config);
227
230
  }
228
231
 
229
- export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter {
232
+ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: AnthropicCacheRetention): ProviderAdapter {
230
233
  let delegate: ZedDelegate | undefined;
231
234
  let credentials: ZedCredentials | undefined;
232
235
 
@@ -246,7 +249,7 @@ export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter {
246
249
  /* Model inference fallback below; a transient catalog outage must not block passthrough. */
247
250
  }
248
251
  const zedProvider = providerFromCatalog(catalog, parsed.modelId);
249
- const selected = createDelegate(provider, zedProvider);
252
+ const selected = createDelegate(provider, zedProvider, cacheRetention);
250
253
  const safeIncoming: IncomingMeta = {
251
254
  headers: incoming?.headers ?? new Headers(),
252
255
  translatorBudget: incoming?.translatorBudget ?? createTranslatorBudget(),
@@ -23,6 +23,11 @@ export function uuid(): string {
23
23
  return crypto.randomUUID().replace(/-/g, "");
24
24
  }
25
25
 
26
+ /** The client-visible id of a hosted search cell: the producer's `ws_` id when well-formed, else a fresh one. */
27
+ export function webSearchCellItemId(candidate: string | undefined): string {
28
+ return typeof candidate === "string" && /^ws_[A-Za-z0-9_-]{1,128}$/.test(candidate) ? candidate : `ws_${uuid()}`;
29
+ }
30
+
26
31
  /** Test-only: bound the abandoned-owned-budget watchdog delay (null restores). */
27
32
  export let ownedBudgetAbandonedMs = 10 * 60 * 1000;
28
33
  const OWNED_BUDGET_ABANDONED_DEFAULT_MS = ownedBudgetAbandonedMs;
@@ -64,7 +69,7 @@ export function responsesUsage(usage: OcxUsage | undefined): Record<string, unkn
64
69
  // normalized contract).
65
70
  const rawInputDetails = isRecord(raw.input_tokens_details)
66
71
  ? Object.fromEntries(Object.entries(raw.input_tokens_details as Record<string, unknown>)
67
- .filter(([key]) => key !== "cache_write_tokens"))
72
+ .filter(([key]) => key !== "cache_write_tokens" && key !== "cache_write_1h_tokens"))
68
73
  : {} as Record<string, unknown>;
69
74
  const rawOutputDetails = isRecord(raw.output_tokens_details)
70
75
  ? raw.output_tokens_details as Record<string, unknown>
@@ -92,6 +97,11 @@ export function responsesUsage(usage: OcxUsage | undefined): Record<string, unkn
92
97
  usage.cacheCreationInputTokens,
93
98
  Math.max(0, inputTokens - cacheRead),
94
99
  );
100
+ // The 1-hour-TTL share of the write (Anthropic cache_creation split), only when reported,
101
+ // so a translated Messages client can price it at the 1h rate.
102
+ if (usage.cacheCreation1hInputTokens !== undefined) {
103
+ inputDetails.cache_write_1h_tokens = Math.min(usage.cacheCreation1hInputTokens, inputDetails.cache_write_tokens as number);
104
+ }
95
105
  }
96
106
  out.input_tokens_details = inputDetails;
97
107
  out.output_tokens_details = { ...rawOutputDetails, reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
@@ -42,7 +42,7 @@ import {
42
42
  type TranslatorBudget,
43
43
  type TranslatorBufferKind,
44
44
  } from "../lib/translator-budget";
45
- import { adapterFailureFromEvent, emptyChunks, joinChunks, responsesUsage, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal";
45
+ import { adapterFailureFromEvent, emptyChunks, joinChunks, responsesUsage, toolCallArgumentsUsable, uuid, webSearchAction, webSearchCellItemId } from "./internal";
46
46
  import type { OutputItem, StringChunks } from "./internal";
47
47
  import { bridgeToResponsesSSE } from "./sse";
48
48
 
@@ -546,7 +546,7 @@ function buildResponseJSONWithBudget(
546
546
  flushToolCall();
547
547
  const safeSources = safeWebSearchSources(e.sources);
548
548
  pushOutput({
549
- type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
549
+ type: "web_search_call", id: webSearchCellItemId(e.itemId), status: e.status ?? "completed",
550
550
  action: webSearchAction(e.queries, e.anthropic),
551
551
  ...(safeSources.length > 0 ? { sources: safeSources } : {}),
552
552
  ...(e.anthropic ? { anthropic_blocks: e.anthropic } : {}),
package/src/bridge/sse.ts CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  type TranslatorBudget,
51
51
  type TranslatorBufferKind,
52
52
  } from "../lib/translator-budget";
53
- import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal";
53
+ import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction, webSearchCellItemId } from "./internal";
54
54
  import type { OutputItem, StringChunks } from "./internal";
55
55
  import { terminalRefusalFailure } from "./terminal-refusal";
56
56
 
@@ -770,9 +770,14 @@ export function bridgeToResponsesSSE(
770
770
  returnIterator();
771
771
  };
772
772
  let handlingTranslatorOverflow = false;
773
- terminateForTranslatorOverflow = _error => {
773
+ terminateForTranslatorOverflow = error => {
774
774
  if (handlingTranslatorOverflow || terminated || clientCancelled || closed) return;
775
775
  handlingTranslatorOverflow = true;
776
+ // An adapter-raised overflow event carries the usage the turn already consumed; the
777
+ // provider billed it, so it must not be logged as zero.
778
+ const usage = (error as AdapterEvent | null | undefined)?.type === "error"
779
+ ? (error as Extract<AdapterEvent, { type: "error" }>).usage : undefined;
780
+ if (usage) options?.onUsage?.(usage);
776
781
  abortCurrentToolCallForTranslatorOverflow();
777
782
  currentWebSearch = null;
778
783
  releasePendingWebSources();
@@ -788,6 +793,7 @@ export function bridgeToResponsesSSE(
788
793
  sequence_number: seq++,
789
794
  response: {
790
795
  ...responseSnapshot("failed", finishedItems),
796
+ ...(usage ? { usage: responsesUsage(usage) } : {}),
791
797
  error: failure,
792
798
  last_error: failure,
793
799
  },
@@ -1211,7 +1217,7 @@ export function bridgeToResponsesSSE(
1211
1217
  flushHiddenRawReasoning();
1212
1218
  if (currentToolCall) closeCurrentToolCall();
1213
1219
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
1214
- const wsItemId = `ws_${uuid()}`;
1220
+ const wsItemId = webSearchCellItemId(event.itemId);
1215
1221
  emit("response.output_item.added", {
1216
1222
  output_index: outputIndex,
1217
1223
  item: { type: "web_search_call", id: wsItemId, status: "in_progress" },
@@ -1224,7 +1230,7 @@ export function bridgeToResponsesSSE(
1224
1230
  // (defensive), synthesize the added frame first so the done has a matching item.
1225
1231
  if (!currentWebSearch || currentWebSearch.eventId !== event.id) {
1226
1232
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
1227
- const wsItemId2 = `ws_${uuid()}`;
1233
+ const wsItemId2 = webSearchCellItemId(event.itemId);
1228
1234
  emit("response.output_item.added", {
1229
1235
  output_index: outputIndex,
1230
1236
  item: { type: "web_search_call", id: wsItemId2, status: "in_progress" },
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
@@ -44,6 +44,20 @@ export interface Desktop3pMetadata {
44
44
 
45
45
  export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker";
46
46
 
47
+ export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024;
48
+
49
+ export function assertDesktopLibraryTextSize(text: string): void {
50
+ if (Buffer.byteLength(text, "utf8") > MAX_DESKTOP_PROFILE_FILE_BYTES) {
51
+ throw new Error("desktop_config_too_large");
52
+ }
53
+ }
54
+
55
+ export function serializeDesktopLibraryJson(value: unknown): string {
56
+ const text = JSON.stringify(value, null, 2) + "\n";
57
+ assertDesktopLibraryTextSize(text);
58
+ return text;
59
+ }
60
+
47
61
  /** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */
48
62
  function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean {
49
63
  try {
@@ -54,7 +68,13 @@ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolea
54
68
  }
55
69
 
56
70
  /** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */
57
- export function readDesktopLibraryBytes(path: string): Buffer {
71
+ export function readDesktopLibraryBytes(
72
+ path: string,
73
+ maxBytes: number = MAX_DESKTOP_PROFILE_FILE_BYTES,
74
+ ): Buffer {
75
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DESKTOP_PROFILE_FILE_BYTES) {
76
+ throw new RangeError("invalid Desktop library read limit");
77
+ }
58
78
  const expected = lstatSync(path, { bigint: true });
59
79
  if (!expected.isFile()) throw new Error("desktop_config_not_regular");
60
80
  // POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX
@@ -73,7 +93,27 @@ export function readDesktopLibraryBytes(path: string): Buffer {
73
93
  const opened = fstatSync(fd, { bigint: true });
74
94
  if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino
75
95
  || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular");
76
- return readFileSync(fd);
96
+ if (opened.size > BigInt(maxBytes)) throw new Error("desktop_config_too_large");
97
+ // Allocate for the observed size plus one sentinel byte, not the full cap: callers scan many
98
+ // small profiles, and the cap would zero-fill megabytes per read. A file that fills the
99
+ // buffer grew after validation — keep reading within the bound so it classifies as
100
+ // desktop_config_too_large rather than silently truncating.
101
+ let bytes = Buffer.alloc(Number(opened.size) + 1);
102
+ let length = 0;
103
+ for (;;) {
104
+ const count = readSync(fd, bytes, length, Math.min(64 * 1024, bytes.length - length), null);
105
+ if (count === 0) break;
106
+ length += count;
107
+ if (length > maxBytes) throw new Error("desktop_config_too_large");
108
+ if (length === bytes.length && bytes.length <= maxBytes) {
109
+ const grown = Buffer.alloc(Math.min(maxBytes + 1, bytes.length * 2));
110
+ bytes.copy(grown, 0, 0, length);
111
+ bytes = grown;
112
+ }
113
+ }
114
+ if (length > maxBytes) throw new Error("desktop_config_too_large");
115
+ if (!pathStillNamesExpectedFile(path, opened)) throw new Error("desktop_config_not_regular");
116
+ return bytes.subarray(0, length);
77
117
  } finally {
78
118
  closeSync(fd);
79
119
  }