@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
@@ -36,7 +36,7 @@ import {
36
36
  observeAntigravityReplay,
37
37
  } from "./google-antigravity-replay";
38
38
  import { canonicalAntigravityUsageModel, resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
39
- import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
39
+ import { selectVertexEndpoint, vertexInferenceUrl, vertexReplayModelKey } from "./google-vertex-endpoint";
40
40
  import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
41
41
  import {
42
42
  isTranslatorBudgetExceededError,
@@ -333,13 +333,20 @@ function geminiToolResultParts(
333
333
  msg: OcxToolResultMessage,
334
334
  wireName: string,
335
335
  wireCallId: string,
336
+ nestToolResultImages: boolean,
336
337
  ): unknown[] {
337
338
  const functionResponse: Record<string, unknown> = {
338
339
  name: wireName,
339
340
  response: { result: geminiToolResultText(msg.content) },
340
341
  id: wireCallId,
341
342
  };
342
- return [{ functionResponse }, ...toolResultImageParts(msg.content)];
343
+ const images = toolResultImageParts(msg.content);
344
+ // Vertex Argon rejects image siblings beside a functionResponse as an unsupported model tail.
345
+ if (nestToolResultImages && images.length > 0) {
346
+ functionResponse.parts = images;
347
+ return [{ functionResponse }];
348
+ }
349
+ return [{ functionResponse }, ...images];
343
350
  }
344
351
 
345
352
  function geminiMissingToolResultPart(wireName: string, wireCallId: string): unknown {
@@ -370,6 +377,8 @@ function messagesToGeminiFormat(
370
377
  identityModelId: string,
371
378
  stripRejectedClaudeSdkParagraph = false,
372
379
  isCloudCodeAssist = false,
380
+ nestToolResultImages = false,
381
+ toolResponseImagesLast = false,
373
382
  ): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } {
374
383
  // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
375
384
  // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
@@ -549,15 +558,25 @@ function messagesToGeminiFormat(
549
558
  }
550
559
 
551
560
  const responseParts: unknown[] = [];
561
+ const trailingImages: unknown[] = [];
552
562
  for (const call of toolCalls) {
553
563
  const result = resultsById.get(call.wireCallId);
554
- if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId));
555
- else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId));
556
- }
557
- for (const orphan of orphanResults) {
558
- responseParts.push(...geminiOrphanToolResultParts(orphan));
564
+ if (result) {
565
+ const [response, ...images] = geminiToolResultParts(result, call.wireName, call.wireCallId, nestToolResultImages);
566
+ responseParts.push(response);
567
+ (toolResponseImagesLast ? trailingImages : responseParts).push(...images);
568
+ } else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId));
559
569
  }
570
+ // CCA Claude misses later responses when image siblings interrupt the response batch.
571
+ responseParts.push(...trailingImages);
572
+ const orphanParts = orphanResults.flatMap(geminiOrphanToolResultParts);
560
573
  contents.push({ role: "user", parts: responseParts });
574
+ // Orphan images cannot share Argon's response batch or claim another call's ownership.
575
+ if (nestToolResultImages && orphanParts.some(part => typeof part === "object" && part !== null && "inline_data" in part)) {
576
+ contents.push({ role: "user", parts: orphanParts });
577
+ } else {
578
+ responseParts.push(...orphanParts);
579
+ }
561
580
  i = j - 1;
562
581
  }
563
582
  break;
@@ -1014,7 +1033,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1014
1033
  : provider.googleMode === "vertex" || openCodeZenGeminiEndpoint(provider, parsed.modelId) !== undefined
1015
1034
  ? parsed.modelId
1016
1035
  : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false);
1017
- returnsThoughtSummaries = provider.googleMode === "cloud-code-assist"
1036
+ const configuredEfforts = configuredReasoningEfforts(provider, parsed.modelId);
1037
+ const thinkingEligible = provider.googleMode !== "cloud-code-assist"
1038
+ && !isImageCapableModel(parsed.modelId)
1039
+ && (configuredEfforts !== undefined
1040
+ ? configuredEfforts.length > 0
1041
+ : (provider.googleMode !== "vertex"
1042
+ && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")));
1043
+ returnsThoughtSummaries = (provider.googleMode === "cloud-code-assist" || (thinkingEligible && provider.showThinkingSummary === true))
1018
1044
  && /^gemini-/.test(routedModelId) && !isImageCapableModel(parsed.modelId);
1019
1045
  // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation.
1020
1046
  const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId;
@@ -1026,6 +1052,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1026
1052
  identityModelId,
1027
1053
  stripRejectedClaudeSdkParagraph,
1028
1054
  isCloudCodeAssist,
1055
+ provider.googleMode === "vertex" && parsed.modelId === "gemini-4-argon",
1056
+ isCloudCodeAssist && /^claude-/i.test(routedModelId),
1029
1057
  );
1030
1058
  lastInjectedCallIds = [...replayedCallIds];
1031
1059
  lastReasoningReplayScope = parsed._reasoningReplayScope;
@@ -1052,15 +1080,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1052
1080
  // current behavior; Vertex participates only through an explicitly configured ladder (the
1053
1081
  // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would
1054
1082
  // suppress the responseModalities fallback below. CCA maps effort on its envelope path.
1055
- const thinkingEligible = provider.googleMode !== "cloud-code-assist"
1056
- && !isImageCapableModel(parsed.modelId)
1057
- && (configuredReasoningEfforts(provider, parsed.modelId) !== undefined
1058
- || (provider.googleMode !== "vertex"
1059
- && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")));
1060
1083
  const thinkingLevel = thinkingEligible
1061
1084
  ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning)
1062
1085
  : undefined;
1063
- if (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel };
1086
+ const includeThoughts = thinkingEligible
1087
+ && provider.showThinkingSummary === true
1088
+ && parsed.options.hideThinkingSummary !== true
1089
+ && /^gemini-/.test(routedModelId);
1090
+ if (thinkingLevel || includeThoughts) {
1091
+ generationConfig.thinkingConfig = {
1092
+ ...(thinkingLevel ? { thinkingLevel } : {}),
1093
+ ...(includeThoughts ? { includeThoughts: true } : {}),
1094
+ };
1095
+ }
1064
1096
  if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) {
1065
1097
  generationConfig.responseModalities = ["TEXT", "IMAGE"];
1066
1098
  }
@@ -1192,9 +1224,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1192
1224
  const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy);
1193
1225
  reportToolSchemaLoss(compiled.toolSchemaLossReport);
1194
1226
  restoreGoogleToolName = compiled.restoreToolName;
1195
- const vertexProject = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || "api-key";
1196
- const vertexLocation = provider.location || process.env.GOOGLE_CLOUD_LOCATION || "global";
1197
- vertexReplayModel = `vertex:${vertexProject}:${vertexLocation}:${parsed.modelId}`;
1227
+ const apiKey = resolveVertexApiKey(provider.apiKey);
1228
+ const vertexEndpoint = selectVertexEndpoint(provider, Boolean(apiKey));
1229
+ const url = vertexInferenceUrl(vertexEndpoint, parsed.modelId, method, streamParam);
1230
+ vertexReplayModel = vertexReplayModelKey(vertexEndpoint, parsed.modelId);
1198
1231
  vertexReplaySession = vertexReplaySessionId(parsed);
1199
1232
  // Compile names before replay so the cache matches the exact provider-visible
1200
1233
  // functionCall identity. This is the same bounded TTL/LRU store used by CCA, with the
@@ -1210,27 +1243,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1210
1243
  (compiled.body as { contents: unknown[] }).contents,
1211
1244
  );
1212
1245
  }
1213
- // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
1214
- const apiKey = resolveVertexApiKey(provider.apiKey);
1215
- if (apiKey) {
1216
- const url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
1217
- headers["x-goog-api-key"] = apiKey;
1218
- return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
1246
+ // Endpoint, location and model path were validated above, before any credential is attached.
1247
+ if (apiKey) headers["x-goog-api-key"] = apiKey;
1248
+ else {
1249
+ // Resolve each marker per request; never share a process-global active account.
1250
+ const markerAccount = gcpCredentialMarkerAccount(provider.apiKey);
1251
+ headers["Authorization"] = `Bearer ${await getVertexAccessToken({ markerAccount })}`;
1219
1252
  }
1220
- const project = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
1221
- if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT).");
1222
- const location = provider.location || process.env.GOOGLE_CLOUD_LOCATION;
1223
- if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION).");
1224
- const locationError = googleVertexLocationConfigError(location);
1225
- if (locationError) throw new Error(locationError);
1226
- const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
1227
- const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
1228
- // The marker account travels per-request as an argument (never process-global state): a
1229
- // module-level "active marker" written at routing time would race across concurrent
1230
- // requests for different providers and could resolve one call with another's credential.
1231
- const markerAccount = gcpCredentialMarkerAccount(provider.apiKey);
1232
- const token = await getVertexAccessToken({ markerAccount });
1233
- headers["Authorization"] = `Bearer ${token}`;
1234
1253
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
1235
1254
  }
1236
1255
 
@@ -1254,6 +1273,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1254
1273
  const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy);
1255
1274
  reportToolSchemaLoss(compiled.toolSchemaLossReport);
1256
1275
  restoreGoogleToolName = compiled.restoreToolName;
1276
+ // A functionCall issued by another model (a combo hop, #7298) has no Gemini signature, and
1277
+ // Gemini 3 refuses the turn without one. The same Gemini-only sentinel CCA and Vertex
1278
+ // apply; a real signature carried by history always wins.
1279
+ if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) {
1280
+ applyAntigravityThoughtSignatureFallback(routedModelId, (compiled.body as { contents: unknown[] }).contents);
1281
+ }
1257
1282
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
1258
1283
  },
1259
1284
 
@@ -18,9 +18,11 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string }
18
18
  */
19
19
  export function contentPartsToText(content: string | OcxContentPart[]): string {
20
20
  if (typeof content === "string") return content;
21
- // A document carries its own marker, so this wire states the attachment instead of
22
- // mislabelling it as a video.
21
+ // Documents and audio carry their own markers, so this wire states the attachment instead
22
+ // of mislabelling it as a video.
23
23
  const text = content.map(p =>
24
- p.type === "text" || p.type === "document" ? p.text : p.type === "image" ? "[image]" : "[video]").join("");
24
+ p.type === "text" || p.type === "document" || p.type === "audio"
25
+ ? p.text
26
+ : p.type === "image" ? "[image]" : "[video]").join("");
25
27
  return text || "[image]";
26
28
  }
@@ -10,7 +10,8 @@ import type { OcxParsedRequest } from "../types";
10
10
  * out because it uses the original body rather than the lossy normalized content.
11
11
  *
12
12
  * Two of the three checks are wire-scoped, and that is the point. A constraint the normalized
13
- * request CAN carry — a caller restriction on a tool, an attached document's bytes — still has
13
+ * request CAN carry — a caller restriction on a tool, an attached document's bytes, or inline
14
+ * audio in the translated Chat content model — still has
14
15
  * to reach a wire that can express it. Leaving that to each adapter means an adapter that never
15
16
  * learned about the carrier rebuilds without it and answers normally, so the allowlist in
16
17
  * `declaration-carrier.ts` is default-deny and this is the one place every registered adapter
@@ -18,7 +19,9 @@ import type { OcxParsedRequest } from "../types";
18
19
  */
19
20
  export function withInputMediaGuard<T extends ProviderAdapter>(adapter: T, wire: AdapterWire): T {
20
21
  const refusal = (parsed: OcxParsedRequest): string | undefined => {
21
- const kind = untranslatedResponsesInputMedia(parsed._rawBody);
22
+ // Chat Completions has a native input_audio user-content part. The adapter promotes
23
+ // tool-result audio into a follow-up user message, so these blocks no longer lose bytes.
24
+ const kind = untranslatedResponsesInputMedia(parsed._rawBody, { allowAudio: wire === "openai-chat" });
22
25
  return kind ? untranslatedInputMediaMessage(kind) : unrepresentableDeclaration(parsed, wire);
23
26
  };
24
27
  const build = adapter.buildRequest.bind(adapter);
@@ -1,8 +1,13 @@
1
1
  /** Account-scoped Kiro refusals. Unknown data never convicts an account. */
2
2
  import { BOUNDED_BODY_MAX_BYTES } from "../lib/bounded-body";
3
- export type KiroRefusalKind = "rate" | "monthly_quota" | "suspended" | "other";
3
+ export type KiroRefusalKind = "rate" | "monthly_quota" | "suspended" | "profile" | "other";
4
4
  export interface KiroRefusal { kind: KiroRefusalKind; resetAt?: number }
5
5
 
6
+ /** The vendor's own name for the body validator that rejects a request for this identity. */
7
+ const VALIDATION_EXCEPTION = "ValidationException";
8
+ /** Kiro's wording for that rejection; the code alone is also what a genuinely bad payload gets. */
9
+ const IMPROPER_REQUEST_WORD = /improperly formed request/i;
10
+
6
11
  const SUSPENSION_WORDS = [
7
12
  "temporarily suspended", "temporarily is suspended",
8
13
  "locked your account", "locked it as a",
@@ -22,9 +27,44 @@ export function classifyKiroRefusal(status: number, bodyText: string): KiroRefus
22
27
  } catch { /* Unrecognised bodies cannot establish an account verdict. */ }
23
28
  if ((status === 400 || status === 429) && reason === "MONTHLY_REQUEST_COUNT")
24
29
  return { kind: "monthly_quota" };
30
+ // A 400 whose own validator calls the body improperly formed is not proof that the request is
31
+ // impossible: Kiro answers this shape when the identity a request was routed under cannot serve
32
+ // it. Treating it as `other` leaves the account selected and the same 400 reaches the client.
33
+ // Both vendor tokens are required, so a bad payload or a caller echoing one token stays terminal.
34
+ if (status === 400 && typeof message === "string" && IMPROPER_REQUEST_WORD.test(message)
35
+ && (reason === VALIDATION_EXCEPTION || message.includes(VALIDATION_EXCEPTION)))
36
+ return { kind: "profile" };
25
37
  if (status === 403 && (reason === "TEMPORARILY_SUSPENDED"
26
38
  || (typeof message === "string" && SUSPENSION_WORDS.some(word => message.toLowerCase().includes(word)))))
27
39
  return { kind: "suspended" };
28
40
  if (status === 429) return { kind: "rate" };
29
41
  return { kind: "other" };
30
42
  }
43
+
44
+ /** The adapter's own prefix for a normalized 400 (`normalizedKiroErrorMessage` in kiro-errors.ts). */
45
+ const KIRO_INVALID_REQUEST_PREFIX = "Kiro invalid request: ";
46
+
47
+ /**
48
+ * The same verdict at the combo layer. There the text is either the adapter's normalized message
49
+ * ("Kiro invalid request: ValidationException: Improperly formed request", what a combo child's
50
+ * failure carries) or a raw JSON body, each possibly behind the "Provider error 400: " frame.
51
+ * JSON goes through the parsed `reason`/`message` rules of `classifyKiroRefusal` (tokens in
52
+ * unrelated fields never count); prose counts only behind the adapter's own prefix. Callers must
53
+ * also know the failed target is a Kiro adapter.
54
+ */
55
+ export function isKiroAccountProfileRefusal(status: number, bodyText: string): boolean {
56
+ if (status !== 400 || Buffer.byteLength(bodyText, "utf8") > BOUNDED_BODY_MAX_BYTES) return false;
57
+ const text = bodyText.replace(/^Provider error \d{3}: /, "");
58
+ if (text.startsWith(KIRO_INVALID_REQUEST_PREFIX)) {
59
+ const detail = text.slice(KIRO_INVALID_REQUEST_PREFIX.length);
60
+ return detail.includes(VALIDATION_EXCEPTION) && IMPROPER_REQUEST_WORD.test(detail);
61
+ }
62
+ try {
63
+ const row: unknown = JSON.parse(text);
64
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
65
+ const envelope = (row as Record<string, unknown>).error;
66
+ const body = envelope && typeof envelope === "object" && !Array.isArray(envelope) ? envelope : row;
67
+ const { reason, message } = body as Record<string, unknown>;
68
+ return classifyKiroRefusal(400, JSON.stringify({ reason, message })).kind === "profile";
69
+ } catch { return false; }
70
+ }
@@ -290,6 +290,7 @@ function contentToNative(
290
290
  text += part.text;
291
291
  continue;
292
292
  }
293
+ if (part.type === "audio") throw new Error(`ollama-native cannot send audio content in ${label}`);
293
294
  // Ollama's native /api/chat message shape carries `images: string[]` and has no video
294
295
  // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image.
295
296
  if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`);
@@ -27,8 +27,8 @@ const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has
27
27
  /**
28
28
  * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
29
29
  * content is text-only on every chat provider, so these ride in a follow-up user message instead of
30
- * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
31
- * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
30
+ * being flattened to the "[image]" marker the model can't actually see. The same follow-up carries
31
+ * inline audio as input_audio, which is also user-content-only on this wire.
32
32
  */
33
33
  export function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string {
34
34
  // An empty content array is a present-but-empty result; `contentPartsToText` would
@@ -63,6 +63,14 @@ export function toolResultImageChatParts(content: string | OcxContentPart[]): un
63
63
  return parts;
64
64
  }
65
65
 
66
+ /** Chat input_audio parts for audio carried inside a tool result. */
67
+ export function toolResultAudioChatParts(content: string | OcxContentPart[]): unknown[] {
68
+ if (typeof content === "string") return [];
69
+ return content
70
+ .filter((p): p is Extract<OcxContentPart, { type: "audio" }> => p.type === "audio")
71
+ .map(p => ({ type: "input_audio", input_audio: { data: p.data, format: p.format } }));
72
+ }
73
+
66
74
  /**
67
75
  * Whether this wire serializes a replayed thinking block for the given model.
68
76
  *
@@ -98,6 +106,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
98
106
  let pendingToolCalls: PendingToolCall[] = [];
99
107
  let deferredBarrierMessages: unknown[] = [];
100
108
  let pendingToolResultImageParts: unknown[] = [];
109
+ let pendingToolResultAudioParts: unknown[] = [];
101
110
  let mintedIdSeq = 0;
102
111
  const seenWireCallIds = new Set<string>();
103
112
 
@@ -128,6 +137,18 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
128
137
  pendingToolResultImageParts = [];
129
138
  };
130
139
 
140
+ const flushToolResultAudio = (): void => {
141
+ if (pendingToolResultAudioParts.length === 0) return;
142
+ out.push({
143
+ role: "user",
144
+ content: [
145
+ { type: "text", text: "[ocx] audio output from the preceding tool result(s):" },
146
+ ...pendingToolResultAudioParts,
147
+ ],
148
+ });
149
+ pendingToolResultAudioParts = [];
150
+ };
151
+
131
152
  const flushPendingToolCalls = (): void => {
132
153
  if (pendingToolCalls.length === 0) return;
133
154
  for (const call of pendingToolCalls) {
@@ -139,6 +160,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
139
160
  }
140
161
  pendingToolCalls = [];
141
162
  flushToolResultImages();
163
+ flushToolResultAudio();
142
164
  releaseDeferredBarriers();
143
165
  };
144
166
 
@@ -185,9 +207,10 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
185
207
  case "developer": {
186
208
  const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
187
209
  const hasImages = parts?.some(p => p.type === "image") ?? false;
210
+ const hasAudio = parts?.some(p => p.type === "audio") ?? false;
188
211
  // A document has a structured counterpart on this wire, so it needs the parts array for
189
212
  // the same reason an image does: flattening it to a string would drop the bytes.
190
- const hasStructured = hasImages || (parts?.some(p => p.type === "document") ?? false);
213
+ const hasStructured = hasImages || hasAudio || (parts?.some(p => p.type === "document") ?? false);
191
214
  let chatMsg: Record<string, unknown>;
192
215
  if (msg.role === "developer" && !hasStructured) {
193
216
  const text = typeof msg.content === "string"
@@ -219,6 +242,9 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
219
242
  if (p.type === "image") {
220
243
  return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } };
221
244
  }
245
+ if (p.type === "audio") {
246
+ return { type: "input_audio", input_audio: { data: p.data, format: p.format } };
247
+ }
222
248
  // Chat Completions carries an attached document as a file part with inline bytes,
223
249
  // the direct counterpart of the Anthropic document block the caller sent.
224
250
  if (p.type === "document") {
@@ -240,7 +266,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
240
266
  // this wire. One carrying only a document has no such precedent, and demoting it would
241
267
  // undo the role this adapter just finished preserving.
242
268
  chatMsg = {
243
- role: msg.role === "developer" && !hasImages ? developerWireRole : "user",
269
+ role: msg.role === "developer" && !hasImages && !hasAudio ? developerWireRole : "user",
244
270
  content: chatParts,
245
271
  };
246
272
  }
@@ -330,9 +356,11 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
330
356
  content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
331
357
  });
332
358
  pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
359
+ pendingToolResultAudioParts.push(...toolResultAudioChatParts(msg.content));
333
360
  pendingToolCalls.splice(matchIdx, 1);
334
361
  if (pendingToolCalls.length === 0) {
335
362
  flushToolResultImages();
363
+ flushToolResultAudio();
336
364
  releaseDeferredBarriers();
337
365
  }
338
366
  } else {
@@ -380,7 +408,9 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
380
408
  content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
381
409
  });
382
410
  pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
411
+ pendingToolResultAudioParts.push(...toolResultAudioChatParts(msg.content));
383
412
  flushToolResultImages();
413
+ flushToolResultAudio();
384
414
  }
385
415
  break;
386
416
  }
@@ -14,8 +14,11 @@ import { modelRecordValue } from "../../reasoning-effort";
14
14
  import { modelInList, type OcxProviderConfig } from "../../types";
15
15
  import { chatParallelToolCallsWireValue } from "./parallel-tool-calls";
16
16
  import { applyExplicitChatReasoningWirePolicy } from "./reasoning-wire";
17
- import { replayNativeChatReasoning } from "./reasoning-replay";
17
+ import { planNativeChatReasoningReplay } from "./reasoning-replay";
18
18
  import type { OcxReasoningReplayScopeRef } from "../../types";
19
+ import { jsonUtf8Bytes } from "../../lib/json-byte-size";
20
+ import { isTranslatorBudgetExceededError, TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError,
21
+ type TranslatorBudget } from "../../lib/translator-budget";
19
22
 
20
23
  const CHAT_PASSTHROUGH_FIELDS = [
21
24
  "audio",
@@ -58,6 +61,8 @@ export function buildOpenAIChatPassthroughRequest(
58
61
  fastMode?: boolean,
59
62
  providerName?: string,
60
63
  replayScope?: OcxReasoningReplayScopeRef,
64
+ maxSerializedBytes = TRANSLATOR_MAX_TURN_BYTES,
65
+ translatorBudget?: TranslatorBudget,
61
66
  ): AdapterRequest {
62
67
  const { url, headers, hasCredential } = openAIChatTransport(provider);
63
68
 
@@ -79,8 +84,8 @@ export function buildOpenAIChatPassthroughRequest(
79
84
  for (const field of CHAT_PASSTHROUGH_FIELDS) {
80
85
  if (rawBody[field] !== undefined) body[field] = rawBody[field];
81
86
  }
82
- body.messages = replayNativeChatReasoning(body.messages, provider, modelId, replayScope,
83
- Array.isArray(rawBody.tools) && rawBody.tools.length > 0);
87
+ const replayPlan = planNativeChatReasoningReplay(body.messages, provider, modelId, replayScope,
88
+ Array.isArray(rawBody.tools) && rawBody.tools.length > 0, maxSerializedBytes);
84
89
  if (protectGlmSummaryBudget(body, provider.baseUrl, body.reasoning_effort)) body.reasoning_effort = "low";
85
90
  const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
86
91
  const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0;
@@ -161,7 +166,35 @@ export function buildOpenAIChatPassthroughRequest(
161
166
  body.stream_options = rawBody.stream_options;
162
167
  }
163
168
 
164
- const bodyJson = JSON.stringify(applyGithubCopilotContextTier(body, provider, modelId, providerName));
169
+ const transformedBody = () => applyGithubCopilotContextTier(body, provider, modelId, providerName);
170
+ // The binding constraint is the final size base + net, so a shrinking replay may legitimately
171
+ // measure a base above the cap; only a base above max - net makes the result unserializable.
172
+ let baseBytes = 0;
173
+ if (replayPlan.hasChanges) {
174
+ try {
175
+ baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes);
176
+ } catch (error) {
177
+ if (isTranslatorBudgetExceededError(error)) {
178
+ throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
179
+ }
180
+ throw error;
181
+ }
182
+ }
183
+ if (replayPlan.serializedBytes > maxSerializedBytes - baseBytes) {
184
+ throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
185
+ }
186
+ const preSerialization = translatorBudget?.reserveTransient(baseBytes + replayPlan.serializedBytes, {
187
+ kind: "request_copies",
188
+ });
189
+ let bodyJson: string;
190
+ try {
191
+ if (replayPlan.hasChanges) {
192
+ body.messages = replayPlan.apply();
193
+ }
194
+ bodyJson = JSON.stringify(transformedBody());
195
+ } finally {
196
+ preSerialization?.release();
197
+ }
165
198
 
166
199
  if (isDebugEnabled()) {
167
200
  let host = "upstream";
@@ -1,10 +1,14 @@
1
1
  import { modelInList, type OcxProviderConfig, type OcxReasoningReplayScopeRef } from "../../types";
2
2
  import { peekReasoningForCall } from "../../responses/reasoning-replay-cache";
3
+ import { jsonStringPartsUtf8Bytes, jsonUtf8Bytes } from "../../lib/json-byte-size";
4
+ import { isTranslatorBudgetExceededError, TranslatorBudgetExceededError } from "../../lib/translator-budget";
3
5
 
4
6
  type Rec = Record<string, unknown>;
5
7
  export const isChatRecord = (value: unknown): value is Rec =>
6
8
  value !== null && typeof value === "object" && !Array.isArray(value);
7
9
 
10
+ const REASONING_CONTENT_FIELD_BYTES = Buffer.byteLength('"reasoning_content":', "utf8");
11
+
8
12
  const isDeepSeekThinkingModel = (modelId: string): boolean =>
9
13
  /(?:^|\/)deepseek-(?:v\d+(?:\.\d+)*-(?:flash|pro)|flash|pro)(?:$|[-_:])/i.test(modelId);
10
14
 
@@ -25,28 +29,109 @@ export function chatReasoningReplayPolicy(provider: OcxProviderConfig, modelId:
25
29
  };
26
30
  }
27
31
 
28
- /** Repair missing native history without mutating caller bodies used by retries/combo siblings. */
29
- export function replayNativeChatReasoning(
32
+ interface ReplayMessagePlan {
33
+ message: Rec;
34
+ parts: string[];
35
+ }
36
+
37
+ export interface NativeChatReasoningReplayPlan {
38
+ /** Net byte change in the final escaped JSON object representation; may be zero or negative. */
39
+ readonly serializedBytes: number;
40
+ /** Whether apply() rewrites any message, independent of the net byte change. */
41
+ readonly hasChanges: boolean;
42
+ apply(): unknown[];
43
+ }
44
+
45
+ /** Preflight escaped replay growth before allocating the copied messages or serialized body. */
46
+ export function planNativeChatReasoningReplay(
30
47
  messages: unknown,
31
48
  provider: OcxProviderConfig,
32
49
  modelId: string,
33
50
  scope?: OcxReasoningReplayScopeRef,
34
51
  hasTools = false,
35
- ): unknown {
52
+ maxSerializedBytes = Number.MAX_SAFE_INTEGER,
53
+ ): NativeChatReasoningReplayPlan {
36
54
  const policy = chatReasoningReplayPolicy(provider, modelId);
37
55
  if (!policy.preserve || !Array.isArray(messages)
38
- || modelInList(provider.reasoningDetailsModels, modelId)) return messages;
39
- return messages.map(message => {
56
+ || modelInList(provider.reasoningDetailsModels, modelId)) {
57
+ return { serializedBytes: 0, hasChanges: false, apply: () => messages as unknown[] };
58
+ }
59
+ const plans: ReplayMessagePlan[] = [];
60
+ let serializedBytes = 0;
61
+ for (const message of messages) {
40
62
  if (!isChatRecord(message) || message.role !== "assistant"
41
- || (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0)) return message;
63
+ || (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0)) continue;
42
64
  const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
43
65
  // DeepSeek's tools contract covers all prior assistant turns, even plain answers.
44
66
  // Those turns have no call id for original replay; only the configured fallback applies.
45
- if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId))) return message;
46
- const cached = calls.filter(isChatRecord)
47
- .map(tool => typeof tool.id === "string" ? peekReasoningForCall(tool.id, scope) : undefined)
48
- .filter((text): text is string => typeof text === "string" && text.length > 0);
49
- const reasoning = cached.length > 0 ? [...new Set(cached)].join("\n") : policy.placeholder ? " " : undefined;
50
- return reasoning === undefined ? message : { ...message, reasoning_content: reasoning };
51
- });
67
+ if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId))) continue;
68
+ const cached = new Set<string>();
69
+ for (const tool of calls) {
70
+ if (!isChatRecord(tool) || typeof tool.id !== "string") continue;
71
+ const text = peekReasoningForCall(tool.id, scope);
72
+ if (text) cached.add(text);
73
+ }
74
+ const parts = cached.size > 0
75
+ ? [...cached]
76
+ : policy.placeholder ? [" "] : [];
77
+ if (parts.length === 0) continue;
78
+
79
+ // The envelope delta around the joined value is exact for the plain JSON the Chat
80
+ // ingress produces: an absent or skipped key adds the reasoning_content field bytes
81
+ // plus one separator when another field serializes, while an already-serialized key
82
+ // ("" included) keeps its position and commas, so only the value size changes.
83
+ const oldReasoning = message.reasoning_content;
84
+ try {
85
+ const envelopeDelta = oldReasoning === undefined
86
+ ? REASONING_CONTENT_FIELD_BYTES
87
+ + (Object.keys(message).some(key => message[key] !== undefined) ? 1 : 0)
88
+ : -jsonUtf8Bytes(oldReasoning, Number.MAX_SAFE_INTEGER);
89
+ const fragments: string[] = [];
90
+ for (let index = 0; index < parts.length; index++) {
91
+ if (index > 0) fragments.push("\n");
92
+ fragments.push(parts[index]!);
93
+ }
94
+ const valueBytes = jsonStringPartsUtf8Bytes(
95
+ fragments,
96
+ maxSerializedBytes - serializedBytes - envelopeDelta,
97
+ );
98
+ const added = valueBytes + envelopeDelta;
99
+ if (added > maxSerializedBytes - serializedBytes) {
100
+ throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
101
+ }
102
+ serializedBytes += added;
103
+ } catch (error) {
104
+ // Inner measurements cap at the remaining budget; report the caller's real limit.
105
+ if (isTranslatorBudgetExceededError(error)) {
106
+ throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
107
+ }
108
+ throw error;
109
+ }
110
+ plans.push({ message, parts });
111
+ }
112
+ return {
113
+ serializedBytes,
114
+ hasChanges: plans.length > 0,
115
+ apply: () => {
116
+ if (plans.length === 0) return messages;
117
+ const byMessage = new Map(plans.map(plan => [plan.message, plan.parts]));
118
+ return (messages as unknown[]).map(message => {
119
+ if (!isChatRecord(message)) return message;
120
+ const parts = byMessage.get(message);
121
+ if (!parts) return message;
122
+ return { ...message, reasoning_content: parts.join("\n") };
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ /** Repair missing native history without mutating caller bodies used by retries/combo siblings. */
129
+ export function replayNativeChatReasoning(
130
+ messages: unknown,
131
+ provider: OcxProviderConfig,
132
+ modelId: string,
133
+ scope?: OcxReasoningReplayScopeRef,
134
+ hasTools = false,
135
+ ): unknown {
136
+ return planNativeChatReasoningReplay(messages, provider, modelId, scope, hasTools).apply();
52
137
  }