@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.
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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))
|
|
39
|
-
|
|
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))
|
|
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)))
|
|
46
|
-
const cached =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
86
|
-
close = text.indexOf(
|
|
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[
|
|
85
|
+
name: header[2]!.trim(),
|
|
93
86
|
body: text.slice(bodyStart, bodyEnd),
|
|
94
87
|
start: offset,
|
|
95
|
-
end: close +
|
|
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
|
|
104
|
-
function
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
this.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 =
|
|
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(-(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
394
|
-
|
|
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
|
-
&&
|
|
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 }),
|