@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
@@ -1,10 +1,9 @@
1
1
  import type { TranslatorBudget } from "../../lib/translator-budget";
2
2
  import type { AdapterEvent } from "../../types";
3
3
  import { repairFreeformToolInput } from "../../responses/apply-patch-envelope";
4
+ import { contextAfter, deferredContinuationBreak, splitAtPossibleSerializedToolCall, MAX_TAG_LENGTH, TAG_SUFFIX, type TextContext } from "./serialized-tool-call-markup";
5
+ export { splitAtPossibleSerializedToolCall } from "./serialized-tool-call-markup";
4
6
 
5
- const OPEN_TAG = "<tool_call>";
6
- const CLOSE_TAG = "</tool_call>";
7
- const FUNCTION_TAG = "<function=";
8
7
  /**
9
8
  * Streaming bounds (ingestStreaming only; buffered reconciliation already knows its structured calls).
10
9
  * MAX_HELD_BYTES is a runaway guard on held text plus queued events, far below the translator budget,
@@ -14,12 +13,7 @@ const FUNCTION_TAG = "<function=";
14
13
  */
15
14
  const MAX_HELD_BYTES = 4 * 1024 * 1024;
16
15
  const MAX_TRAILING_CHARS = 8 * 1024;
17
-
18
- interface TextContext {
19
- fence: string | null;
20
- lineStart: boolean;
21
- inlineTicks?: number;
22
- }
16
+ export const MAX_STANDALONE_CANDIDATES = 128;
23
17
 
24
18
  export interface SerializedToolCall {
25
19
  name: string;
@@ -49,9 +43,9 @@ export interface StructuredToolCallReference {
49
43
  recoveredParameterWrapper?: boolean;
50
44
  }
51
45
 
52
- const BLOCK_HEADER = /<tool_call>\s*<function=([^>\r\n]+)>/y;
46
+ const BLOCK_HEADER = new RegExp(`<tool_call(${TAG_SUFFIX})>\\s*<function=([^>\\r\\n]+)>`, "y");
53
47
  /** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */
54
- const NEXT_BLOCK_HEADER = /\n<tool_call>\s*<function=[^>\r\n]+>/g;
48
+ const NEXT_BLOCK_HEADER = new RegExp(`\\n<tool_call${TAG_SUFFIX}>\\s*<function=[^>\\r\\n]+>`, "g");
55
49
  const FUNCTION_CLOSE = "</function>";
56
50
  const PARAMETER_CLOSE = "</parameter>";
57
51
 
@@ -73,26 +67,25 @@ function endsWithAt(text: string, from: number, to: number, suffix: string): boo
73
67
  * bounds an unclosed candidate: with no close at all before it, it is body text, and a closed
74
68
  * `</function></tool_call>` after it still ends the block.
75
69
  */
76
- function blockAt(text: string, offset: number): SerializedToolCall | undefined {
70
+ function blockAt(text: string, offset: number, nextHeaderAt: (from: number) => number): SerializedToolCall | undefined {
77
71
  BLOCK_HEADER.lastIndex = offset;
78
72
  const header = BLOCK_HEADER.exec(text);
79
73
  if (!header) return undefined;
74
+ const closeTag = `</tool_call${header[1]}>`;
80
75
  const bodyStart = offset + header[0].length;
81
- NEXT_BLOCK_HEADER.lastIndex = bodyStart;
82
- const next = NEXT_BLOCK_HEADER.exec(text);
83
- const limit = next ? next.index + 1 : text.length;
76
+ const limit = nextHeaderAt(bodyStart);
84
77
  let unclosed: SerializedToolCall | undefined;
85
- for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed);
86
- close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) {
78
+ for (let close = text.indexOf(closeTag, bodyStart); close >= 0 && (close < limit || !unclosed);
79
+ close = text.indexOf(closeTag, close + closeTag.length)) {
87
80
  let bodyEnd = trimmedEnd(text, bodyStart, close);
88
81
  const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE);
89
82
  if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length);
90
83
  if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length;
91
84
  const call = {
92
- name: header[1]!.trim(),
85
+ name: header[2]!.trim(),
93
86
  body: text.slice(bodyStart, bodyEnd),
94
87
  start: offset,
95
- end: close + CLOSE_TAG.length,
88
+ end: close + closeTag.length,
96
89
  };
97
90
  if (closed) return call;
98
91
  if (close < limit) unclosed ??= call;
@@ -100,21 +93,64 @@ function blockAt(text: string, offset: number): SerializedToolCall | undefined {
100
93
  return unclosed;
101
94
  }
102
95
 
103
- /** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */
104
- function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] {
96
+ /** Finds outer blocks outside literal Markdown, reporting an unresolved enclosing header. */
97
+ export function scanSerializedToolCalls(
98
+ text: string, context: TextContext = { fence: null, lineStart: true }, allowMidLineBare = false,
99
+ maxCandidates = allowMidLineBare ? MAX_STANDALONE_CANDIDATES : Infinity,
100
+ ): { calls: SerializedToolCall[]; incomplete: boolean; overflow: boolean } {
105
101
  const calls: SerializedToolCall[] = [];
102
+ // Adjacent same-line blocks share one forward-only newline-header search, including EOF.
103
+ const headers = new RegExp(NEXT_BLOCK_HEADER.source, "g");
104
+ let next = headers.exec(text);
105
+ const nextHeaderAt = (from: number): number => {
106
+ while (next && next.index + 1 < from) next = headers.exec(text);
107
+ return next ? next.index + 1 : text.length;
108
+ };
106
109
  let offset = 0;
107
110
  while (offset < text.length) {
108
- const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true);
111
+ const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true, allowMidLineBare);
109
112
  offset += split.emit.length;
110
113
  if (!split.hasOpenTag) break;
111
- const match = blockAt(text, offset);
112
- if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call.
114
+ if (calls.length >= maxCandidates) return { calls, incomplete: false, overflow: true };
115
+ const match = blockAt(text, offset, nextHeaderAt);
116
+ if (!match) return { calls, incomplete: true, overflow: false }; // An unresolved outer block cannot authorize an inner call.
113
117
  calls.push(match);
114
118
  offset = match.end;
115
119
  context = { fence: null, lineStart: false };
116
120
  }
117
- return calls;
121
+ return { calls, incomplete: false, overflow: false };
122
+ }
123
+
124
+ function callsIn(text: string, context?: TextContext, allowMidLineBare = false): SerializedToolCall[] {
125
+ return scanSerializedToolCalls(text, context, allowMidLineBare).calls;
126
+ }
127
+
128
+ /** Standalone recovery is separate from identified-wrapper/empty-structured-input reconciliation. */
129
+ export function hasNestedSerializedToolCallHeader(body: string): boolean {
130
+ return /<(?:tool_call(?:_[a-fA-F0-9]{1,64})?|function=[^>\r\n]+)>/.test(body);
131
+ }
132
+
133
+ export function recoverStandaloneBareBlocks(
134
+ text: string,
135
+ freeformTools: ReadonlyMap<string, FreeformToolIdentity>,
136
+ context?: TextContext,
137
+ ): { name: string; input: string; start: number; end: number }[] {
138
+ if (freeformTools.size === 0) return [];
139
+ const scan = scanSerializedToolCalls(text, context, true);
140
+ if (scan.overflow) return [];
141
+ const blocks = scan.calls;
142
+ // A nested header makes delimiter ownership ambiguous; no later apparent sibling is safe.
143
+ if (blocks.some(call => hasNestedSerializedToolCallHeader(call.body))) return [];
144
+ return blocks.flatMap(call => {
145
+ if (!text.startsWith("<tool_call>", call.start) || !freeformTools.has(call.name)) return [];
146
+ const wrapped = freeformBody(call.body);
147
+ const recoveredParameterWrapper = wrapped.startsWith("<parameter=");
148
+ const input = recoveredParameterWrapper ? freeformBody(wrapped.slice("<parameter=".length)) : wrapped;
149
+ if (!input.trim()
150
+ || (recoveredParameterWrapper && /^<parameter=[A-Za-z_$][\w$.-]*>/.test(wrapped))
151
+ || input.includes("<parameter=") || input.includes("</parameter>")) return [];
152
+ return [{ name: call.name, input, start: call.start, end: call.end }];
153
+ });
118
154
  }
119
155
 
120
156
  /**
@@ -128,94 +164,6 @@ function repeatedCallIn(text: string, context?: TextContext): SerializedToolCall
128
164
  return { ...first, end: text.length };
129
165
  }
130
166
 
131
- /** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */
132
- export function splitAtPossibleSerializedToolCall(
133
- text: string,
134
- initialContext: TextContext = { fence: null, lineStart: true },
135
- final = false,
136
- ): {
137
- emit: string;
138
- defer: string;
139
- hasOpenTag: boolean;
140
- context: TextContext;
141
- } {
142
- const context = { ...initialContext };
143
- const split = (at: number, hasOpenTag = false) => ({
144
- emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context,
145
- });
146
- for (let index = 0; index < text.length; index++) {
147
- if (context.lineStart) {
148
- const rest = text.slice(index);
149
- const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest);
150
- if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0]
151
- && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) {
152
- if (!final && !rest.includes("\n")) return split(index);
153
- context.fence = context.fence ? null : fence[1]!;
154
- index += fence[0].length - 1;
155
- context.lineStart = false;
156
- continue;
157
- }
158
- if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index);
159
- // Only bare control markup qualifies. Prose, quotes, indented examples and
160
- // fenced code stay user-visible even when their body matches a real call.
161
- if (!context.fence && !context.inlineTicks) {
162
- if (rest.startsWith(OPEN_TAG)) {
163
- const header = rest.slice(OPEN_TAG.length).trimStart();
164
- if (/^<function=[^>\r\n]+>/.test(header)) return split(index, true);
165
- if (!final && (FUNCTION_TAG.startsWith(header)
166
- || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) {
167
- return split(index);
168
- }
169
- } else if (!final && OPEN_TAG.startsWith(rest)) return split(index);
170
- }
171
- }
172
- if (!context.fence && text[index] === "`") {
173
- let end = index + 1;
174
- while (text[end] === "`") end++;
175
- if (!final && end === text.length) return split(index);
176
- const ticks = end - index;
177
- if (!context.inlineTicks) context.inlineTicks = ticks;
178
- else if (context.inlineTicks === ticks) context.inlineTicks = undefined;
179
- index = end - 1;
180
- }
181
- context.lineStart = text[index] === "\n";
182
- }
183
- return split(text.length);
184
- }
185
-
186
- /**
187
- * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so
188
- * the scan runs through the whole text instead of stopping at the first opening tag; a block ends
189
- * mid-line, which is exactly what the neutral spelling reports too.
190
- */
191
- function contextAfter(text: string, context: TextContext): TextContext {
192
- if (text.length === 0) return context;
193
- return splitAtPossibleSerializedToolCall(text.replaceAll(OPEN_TAG, "<tool-call>"), context, true).context;
194
- }
195
-
196
- /**
197
- * A deferred header/fence can itself be long. While its unresolved portion only grows, a
198
- * chunk-local check suffices; the first resolving character goes back through the full splitter.
199
- * Each unbounded phase is scanned once on entry and once on exit, preserving its Markdown rules.
200
- */
201
- function deferredContinuationBreak(text: string, context: TextContext): RegExp | undefined {
202
- if (text.startsWith(OPEN_TAG)) {
203
- if (/^<tool_call>\s*$/.test(text)) return /\S/;
204
- if (/^<tool_call>\s*<function=[^>\r\n]*$/.test(text)) return /[>\r\n]/;
205
- }
206
- if (!context.inlineTicks && context.lineStart) {
207
- const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(text);
208
- if (fence) {
209
- if (!context.fence) return /\n/;
210
- // Closing-fence ticks may grow only before its optional whitespace suffix.
211
- if (!fence[2]) return fence[1]![0] === "`" ? /[^`]/ : /[^~]/;
212
- return /[^ \t\r]/;
213
- }
214
- }
215
- if (/^`+$/.test(text)) return /[^`]/;
216
- return undefined;
217
- }
218
-
219
167
  /**
220
168
  * Holds possible duplicate text within the shared translator budget until the dispatch outcome is
221
169
  * known. While a block candidate is open, any other event (reasoning) is queued at its position in
@@ -232,10 +180,13 @@ export class SerializedToolCallContentBuffer {
232
180
  private sawCloser = false;
233
181
  private openAfterCloser = false;
234
182
  private trailingProseChars = 0;
235
- private context: TextContext = { fence: null, lineStart: true };
183
+ protected context: TextContext = { fence: null, lineStart: true };
236
184
  private queued: { offset: number; event: AdapterEvent }[] = [];
237
185
 
238
- constructor(private readonly budget: TranslatorBudget) {}
186
+ constructor(private readonly budget: TranslatorBudget, private readonly allowMidLineBare = false) {}
187
+
188
+ /** Optional recovery retains bounded candidates before prose is released, never the prose itself. */
189
+ protected releaseBounded(): AdapterEvent[] { return this.drain([]); }
239
190
 
240
191
  /** Remembers visible text released before a later structured call can be reconciled. */
241
192
  private observeText(events: AdapterEvent[]): AdapterEvent[] {
@@ -291,19 +242,22 @@ export class SerializedToolCallContentBuffer {
291
242
  /** Scan new text plus a fixed overlap, never the retained body (which may be a large rope). */
292
243
  private observeDelimiters(delta: string): void {
293
244
  const scan = this.delimiterSuffix + delta;
294
- const closer = scan.lastIndexOf(CLOSE_TAG);
295
- const afterCloser = closer + CLOSE_TAG.length;
296
- if (closer >= 0) {
297
- this.sawCloser = true;
298
- this.openAfterCloser = false;
299
- this.trailingProseChars = 0;
300
- }
301
- if (scan.lastIndexOf(OPEN_TAG) >= (closer >= 0 ? afterCloser : 0)) {
302
- this.openAfterCloser = true;
245
+ const tags = scan.matchAll(new RegExp(`<(/?)tool_call${TAG_SUFFIX}>`, "g"));
246
+ let afterCloser = -1;
247
+ for (const tag of tags) {
248
+ if (tag.index! + tag[0].length <= this.delimiterSuffix.length) continue;
249
+ if (tag[1]) {
250
+ this.sawCloser = true;
251
+ this.openAfterCloser = false;
252
+ this.trailingProseChars = 0;
253
+ afterCloser = tag.index! + tag[0].length;
254
+ } else {
255
+ this.openAfterCloser = true;
256
+ }
303
257
  }
304
- const tail = closer >= 0 ? scan.slice(afterCloser) : delta;
258
+ const tail = afterCloser >= 0 ? scan.slice(afterCloser) : delta;
305
259
  if (this.sawCloser) this.trailingProseChars += tail.replace(/\s+/g, "").length;
306
- this.delimiterSuffix = scan.slice(-(Math.max(OPEN_TAG.length, CLOSE_TAG.length) - 1));
260
+ this.delimiterSuffix = scan.slice(-(MAX_TAG_LENGTH - 1));
307
261
  }
308
262
 
309
263
  /** Returns immediately safe text and retains only the suffix that still needs reconciliation. */
@@ -312,7 +266,7 @@ export class SerializedToolCallContentBuffer {
312
266
  this.append(delta);
313
267
  return "";
314
268
  }
315
- const split = splitAtPossibleSerializedToolCall(this.text + delta, this.context);
269
+ const split = splitAtPossibleSerializedToolCall(this.text + delta, this.context, false, this.allowMidLineBare);
316
270
  this.replace(split.defer, split.hasOpenTag);
317
271
  this.context = split.context;
318
272
  if (!split.hasOpenTag) this.continuationBreak = deferredContinuationBreak(split.defer, split.context);
@@ -328,7 +282,7 @@ export class SerializedToolCallContentBuffer {
328
282
  ingestStreaming(delta: string): AdapterEvent[] {
329
283
  const deltaBytes = this.appendedByteLength(delta);
330
284
  if (this.hasOpenTag && this.bytes + deltaBytes > MAX_HELD_BYTES) {
331
- const released = this.drain([]);
285
+ const released = this.releaseBounded();
332
286
  // A delta that alone passes the bound is delivered as text rather than retained.
333
287
  if (Buffer.byteLength(delta) > MAX_HELD_BYTES) {
334
288
  this.context = contextAfter(delta, this.context);
@@ -339,7 +293,7 @@ export class SerializedToolCallContentBuffer {
339
293
  const text = this.ingest(delta);
340
294
  // Checked after ingest too: one delta can open a block and already carry more than a bound.
341
295
  if (this.hasOpenTag && (this.bytes > MAX_HELD_BYTES || (this.sawCloser && !this.openAfterCloser && this.trailingProseChars > MAX_TRAILING_CHARS))) {
342
- return this.observeText([...textEvents(text), ...this.drain([])]);
296
+ return this.observeText([...textEvents(text), ...this.releaseBounded()]);
343
297
  }
344
298
  return this.observeText(textEvents(text));
345
299
  }
@@ -358,7 +312,7 @@ export class SerializedToolCallContentBuffer {
358
312
  if (!this.hasOpenTag) return [...this.drain([]), event];
359
313
  const eventBytes = Buffer.byteLength(JSON.stringify(event));
360
314
  // Queued events count toward the same runaway bound as held text.
361
- if (this.bytes + eventBytes > MAX_HELD_BYTES) return [...this.drain([]), event];
315
+ if (this.bytes + eventBytes > MAX_HELD_BYTES) return [...this.releaseBounded(), event];
362
316
  this.budget.reserveTransient(eventBytes, { kind: "live_transient" }).commitRetained();
363
317
  this.bytes += eventBytes;
364
318
  this.queued.push({ offset: this.text.length, event });
@@ -371,7 +325,11 @@ export class SerializedToolCallContentBuffer {
371
325
  * duplicate a dispatched call; pass an empty list on failure to preserve everything.
372
326
  */
373
327
  drain(structuredCalls: readonly StructuredToolCallReference[]): AdapterEvent[] {
374
- const removed = duplicatedSerializedToolCallRanges(this.text, structuredCalls, this.context);
328
+ return this.drainRanges(duplicatedSerializedToolCallRanges(this.text, structuredCalls, this.context));
329
+ }
330
+
331
+ /** Standalone recovery supplies only its own accepted bare ranges, never identified wrappers. */
332
+ protected drainRanges(removed: readonly { start: number; end: number }[]): AdapterEvent[] {
375
333
  const kept = (from: number, to: number): string => {
376
334
  let piece = "";
377
335
  let cursor = from;
@@ -390,8 +348,11 @@ export class SerializedToolCallContentBuffer {
390
348
  if (boundary.event) out.push(boundary.event);
391
349
  cursor = boundary.offset;
392
350
  }
393
- // Later text continues after what was drained, so its line and fence state carry forward.
394
- this.context = contextAfter(this.text, this.context);
351
+ // Control bodies are opaque on every provider, regardless of standalone recovery eligibility.
352
+ // Context still traverses the whole released text when recovery hit its candidate limit.
353
+ const last = scanSerializedToolCalls(this.text, this.context, this.allowMidLineBare, Infinity).calls.at(-1);
354
+ this.context = last ? contextAfter(this.text.slice(last.end), { fence: null, lineStart: false })
355
+ : contextAfter(this.text, this.context);
395
356
  this.queued = [];
396
357
  this.replace("", false);
397
358
  return this.observeText(out);
@@ -609,7 +570,7 @@ export function reconcileStructuredToolCalls(
609
570
  const body = hasParameterOpener
610
571
  ? freeformBody(wrappedBody.slice("<parameter=".length)) : wrappedBody;
611
572
  if (block.name === calls[0]!.wireName && body.trim() !== ""
612
- && !/(?:^|\n)<tool_call>\s*<function=[^>\r\n]+>/.test(block.body)
573
+ && !new RegExp(`(?:^|\\n)<tool_call${TAG_SUFFIX}>\\s*<function=[^>\\r\\n]+>`).test(block.body)
613
574
  && !(hasParameterOpener && /^<parameter=[A-Za-z_$][\w$.-]*>/.test(wrappedBody))
614
575
  && !body.includes("<parameter=") && !body.includes("</parameter>")) {
615
576
  references[0] = { ...references[0]!, argumentsText: JSON.stringify({ input: body }),
@@ -0,0 +1,108 @@
1
+ const OPEN_TAG = "<tool_call>";
2
+ const FUNCTION_TAG = "<function=";
3
+ // MiMo gateways append an opaque hexadecimal identity to both delimiters.
4
+ export const TAG_SUFFIX = "(?:_[a-fA-F0-9]{1,64})?";
5
+ export const MAX_TAG_LENGTH = "</tool_call_".length + 64 + 1;
6
+ const OPENING = new RegExp(`^<tool_call${TAG_SUFFIX}>`);
7
+ const OPEN_PREFIX = /^<tool_call(?:_[a-fA-F0-9]{0,64})?$/;
8
+
9
+ export interface TextContext {
10
+ fence: string | null;
11
+ lineStart: boolean;
12
+ inlineTicks?: number;
13
+ literalLine?: boolean;
14
+ }
15
+
16
+ /** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */
17
+ export function splitAtPossibleSerializedToolCall(
18
+ text: string,
19
+ initialContext: TextContext = { fence: null, lineStart: true },
20
+ final = false,
21
+ allowMidLineBare = false,
22
+ ): {
23
+ emit: string;
24
+ defer: string;
25
+ hasOpenTag: boolean;
26
+ context: TextContext;
27
+ } {
28
+ const context = { ...initialContext };
29
+ const split = (at: number, hasOpenTag = false) => ({
30
+ emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context,
31
+ });
32
+ for (let index = 0; index < text.length; index++) {
33
+ if (context.lineStart) {
34
+ const rest = text.slice(index);
35
+ const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest);
36
+ if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0]
37
+ && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) {
38
+ if (!final && !rest.includes("\n")) return split(index);
39
+ context.fence = context.fence ? null : fence[1]!;
40
+ index += fence[0].length - 1;
41
+ context.lineStart = false;
42
+ continue;
43
+ }
44
+ if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index);
45
+ context.literalLine = /[ \t>]/.test(text[index]!);
46
+ }
47
+ // Plain wrappers retain their bare-line rule. Nonced wrappers also occur after prose;
48
+ // quotes, indentation and Markdown code remain literal, even beside a matching call.
49
+ if (!context.fence && !context.inlineTicks && !context.literalLine && text[index] === "<") {
50
+ const rest = text.slice(index);
51
+ const opening = OPENING.exec(rest)?.[0];
52
+ if (opening && (context.lineStart || opening !== OPEN_TAG || allowMidLineBare)) {
53
+ const header = rest.slice(opening.length).trimStart();
54
+ if (/^<function=[^>\r\n]+>/.test(header)) return split(index, true);
55
+ if (!final && (FUNCTION_TAG.startsWith(header)
56
+ || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) {
57
+ return split(index);
58
+ }
59
+ } else if (!final && (OPEN_TAG.startsWith(rest) || OPEN_PREFIX.test(rest))) return split(index);
60
+ }
61
+ if (!context.fence && text[index] === "`") {
62
+ let end = index + 1;
63
+ while (text[end] === "`") end++;
64
+ if (!final && end === text.length) return split(index);
65
+ const ticks = end - index;
66
+ if (!context.inlineTicks) context.inlineTicks = ticks;
67
+ else if (context.inlineTicks === ticks) context.inlineTicks = undefined;
68
+ index = end - 1;
69
+ }
70
+ context.lineStart = text[index] === "\n";
71
+ }
72
+ return split(text.length);
73
+ }
74
+
75
+ /**
76
+ * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so
77
+ * the scan runs through the whole text instead of stopping at the first opening tag; a block ends
78
+ * mid-line, which is exactly what the neutral spelling reports too.
79
+ */
80
+ export function contextAfter(text: string, context: TextContext): TextContext {
81
+ if (text.length === 0) return context;
82
+ return splitAtPossibleSerializedToolCall(text.replace(new RegExp(`<tool_call${TAG_SUFFIX}>`, "g"), "<tool-call>"), context, true).context;
83
+ }
84
+
85
+ /**
86
+ * A deferred header/fence can itself be long. While its unresolved portion only grows, a
87
+ * chunk-local check suffices; the first resolving character goes back through the full splitter.
88
+ * Each unbounded phase is scanned once on entry and once on exit, preserving its Markdown rules.
89
+ */
90
+ export function deferredContinuationBreak(text: string, context: TextContext): RegExp | undefined {
91
+ const opening = OPENING.exec(text)?.[0];
92
+ if (opening) {
93
+ const header = text.slice(opening.length);
94
+ if (/^\s*$/.test(header)) return /\S/;
95
+ if (/^\s*<function=[^>\r\n]*$/.test(header)) return /[>\r\n]/;
96
+ }
97
+ if (!context.inlineTicks && context.lineStart) {
98
+ const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(text);
99
+ if (fence) {
100
+ if (!context.fence) return /\n/;
101
+ // Closing-fence ticks may grow only before its optional whitespace suffix.
102
+ if (!fence[2]) return fence[1]![0] === "`" ? /[^`]/ : /[^~]/;
103
+ return /[^ \t\r]/;
104
+ }
105
+ }
106
+ if (/^`+$/.test(text)) return /[^`]/;
107
+ return undefined;
108
+ }
@@ -0,0 +1,136 @@
1
+ import { TRANSLATOR_MAX_CALL_ARGUMENT_BYTES, type TranslatorBudget } from "../../lib/translator-budget";
2
+ import { jsonUtf8Bytes } from "../../lib/json-byte-size";
3
+ import type { AdapterEvent } from "../../types";
4
+ import {
5
+ hasNestedSerializedToolCallHeader, MAX_STANDALONE_CANDIDATES, recoverStandaloneBareBlocks, reconcileSerializedToolCallEvents, scanSerializedToolCalls, SerializedToolCallContentBuffer,
6
+ type FreeformToolIdentity, type StructuredToolCallReference,
7
+ } from "./serialized-tool-call-content";
8
+
9
+ const MAX_CANDIDATE_BYTES = 4 * 1024 * 1024;
10
+ type BareCall = Pick<ReturnType<typeof recoverStandaloneBareBlocks>[number], "name" | "input">;
11
+ const isMiMo = (model: string | undefined) => /(?:^|[/-])mimo-v2(?:\.|$)/i.test(model ?? "");
12
+ const argumentsFor = (call: BareCall) => JSON.stringify({ input: call.input });
13
+ function* callEvents(call: BareCall, id: string, restore: (name: string) => string): Generator<AdapterEvent> {
14
+ yield { type: "tool_call_start", id, name: restore(call.name) };
15
+ yield { type: "tool_call_delta", arguments: argumentsFor(call) };
16
+ yield { type: "tool_call_end" };
17
+ }
18
+
19
+ /** An explicitly separate policy for bare blocks with NO structured calls, never identified wrappers. */
20
+ export class StandaloneBareToolCallBuffer extends SerializedToolCallContentBuffer {
21
+ private candidates: BareCall[] = [];
22
+ private candidateBytes = 0;
23
+ private disabled = false;
24
+ private readonly eligible: boolean;
25
+
26
+ constructor(
27
+ private readonly recoveryBudget: TranslatorBudget,
28
+ model: string | undefined,
29
+ private readonly freeformTools: ReadonlyMap<string, FreeformToolIdentity>,
30
+ ) {
31
+ super(recoveryBudget, isMiMo(model) && freeformTools.size > 0);
32
+ this.eligible = isMiMo(model) && freeformTools.size > 0;
33
+ }
34
+
35
+ /** Retain candidate inputs only, with aggregate and per-call limits; overflow disables the batch. */
36
+ private remember(calls: BareCall[]): void {
37
+ if (this.disabled || calls.length === 0) return;
38
+ const sizes = calls.map(call => jsonUtf8Bytes({ name: call.name, input: call.input }));
39
+ const bytes = sizes.reduce((sum, size) => sum + size, 0);
40
+ if (sizes.some(size => size > TRANSLATOR_MAX_CALL_ARGUMENT_BYTES)
41
+ || this.candidateBytes + bytes > MAX_CANDIDATE_BYTES || this.candidates.length + calls.length > MAX_STANDALONE_CANDIDATES) {
42
+ this.clearCandidates();
43
+ this.disabled = true;
44
+ return;
45
+ }
46
+ const retained = this.recoveryBudget.reserveTransient(bytes, { kind: "live_transient" });
47
+ try {
48
+ const temporary = this.recoveryBudget.reserveTransient(bytes, { kind: "live_transient" });
49
+ try {
50
+ // Detach small inputs from a large released answer's backing string.
51
+ const copies = calls.map(({ name, input }) => JSON.parse(JSON.stringify({ name, input })) as BareCall);
52
+ this.candidates.push(...copies);
53
+ this.candidateBytes += bytes;
54
+ retained.commitRetained();
55
+ } finally { temporary.release(); }
56
+ } catch (error) {
57
+ retained.release();
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ protected override releaseBounded(): AdapterEvent[] {
63
+ if (!this.eligible || this.disabled) return super.releaseBounded();
64
+ const text = this.current();
65
+ const scan = scanSerializedToolCalls(text, this.context, true);
66
+ if (scan.overflow || scan.incomplete || scan.calls.some(call => hasNestedSerializedToolCallHeader(call.body))) {
67
+ // We cannot safely recognize a later inner block after losing its unresolved outer body.
68
+ this.clearCandidates();
69
+ this.disabled = true;
70
+ return super.releaseBounded();
71
+ }
72
+ this.remember(recoverStandaloneBareBlocks(text, this.freeformTools, this.context));
73
+ return super.releaseBounded();
74
+ }
75
+
76
+ *recoverStandalone(noStructuredCalls: boolean, nextId: () => string, restore: (name: string) => string): Generator<AdapterEvent> {
77
+ if (!noStructuredCalls) {
78
+ this.clearCandidates();
79
+ this.disabled = true;
80
+ return;
81
+ }
82
+ if (!this.eligible || this.disabled) return;
83
+ if (scanSerializedToolCalls(this.current(), this.context, true).overflow) {
84
+ this.clearCandidates();
85
+ this.disabled = true;
86
+ return;
87
+ }
88
+ const held = recoverStandaloneBareBlocks(this.current(), this.freeformTools, this.context);
89
+ this.remember(held);
90
+ // The first terminal flush is the only recovery decision; content after it cannot authorize a call.
91
+ if (this.disabled || this.candidates.length === 0) { this.disabled = true; return; }
92
+ yield* this.drainRanges(held);
93
+ for (const call of this.candidates) yield* callEvents(call, nextId(), restore);
94
+ this.clearCandidates();
95
+ this.disabled = true;
96
+ }
97
+
98
+ private clearCandidates(): void {
99
+ this.recoveryBudget.releaseRetained(this.candidateBytes, { kind: "live_transient" });
100
+ this.candidateBytes = 0;
101
+ this.candidates = [];
102
+ }
103
+
104
+ override dispose(): void {
105
+ this.clearCandidates();
106
+ super.dispose();
107
+ }
108
+ }
109
+
110
+ /** Buffered counterpart; the existing structured-call/identified-wrapper reconciler owns its path. */
111
+ export function reconcileStandaloneBareToolCallEvents(
112
+ events: AdapterEvent[], start: number, end: number,
113
+ structuredCalls: readonly StructuredToolCallReference[], budget: TranslatorBudget,
114
+ model: string | undefined, freeformTools: ReadonlyMap<string, FreeformToolIdentity>, restore: (name: string) => string,
115
+ ): void {
116
+ if (structuredCalls.length > 0 || !isMiMo(model) || freeformTools.size === 0) {
117
+ reconcileSerializedToolCallEvents(events, start, end, structuredCalls, budget);
118
+ return;
119
+ }
120
+ const buffer = new StandaloneBareToolCallBuffer(budget, model, freeformTools);
121
+ const reconciled: AdapterEvent[] = [];
122
+ try {
123
+ for (const event of events.slice(start, end)) {
124
+ if (event.type !== "text_delta") reconciled.push(...buffer.hold(event).filter(held => held.type !== "heartbeat"));
125
+ else {
126
+ const text = buffer.ingest(event.text);
127
+ if (text) reconciled.push({ type: "text_delta", text });
128
+ }
129
+ }
130
+ let sequence = 0;
131
+ reconciled.push(...buffer.recoverStandalone(true, () => `call_${++sequence}`, restore), ...buffer.drain([]));
132
+ } finally {
133
+ buffer.dispose();
134
+ }
135
+ events.splice(start, end - start, ...reconciled);
136
+ }
@@ -47,7 +47,8 @@ import { messagesToChatFormat } from "./openai-chat/messages";
47
47
  import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry";
48
48
  import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
49
49
  import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema";
50
- import { freeformToolsByWireName, type FreeformToolIdentity, reconcileSerializedToolCallEvents, reconcileStructuredToolCall, reconcileStructuredToolCalls, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content";
50
+ import { freeformToolsByWireName, type FreeformToolIdentity, reconcileStructuredToolCall, reconcileStructuredToolCalls } from "./openai-chat/serialized-tool-call-content";
51
+ import { StandaloneBareToolCallBuffer, reconcileStandaloneBareToolCallEvents } from "./openai-chat/standalone-bare-tool-calls";
51
52
 
52
53
  export { stripBracketedModelSuffix } from "./openai-chat/wire";
53
54
  export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough";
@@ -300,7 +301,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
300
301
  sawArgumentsString: boolean;
301
302
  }
302
303
  const pendingToolCalls: PendingToolCall[] = [];
303
- const toolCallContent = new SerializedToolCallContentBuffer(budget);
304
+ const toolCallContent = new StandaloneBareToolCallBuffer(budget, lastRequestedModelId, freeformTools);
304
305
  const heldText = (): AdapterEvent[] => toolCallContent.drain([]);
305
306
  let toolCallSeq = 0;
306
307
  const closeToolCalls = (): PendingToolCall[] => {
@@ -322,7 +323,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
322
323
  // Returns "terminate" when a pending call cannot be dispatched, so every flush site
323
324
  // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
324
325
  // so budget reservations are released for every pending call even on the early return.
325
- const flushToolCalls = function* (): Generator<AdapterEvent, "continue" | "terminate"> {
326
+ const flushToolCalls = function* (terminal = true): Generator<AdapterEvent, "continue" | "terminate"> {
326
327
  const calls = closeToolCalls();
327
328
  for (const call of calls) {
328
329
  // Ingest already proved `name` is a string; the typeof guard keeps this branch
@@ -335,9 +336,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
335
336
  return yield* terminateWithError(unnamedToolCallEvent(pendingUsage));
336
337
  }
337
338
  }
338
- // Held markup is released only now, as one batch per response: the doubled-input repair needs every call.
339
339
  const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), !toolCallContent.releasedAnswerText && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? ""));
340
340
  calls.forEach((call, index) => { call.args = references[index]!.argumentsText; });
341
+ yield* toolCallContent.recoverStandalone(calls.length === 0 && terminal, () => `call_${++toolCallSeq}`, name => toolNames.restore(name));
341
342
  yield* toolCallContent.drain(references);
342
343
  for (const call of calls) {
343
344
  if (!call.id) call.id = `call_${++toolCallSeq}`;
@@ -657,7 +658,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
657
658
  yield* terminateWithError({ type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" });
658
659
  return;
659
660
  }
660
- if ((yield* flushToolCalls()) === "terminate") return;
661
+ if ((yield* flushToolCalls(sawFinish)) === "terminate") return; // A cut stream never synthesizes a bare call.
661
662
  const stopReason = stopReasonFor(finishReason);
662
663
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
663
664
  } catch (error) {
@@ -804,7 +805,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
804
805
  }
805
806
  const references = reconcileStructuredToolCalls(structuredCalls, answerText, /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? ""));
806
807
  structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; });
807
- reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget);
808
+ reconcileStandaloneBareToolCallEvents(events, contentStart, contentEnd, references, budget, lastRequestedModelId, freeformTools, name => toolNames.restore(name));
808
809
  const stopReason = stopReasonFor(choice.finish_reason);
809
810
  events.push({
810
811
  type: "done",