@bitkyc08/opencodex 2.89.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 (129) hide show
  1. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  2. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
  3. package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
  4. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +2 -2
  7. package/src/adapters/base.ts +4 -0
  8. package/src/adapters/claude-cli/adapter.ts +13 -7
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  10. package/src/adapters/coding-agent/turn.ts +14 -4
  11. package/src/adapters/cursor/protobuf-request.ts +3 -0
  12. package/src/adapters/google-progress-history.ts +19 -1
  13. package/src/adapters/google.ts +28 -9
  14. package/src/adapters/image.ts +5 -3
  15. package/src/adapters/input-media-guard.ts +5 -2
  16. package/src/adapters/kiro-refusal.ts +41 -1
  17. package/src/adapters/ollama-native.ts +1 -0
  18. package/src/adapters/openai-chat/messages.ts +34 -4
  19. package/src/adapters/openai-chat/passthrough.ts +37 -4
  20. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  21. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  22. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  23. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  24. package/src/adapters/openai-chat.ts +7 -6
  25. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  26. package/src/adapters/openai-responses/passthrough.ts +5 -1
  27. package/src/adapters/openai-responses/transport.ts +13 -1
  28. package/src/claude/desktop-3p-library.ts +43 -3
  29. package/src/claude/desktop-3p.ts +20 -10
  30. package/src/claude/desktop-picker-profile.ts +18 -9
  31. package/src/claude/desktop-remote-store-io.ts +2 -2
  32. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  33. package/src/codex/account-lifecycle.ts +19 -6
  34. package/src/codex/account-store.ts +27 -8
  35. package/src/codex/account-usability.ts +4 -1
  36. package/src/codex/auth-api/account-list.ts +10 -5
  37. package/src/codex/auth-api/account-pause-group.ts +2 -22
  38. package/src/codex/auth-api/login-collision.ts +59 -0
  39. package/src/codex/auth-api/login-flow.ts +12 -7
  40. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  41. package/src/codex/auth-collision.ts +31 -4
  42. package/src/codex/auth-context.ts +48 -6
  43. package/src/codex/cli-update-plan.ts +18 -3
  44. package/src/codex/collision-identity.ts +78 -0
  45. package/src/codex/main-account-hard-lock.ts +14 -0
  46. package/src/codex/routing/cooldown-math.ts +5 -0
  47. package/src/codex/routing/failure-window.ts +61 -9
  48. package/src/codex/routing.ts +4 -3
  49. package/src/codex/shim-diagnostics.ts +2 -1
  50. package/src/codex/shim-fingerprint.ts +11 -2
  51. package/src/codex/shim-templates.ts +16 -5
  52. package/src/codex/shim-windows-refresh.ts +224 -0
  53. package/src/codex/shim.ts +45 -21
  54. package/src/combos/failover.ts +8 -1
  55. package/src/config/provider-validation.ts +7 -2
  56. package/src/generated/compatibility-version.json +168 -108
  57. package/src/generated/model-metadata.ts +1 -1
  58. package/src/images/loop.ts +2 -2
  59. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  60. package/src/lib/provider-admission-wait.ts +14 -0
  61. package/src/lib/service-secrets.ts +37 -2
  62. package/src/lib/translator-budget.ts +1 -1
  63. package/src/lib/upstream-retry.ts +15 -0
  64. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  65. package/src/oauth/account-import/parser.ts +15 -2
  66. package/src/oauth/account-import/types.ts +7 -0
  67. package/src/oauth/chatgpt-shared.ts +10 -1
  68. package/src/oauth/dsh.ts +45 -2
  69. package/src/oauth/kiro.ts +44 -1
  70. package/src/oauth/login-flow-state.ts +11 -2
  71. package/src/providers/claude-cli-usage.ts +20 -5
  72. package/src/providers/initial-model-selection.ts +2 -2
  73. package/src/providers/key-failover.ts +10 -2
  74. package/src/providers/key-store.ts +8 -3
  75. package/src/providers/mistral-transport.ts +207 -0
  76. package/src/providers/openai-sidecar.ts +5 -0
  77. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  78. package/src/providers/quota/antigravity.ts +6 -2
  79. package/src/providers/quota/failure-codes.ts +18 -0
  80. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  81. package/src/providers/quota-routing-cache.ts +1 -1
  82. package/src/providers/quota.ts +5 -1
  83. package/src/providers/registry/entries-extended.ts +6 -1
  84. package/src/providers/routed-config.ts +1 -1
  85. package/src/providers/xai-transport.ts +2 -1
  86. package/src/responses/audio.ts +46 -0
  87. package/src/responses/input-media.ts +37 -15
  88. package/src/responses/parser-content.ts +22 -27
  89. package/src/responses/reasoning-retention.ts +218 -11
  90. package/src/responses/schema.ts +3 -4
  91. package/src/responses/web-search-replay.ts +11 -2
  92. package/src/server/chat-native.ts +8 -7
  93. package/src/server/index/serve-options.ts +8 -2
  94. package/src/server/index/websocket-handler.ts +10 -0
  95. package/src/server/lifecycle.ts +17 -1
  96. package/src/server/management/oauth-account-routes.ts +2 -1
  97. package/src/server/messages-native.ts +11 -0
  98. package/src/server/request-log.ts +16 -7
  99. package/src/server/responses/adapter-continuation.ts +2 -2
  100. package/src/server/responses/adapter-dispatch.ts +2 -2
  101. package/src/server/responses/codex-ws-exchange.ts +3 -0
  102. package/src/server/responses/codex-ws-metadata.ts +44 -0
  103. package/src/server/responses/codex-ws-session.ts +21 -1
  104. package/src/server/responses/combo-stream-preflight.ts +7 -2
  105. package/src/server/responses/compact.ts +2 -0
  106. package/src/server/responses/core-auth.ts +20 -0
  107. package/src/server/responses/core-codex-account.ts +21 -14
  108. package/src/server/responses/core-combo.ts +3 -0
  109. package/src/server/responses/core-opaque-recovery.ts +20 -11
  110. package/src/server/responses/deepseek-image-files.ts +121 -0
  111. package/src/server/responses/empty-completion-guard.ts +8 -0
  112. package/src/server/responses/fetch-helpers.ts +30 -10
  113. package/src/server/responses/input-admission.ts +6 -2
  114. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  115. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  116. package/src/server/responses/passthrough-completion.ts +41 -0
  117. package/src/server/responses/passthrough-delivery.ts +2 -0
  118. package/src/server/responses/passthrough-dispatch.ts +13 -13
  119. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  120. package/src/server/responses/reasoning-archive-error.ts +21 -0
  121. package/src/server/responses/request-prepare.ts +11 -1
  122. package/src/server/responses/request-transport.ts +61 -3
  123. package/src/service/orchestration.ts +17 -0
  124. package/src/types/provider.ts +2 -0
  125. package/src/types/request.ts +16 -1
  126. package/src/types.ts +1 -0
  127. package/src/web-search/loop.ts +2 -2
  128. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  129. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -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
  }
@@ -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 }),