@bitkyc08/opencodex 2.64.0 → 2.65.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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-DGLte6IR.js +50 -0
  5. package/gui/dist/assets/Tray-D4bA1vff.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-Bi1K37Wl.js} +12 -12
  8. package/gui/dist/assets/tray-data-BZXhB3qz.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -0,0 +1,569 @@
1
+ import type { TranslatorBudget } from "../../lib/translator-budget";
2
+ import type { AdapterEvent } from "../../types";
3
+
4
+ const OPEN_TAG = "<tool_call>";
5
+ const CLOSE_TAG = "</tool_call>";
6
+ const FUNCTION_TAG = "<function=";
7
+ /**
8
+ * Streaming bounds (ingestStreaming only; buffered reconciliation already knows its structured calls).
9
+ * MAX_HELD_BYTES is a runaway guard on held text plus queued events, far below the translator budget,
10
+ * so a large duplicated write inside the block is still reconciled. MAX_TRAILING_CHARS bounds the
11
+ * latency case: a duplicated block is the tail of the content (#5548), so a closed block followed by
12
+ * this much prose, with no block open after it, is treated as prose and released.
13
+ */
14
+ const MAX_HELD_BYTES = 4 * 1024 * 1024;
15
+ const MAX_TRAILING_CHARS = 8 * 1024;
16
+
17
+ interface TextContext {
18
+ fence: string | null;
19
+ lineStart: boolean;
20
+ inlineTicks?: number;
21
+ }
22
+
23
+ export interface SerializedToolCall {
24
+ name: string;
25
+ body: string;
26
+ start: number;
27
+ end: number;
28
+ }
29
+
30
+ export interface StructuredToolCallReference {
31
+ names: ReadonlySet<string>;
32
+ argumentsText: string;
33
+ }
34
+
35
+ const BLOCK_HEADER = /<tool_call>\s*<function=([^>\r\n]+)>/y;
36
+ /** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */
37
+ const NEXT_BLOCK_HEADER = /\n<tool_call>\s*<function=[^>\r\n]+>/g;
38
+ const FUNCTION_CLOSE = "</function>";
39
+ const PARAMETER_CLOSE = "</parameter>";
40
+
41
+ function trimmedEnd(text: string, from: number, to: number): number {
42
+ while (to > from && /\s/.test(text[to - 1]!)) to--;
43
+ return to;
44
+ }
45
+
46
+ function endsWithAt(text: string, from: number, to: number, suffix: string): boolean {
47
+ return to - suffix.length >= from && text.startsWith(suffix, to - suffix.length);
48
+ }
49
+
50
+ /**
51
+ * The block starting at `offset`, read by delimiter scan so an unterminated block costs linear time.
52
+ * MiMo's echo may close a freeform body with a stray `</parameter>` and may omit `</function>`
53
+ * (#5724), the grammar the Command Code reader accepts too. The first `</tool_call>` preceded by
54
+ * `</function>` closes the block, so a body can still carry a literal `</tool_call>` or header;
55
+ * with none before the next line-start block header, the first `</tool_call>` does. That header only
56
+ * bounds an unclosed candidate: with no close at all before it, it is body text, and a closed
57
+ * `</function></tool_call>` after it still ends the block.
58
+ */
59
+ function blockAt(text: string, offset: number): SerializedToolCall | undefined {
60
+ BLOCK_HEADER.lastIndex = offset;
61
+ const header = BLOCK_HEADER.exec(text);
62
+ if (!header) return undefined;
63
+ const bodyStart = offset + header[0].length;
64
+ NEXT_BLOCK_HEADER.lastIndex = bodyStart;
65
+ const next = NEXT_BLOCK_HEADER.exec(text);
66
+ const limit = next ? next.index + 1 : text.length;
67
+ let unclosed: SerializedToolCall | undefined;
68
+ for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed);
69
+ close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) {
70
+ let bodyEnd = trimmedEnd(text, bodyStart, close);
71
+ const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE);
72
+ if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length);
73
+ if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length;
74
+ const call = {
75
+ name: header[1]!.trim(),
76
+ body: text.slice(bodyStart, bodyEnd),
77
+ start: offset,
78
+ end: close + CLOSE_TAG.length,
79
+ };
80
+ if (closed) return call;
81
+ if (close < limit) unclosed ??= call;
82
+ }
83
+ return unclosed;
84
+ }
85
+
86
+ /** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */
87
+ function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] {
88
+ const calls: SerializedToolCall[] = [];
89
+ let offset = 0;
90
+ while (offset < text.length) {
91
+ const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true);
92
+ offset += split.emit.length;
93
+ if (!split.hasOpenTag) break;
94
+ const match = blockAt(text, offset);
95
+ if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call.
96
+ calls.push(match);
97
+ offset = match.end;
98
+ context = { fence: null, lineStart: false };
99
+ }
100
+ return calls;
101
+ }
102
+
103
+ /**
104
+ * The first block, and only when the text after it is exactly one repetition of that same block
105
+ * (trailing whitespace allowed). The returned range covers the pair and any trailing whitespace.
106
+ */
107
+ function repeatedCallIn(text: string, context?: TextContext): SerializedToolCall | undefined {
108
+ const first = callsIn(text, context)[0];
109
+ if (!first) return undefined;
110
+ if (text.slice(first.end).trimEnd() !== text.slice(first.start, first.end).trimEnd()) return undefined;
111
+ return { ...first, end: text.length };
112
+ }
113
+
114
+ /** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */
115
+ export function splitAtPossibleSerializedToolCall(
116
+ text: string,
117
+ initialContext: TextContext = { fence: null, lineStart: true },
118
+ final = false,
119
+ ): {
120
+ emit: string;
121
+ defer: string;
122
+ hasOpenTag: boolean;
123
+ context: TextContext;
124
+ } {
125
+ const context = { ...initialContext };
126
+ const split = (at: number, hasOpenTag = false) => ({
127
+ emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context,
128
+ });
129
+ for (let index = 0; index < text.length; index++) {
130
+ if (context.lineStart) {
131
+ const rest = text.slice(index);
132
+ const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest);
133
+ if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0]
134
+ && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) {
135
+ if (!final && !rest.includes("\n")) return split(index);
136
+ context.fence = context.fence ? null : fence[1]!;
137
+ index += fence[0].length - 1;
138
+ context.lineStart = false;
139
+ continue;
140
+ }
141
+ if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index);
142
+ // Only bare control markup qualifies. Prose, quotes, indented examples and
143
+ // fenced code stay user-visible even when their body matches a real call.
144
+ if (!context.fence && !context.inlineTicks) {
145
+ if (rest.startsWith(OPEN_TAG)) {
146
+ const header = rest.slice(OPEN_TAG.length).trimStart();
147
+ if (/^<function=[^>\r\n]+>/.test(header)) return split(index, true);
148
+ if (!final && (FUNCTION_TAG.startsWith(header)
149
+ || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) {
150
+ return split(index);
151
+ }
152
+ } else if (!final && OPEN_TAG.startsWith(rest)) return split(index);
153
+ }
154
+ }
155
+ if (!context.fence && text[index] === "`") {
156
+ let end = index + 1;
157
+ while (text[end] === "`") end++;
158
+ if (!final && end === text.length) return split(index);
159
+ const ticks = end - index;
160
+ if (!context.inlineTicks) context.inlineTicks = ticks;
161
+ else if (context.inlineTicks === ticks) context.inlineTicks = undefined;
162
+ index = end - 1;
163
+ }
164
+ context.lineStart = text[index] === "\n";
165
+ }
166
+ return split(text.length);
167
+ }
168
+
169
+ /**
170
+ * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so
171
+ * the scan runs through the whole text instead of stopping at the first opening tag; a block ends
172
+ * mid-line, which is exactly what the neutral spelling reports too.
173
+ */
174
+ function contextAfter(text: string, context: TextContext): TextContext {
175
+ if (text.length === 0) return context;
176
+ return splitAtPossibleSerializedToolCall(text.replaceAll(OPEN_TAG, "<tool-call>"), context, true).context;
177
+ }
178
+
179
+ /**
180
+ * Holds possible duplicate text within the shared translator budget until the dispatch outcome is
181
+ * known. While a block candidate is open, any other event (reasoning) is queued at its position in
182
+ * the held text rather than overtaking it or forcing the block out early, and `drain` restores the
183
+ * original order.
184
+ */
185
+ export class SerializedToolCallContentBuffer {
186
+ private text = "";
187
+ private bytes = 0;
188
+ private hasOpenTag = false;
189
+ private context: TextContext = { fence: null, lineStart: true };
190
+ private queued: { offset: number; event: AdapterEvent }[] = [];
191
+
192
+ constructor(private readonly budget: TranslatorBudget) {}
193
+
194
+ /** Reserves the replacement before releasing the old text, preserving it if the budget rejects growth. */
195
+ private replace(next: string, hasOpenTag: boolean): void {
196
+ const nextBytes = Buffer.byteLength(next);
197
+ const reservation = this.budget.reserveTransient(nextBytes, { kind: "live_transient" });
198
+ try {
199
+ reservation.commitRetained();
200
+ this.budget.releaseRetained(this.bytes, { kind: "live_transient" });
201
+ this.text = next;
202
+ this.bytes = nextBytes;
203
+ this.hasOpenTag = hasOpenTag;
204
+ } catch (error) {
205
+ reservation.release();
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ /** Charges only the appended bytes, so holding an open block never needs twice its retained size. */
211
+ private append(delta: string): void {
212
+ const deltaBytes = Buffer.byteLength(delta);
213
+ this.budget.reserveTransient(deltaBytes, { kind: "live_transient" }).commitRetained();
214
+ this.text += delta;
215
+ this.bytes += deltaBytes;
216
+ }
217
+
218
+ /** Returns immediately safe text and retains only the suffix that still needs reconciliation. */
219
+ ingest(delta: string): string {
220
+ if (this.hasOpenTag) {
221
+ this.append(delta);
222
+ return "";
223
+ }
224
+ const split = splitAtPossibleSerializedToolCall(this.text + delta, this.context);
225
+ this.replace(split.defer, split.hasOpenTag);
226
+ this.context = split.context;
227
+ return split.emit;
228
+ }
229
+
230
+ /**
231
+ * Streaming ingest with bounded retention. Past either bound the stream prefers delivering text
232
+ * over suppressing a possible duplicate: everything held is released in order, nothing is
233
+ * suppressed (the behaviour before #5548 for that block), and scanning resumes from the carried
234
+ * context. The size bound is checked before the delta is retained.
235
+ */
236
+ ingestStreaming(delta: string): AdapterEvent[] {
237
+ const deltaBytes = Buffer.byteLength(delta);
238
+ if (this.hasOpenTag && this.bytes + deltaBytes > MAX_HELD_BYTES) {
239
+ const released = this.drain([]);
240
+ // A delta that alone passes the bound is delivered as text rather than retained.
241
+ if (deltaBytes > MAX_HELD_BYTES) {
242
+ this.context = contextAfter(delta, this.context);
243
+ return [...released, ...textEvents(delta)];
244
+ }
245
+ return [...released, ...textEvents(this.ingest(delta))];
246
+ }
247
+ const text = this.ingest(delta);
248
+ // Checked after ingest too: one delta can open a block and already carry more than a bound.
249
+ if (this.hasOpenTag && (this.bytes > MAX_HELD_BYTES || proseAfterClosedBlock(this.text) > MAX_TRAILING_CHARS)) {
250
+ return [...textEvents(text), ...this.drain([])];
251
+ }
252
+ return textEvents(text);
253
+ }
254
+
255
+ /** Exposes held text as evidence for narrowly repairing duplicated argument prefixes. */
256
+ current(): string {
257
+ return this.text;
258
+ }
259
+
260
+ /**
261
+ * Passes a non-text event through, in order. With an open block candidate held, the event is
262
+ * queued behind the held text and a heartbeat stands in for it; with only a partial prefix held (no complete opening tag yet),
263
+ * that prefix cannot be a whole duplicate and is released ahead of the event.
264
+ */
265
+ hold(event: AdapterEvent): AdapterEvent[] {
266
+ if (!this.hasOpenTag) return [...this.drain([]), event];
267
+ const eventBytes = Buffer.byteLength(JSON.stringify(event));
268
+ // Queued events count toward the same runaway bound as held text.
269
+ if (this.bytes + eventBytes > MAX_HELD_BYTES) return [...this.drain([]), event];
270
+ this.budget.reserveTransient(eventBytes, { kind: "live_transient" }).commitRetained();
271
+ this.bytes += eventBytes;
272
+ this.queued.push({ offset: this.text.length, event });
273
+ // The consumer still sees activity, so a stall watchdog never mistakes a held turn for a dead one.
274
+ return [{ type: "heartbeat" }];
275
+ }
276
+
277
+ /**
278
+ * Drains held text and queued events in their original order, suppressing only blocks that
279
+ * duplicate a dispatched call; pass an empty list on failure to preserve everything.
280
+ */
281
+ drain(structuredCalls: readonly StructuredToolCallReference[]): AdapterEvent[] {
282
+ const removed = duplicatedSerializedToolCallRanges(this.text, structuredCalls, this.context);
283
+ const kept = (from: number, to: number): string => {
284
+ let piece = "";
285
+ let cursor = from;
286
+ for (const range of removed) {
287
+ if (range.end <= cursor || range.start >= to) continue;
288
+ piece += this.text.slice(cursor, Math.max(cursor, range.start));
289
+ cursor = Math.min(to, range.end);
290
+ }
291
+ return piece + this.text.slice(cursor, to);
292
+ };
293
+ const out: AdapterEvent[] = [];
294
+ let cursor = 0;
295
+ for (const boundary of [...this.queued, { offset: this.text.length, event: undefined }]) {
296
+ const text = kept(cursor, boundary.offset);
297
+ if (text.length > 0) out.push({ type: "text_delta", text });
298
+ if (boundary.event) out.push(boundary.event);
299
+ cursor = boundary.offset;
300
+ }
301
+ // Later text continues after what was drained, so its line and fence state carry forward.
302
+ this.context = contextAfter(this.text, this.context);
303
+ this.queued = [];
304
+ this.replace("", false);
305
+ return out;
306
+ }
307
+
308
+ /** Text-only drain for callers that never queued an event. */
309
+ flush(structuredCalls: readonly StructuredToolCallReference[]): string {
310
+ return this.drain(structuredCalls)
311
+ .map(event => (event.type === "text_delta" ? event.text : ""))
312
+ .join("");
313
+ }
314
+
315
+ /** Releases retained bytes when the stream ends or its consumer cancels iteration. */
316
+ dispose(): void {
317
+ this.budget.releaseRetained(this.bytes, { kind: "live_transient" });
318
+ this.text = "";
319
+ this.bytes = 0;
320
+ this.hasOpenTag = false;
321
+ this.queued = [];
322
+ }
323
+ }
324
+
325
+ /** Reads only a string input from a JSON object; other argument shapes cannot prove duplication. */
326
+ function inputFromArguments(argumentsText: string): string | undefined {
327
+ try {
328
+ const parsed = JSON.parse(argumentsText) as unknown;
329
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
330
+ const input = (parsed as Record<string, unknown>).input;
331
+ return typeof input === "string" ? input : undefined;
332
+ } catch {
333
+ return undefined;
334
+ }
335
+ }
336
+
337
+ /** One wrapping newline after the function header is template layout, not input (vLLM `_trim_wrapping_newlines`). */
338
+ function freeformBody(value: string): string {
339
+ return value.replace(/^\r?\n/, "").trimEnd();
340
+ }
341
+
342
+ /**
343
+ * Whether a structured call's freeform input already equals the body of `repeated`. Such a call
344
+ * explains the repeated pair on its own, which is what competes with a doubled call in the same
345
+ * batch: both readings account for the two blocks, and the response never says which one it meant.
346
+ */
347
+ function agreesWithRepeatedBlock(
348
+ structured: StructuredToolCallReference,
349
+ repeated: SerializedToolCall,
350
+ ): boolean {
351
+ const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText) : undefined;
352
+ return input !== undefined && freeformBody(input) === freeformBody(repeated.body);
353
+ }
354
+
355
+ /** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */
356
+ function duplicatedSerializedToolCallRanges(
357
+ text: string,
358
+ structuredCalls: readonly StructuredToolCallReference[],
359
+ context?: TextContext,
360
+ ): { start: number; end: number }[] {
361
+ if (structuredCalls.length === 0) return [];
362
+ const repeated = repeatedCallIn(text, context);
363
+ if (repeated) {
364
+ // Without a single agreeing call the pair is ambiguous, so no shape of it is suppressed.
365
+ const matching = structuredCalls.filter(structured => agreesWithRepeatedBlock(structured, repeated));
366
+ return matching.length === 1 ? [{ start: repeated.start, end: repeated.end }] : [];
367
+ }
368
+ return callsIn(text, context).filter(call => {
369
+ const body = freeformBody(call.body);
370
+ return structuredCalls.some(structured => {
371
+ const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText) : undefined;
372
+ return input !== undefined && freeformBody(input) === body;
373
+ });
374
+ });
375
+ }
376
+
377
+ /** Removes eligible blocks only when both the function identity and freeform input match a dispatched call. */
378
+ export function stripDuplicatedSerializedToolCalls(
379
+ text: string,
380
+ structuredCalls: readonly StructuredToolCallReference[],
381
+ context?: TextContext,
382
+ ): string {
383
+ let result = "";
384
+ let cursor = 0;
385
+ for (const range of duplicatedSerializedToolCallRanges(text, structuredCalls, context)) {
386
+ result += text.slice(cursor, range.start);
387
+ cursor = range.end;
388
+ }
389
+ return result + text.slice(cursor);
390
+ }
391
+
392
+ /**
393
+ * The reduced arguments when the freeform body of the repeated block is written twice in the
394
+ * single string "input" field, or undefined for any other shape. Only the batch reconciler may
395
+ * apply it: the reduction rewrites executable arguments, so it needs a uniqueness proof.
396
+ */
397
+ function doubledInputReduction(
398
+ argumentsText: string,
399
+ functionNames: ReadonlySet<string>,
400
+ repeated: SerializedToolCall,
401
+ ): string | undefined {
402
+ if (!functionNames.has(repeated.name)) return undefined;
403
+ const body = freeformBody(repeated.body);
404
+ try {
405
+ const parsed = JSON.parse(argumentsText) as unknown;
406
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
407
+ && Object.keys(parsed).length === 1
408
+ && ((parsed as Record<string, unknown>).input === body + body
409
+ || (parsed as Record<string, unknown>).input === body + "\n" + body)) {
410
+ return JSON.stringify({ input: body });
411
+ }
412
+ } catch {
413
+ // A malformed concatenation is handled by the prefix repair instead.
414
+ }
415
+ return undefined;
416
+ }
417
+
418
+ /** Removes a malformed argument prefix only when a bare block and the JSON suffix prove identical input. */
419
+ export function repairArgumentsDuplicatedBesideSerializedCall(
420
+ argumentsText: string,
421
+ functionNames: ReadonlySet<string>,
422
+ serializedText: string,
423
+ ): string {
424
+ try {
425
+ JSON.parse(argumentsText);
426
+ return argumentsText;
427
+ } catch {
428
+ // Continue only for the exact duplication shape emitted by some Chat gateways.
429
+ }
430
+
431
+ const bodies = callsIn(serializedText)
432
+ .filter(call => functionNames.has(call.name))
433
+ .map(call => call.body.trimEnd());
434
+ if (bodies.length === 0) return argumentsText;
435
+
436
+ for (const body of bodies) {
437
+ if (!argumentsText.startsWith(body)) continue;
438
+ let start = body.length;
439
+ while (start < argumentsText.length && /\s/.test(argumentsText[start]!)) start += 1;
440
+ const candidate = argumentsText.slice(start);
441
+ let parsed: unknown;
442
+ try {
443
+ parsed = JSON.parse(candidate);
444
+ } catch {
445
+ continue;
446
+ }
447
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
448
+ const input = (parsed as Record<string, unknown>).input;
449
+ if (typeof input !== "string") continue;
450
+ if (body !== input.trimEnd()) continue;
451
+ return candidate;
452
+ }
453
+ return argumentsText;
454
+ }
455
+
456
+ /** One structured call as the reconciler sees it, before its arguments meet the visible text. */
457
+ export interface StructuredToolCallInput {
458
+ wireName: string;
459
+ restoredName: string;
460
+ argumentsText: string;
461
+ }
462
+
463
+ /**
464
+ * Repairs the arguments of every structured call in one response against the visible text that
465
+ * response carried, and returns them in input order. The per-call prefix repair stands alone,
466
+ * because the markup it proves is matched against that call's own repaired input. The
467
+ * doubled-input reduction is applied only when exactly ONE call in the batch qualifies: it either
468
+ * carries the doubled shape or already agrees with the repeated block body. It rewrites executable
469
+ * arguments, and a second qualifying call leaves the block ambiguous, so the uniqueness proof has
470
+ * to cover the whole batch rather than one call at a time.
471
+ */
472
+ export function reconcileStructuredToolCalls(
473
+ calls: readonly StructuredToolCallInput[],
474
+ serializedText: string,
475
+ ): StructuredToolCallReference[] {
476
+ const references = calls.map(call => {
477
+ const names = new Set([call.wireName, call.restoredName]);
478
+ return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(call.argumentsText, names, serializedText) };
479
+ });
480
+ return reduceUnambiguousDoubledInput(references, serializedText);
481
+ }
482
+
483
+ /**
484
+ * Applies the doubled-input reduction across the batch. The doubled shape is valid JSON, so the
485
+ * prefix repair returns it untouched, and the reduction only ever rewrites a call the repair left
486
+ * alone. A call whose input already equals the repeated body is a competing explanation, not a
487
+ * bystander: both readings account for the pair and the response never picks one, so a batch with
488
+ * two qualifying calls keeps every argument exactly as sent. The reduction then rewrites nothing,
489
+ * and the markup is left to the range matcher, which suppresses the pair only when exactly one
490
+ * call already agrees. A lone qualifying call is always the doubled one, because a call that
491
+ * already agrees leaves nothing to reduce.
492
+ */
493
+ function reduceUnambiguousDoubledInput(
494
+ references: readonly StructuredToolCallReference[],
495
+ serializedText: string,
496
+ ): StructuredToolCallReference[] {
497
+ const repeated = repeatedCallIn(serializedText);
498
+ if (!repeated) return [...references];
499
+ const candidates = references.map(reference => ({
500
+ reduction: doubledInputReduction(reference.argumentsText, reference.names, repeated),
501
+ explains: agreesWithRepeatedBlock(reference, repeated),
502
+ }));
503
+ if (candidates.filter(candidate => candidate.explains || candidate.reduction !== undefined).length !== 1) {
504
+ return [...references];
505
+ }
506
+ return references.map((reference, index) => {
507
+ const reduction = candidates[index]!.reduction;
508
+ return reduction === undefined ? reference : { names: reference.names, argumentsText: reduction };
509
+ });
510
+ }
511
+
512
+ /**
513
+ * One structured call as the reconciler sees it: both the wire name and its restored client name
514
+ * identify it, and its arguments are repaired against the visible text the same response carried.
515
+ * A single call is its own batch, so the doubled-input reduction still applies here; a caller
516
+ * holding several calls of one response must pass them together to
517
+ * `reconcileStructuredToolCalls` so the reduction sees all of them.
518
+ */
519
+ export function reconcileStructuredToolCall(
520
+ wireName: string,
521
+ restoredName: string,
522
+ argumentsText: string,
523
+ serializedText: string,
524
+ ): StructuredToolCallReference {
525
+ return reconcileStructuredToolCalls([{ wireName, restoredName, argumentsText }], serializedText)[0]!;
526
+ }
527
+
528
+ /**
529
+ * Buffered-response counterpart of the streaming path, applied in place to the content events in
530
+ * `events[start, end)`. It replays them through the same buffer the stream uses, so both paths
531
+ * share one rule set: text carries its line and fence context across events (the inline-think
532
+ * splitter may cut one answer into several), any other event keeps its place relative to held
533
+ * text, and only text still held at the end is matched against the structured calls.
534
+ */
535
+ export function reconcileSerializedToolCallEvents(
536
+ events: AdapterEvent[],
537
+ start: number,
538
+ end: number,
539
+ structuredCalls: readonly StructuredToolCallReference[],
540
+ budget: TranslatorBudget,
541
+ ): void {
542
+ if (structuredCalls.length === 0) return;
543
+ const buffer = new SerializedToolCallContentBuffer(budget);
544
+ const reconciled: AdapterEvent[] = [];
545
+ try {
546
+ for (const event of events.slice(start, end)) {
547
+ if (event.type !== "text_delta") { reconciled.push(...buffer.hold(event).filter(held => held.type !== "heartbeat")); continue; }
548
+ const text = buffer.ingest(event.text);
549
+ if (text.length > 0) reconciled.push({ type: "text_delta", text });
550
+ }
551
+ reconciled.push(...buffer.drain(structuredCalls));
552
+ } finally {
553
+ buffer.dispose();
554
+ }
555
+ events.splice(start, end - start, ...reconciled);
556
+ }
557
+
558
+ function textEvents(text: string): AdapterEvent[] {
559
+ return text.length > 0 ? [{ type: "text_delta", text }] : [];
560
+ }
561
+
562
+ /** Non-whitespace characters after the last closed block, or 0 while a later block is still open. */
563
+ function proseAfterClosedBlock(text: string): number {
564
+ const closer = text.lastIndexOf(CLOSE_TAG);
565
+ if (closer < 0) return 0;
566
+ const tail = text.slice(closer + CLOSE_TAG.length);
567
+ if (tail.includes(OPEN_TAG)) return 0;
568
+ return tail.replace(/\s+/g, "").length;
569
+ }