@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925
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/README.md +28 -25
- package/bin/ocx.mjs +16 -2
- package/gui/dist/assets/App-8NMiZxT0.css +1 -0
- package/gui/dist/assets/App-CsYvvpr3.js +50 -0
- package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
- package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
- package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
- package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -4
- package/src/adapters/anthropic.ts +30 -1
- package/src/adapters/claude-cli/adapter.ts +218 -0
- package/src/adapters/claude-cli/profiles.ts +38 -0
- package/src/adapters/codebuddy/profiles.ts +2 -0
- package/src/adapters/coding-agent/profile.ts +12 -4
- package/src/adapters/coding-agent/turn.ts +16 -6
- package/src/adapters/command-code-tool-text.ts +133 -23
- package/src/adapters/cursor/catalog.ts +4 -8
- package/src/adapters/cursor/discovery.ts +10 -5
- package/src/adapters/cursor/effort-map.ts +2 -6
- package/src/adapters/cursor/envelope-echo.ts +10 -5
- package/src/adapters/cursor/request-builder.ts +9 -4
- package/src/adapters/cursor/thread-continuity.ts +53 -0
- package/src/adapters/cursor.ts +29 -13
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/google-tool-schema.ts +26 -3
- package/src/adapters/identity.ts +214 -2
- package/src/adapters/openai-chat/passthrough.ts +1 -0
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
- package/src/adapters/openai-chat.ts +43 -15
- package/src/adapters/openai-responses/canonical-forward.ts +17 -10
- package/src/adapters/openai-responses/passthrough.ts +19 -4
- package/src/adapters/openai-responses/request-strips.ts +25 -0
- package/src/adapters/openai-responses/web-search.ts +22 -4
- package/src/adapters/qoder/profiles.ts +2 -0
- package/src/adapters/registry.ts +8 -0
- package/src/adapters/run-turn-queue.ts +7 -0
- package/src/bridge/response-json.ts +6 -4
- package/src/bridge/sse.ts +7 -5
- package/src/claude/alias.ts +73 -26
- package/src/claude/context-windows.ts +15 -6
- package/src/claude/desktop-3p-library.ts +3 -2
- package/src/claude/desktop-3p.ts +1 -1
- package/src/claude/desktop-first-party.ts +63 -22
- package/src/claude/desktop-picker-profile.ts +302 -0
- package/src/claude/desktop-picker.ts +365 -0
- package/src/claude/desktop-risk.ts +20 -0
- package/src/claude/gateway-cache.ts +13 -4
- package/src/claude/inbound.ts +5 -2
- package/src/claude/intercept/connect-proxy.ts +106 -29
- package/src/claude/intercept/local-ca.ts +76 -18
- package/src/claude/intercept/model-bindings.ts +145 -0
- package/src/claude/intercept/picker-bootstrap.ts +141 -0
- package/src/claude/intercept/picker-ca.ts +126 -0
- package/src/claude/intercept/picker-listener.ts +213 -0
- package/src/claude/intercept/picker-models.ts +101 -0
- package/src/claude/intercept/picker-runtime.ts +330 -0
- package/src/claude/intercept/picker-trust.ts +125 -0
- package/src/claude/intercept/runtime.ts +149 -2
- package/src/claude/model-info.ts +18 -4
- package/src/cli/agent.ts +16 -2
- package/src/cli/capabilities.ts +71 -2
- package/src/cli/claude-desktop.ts +319 -33
- package/src/cli/claude.ts +15 -7
- package/src/cli/codex-shim-autorestore.ts +1 -0
- package/src/cli/dispatch.ts +29 -23
- package/src/cli/effort.ts +16 -6
- package/src/cli/ensure-desired-integrations.ts +29 -4
- package/src/cli/ready.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/cli/restart-scope.ts +9 -1
- package/src/cli/runtime-api.ts +17 -0
- package/src/cli/system-command.ts +11 -12
- package/src/client/connect.ts +12 -2
- package/src/client/state.ts +39 -1
- package/src/clients/config-export.ts +57 -9
- package/src/codex/auth-context.ts +51 -11
- package/src/codex/catalog/derive-entry.ts +5 -5
- package/src/codex/catalog/gather-capture.ts +25 -6
- package/src/codex/catalog/metadata.ts +3 -3
- package/src/codex/catalog/parsing.ts +39 -1
- package/src/codex/catalog/provider-models.ts +8 -6
- package/src/codex/catalog/retained-sync.ts +17 -7
- package/src/codex/catalog/sync.ts +2 -2
- package/src/codex/desktop-app-restart.ts +8 -0
- package/src/codex/desktop-switches.ts +9 -1
- package/src/codex/home.ts +43 -4
- package/src/codex/inject/config-toml.ts +137 -0
- package/src/codex/inject/plan.ts +23 -0
- package/src/codex/inject/remove.ts +21 -1
- package/src/codex/inject.ts +63 -8
- package/src/codex/journal.ts +36 -0
- package/src/codex/main-account-hard-lock.ts +14 -1
- package/src/codex/main-account-policy-wait.ts +109 -0
- package/src/codex/native-profile-startup.ts +24 -6
- package/src/codex/native-residue.ts +28 -10
- package/src/codex/quota-types.ts +8 -1
- package/src/codex/quota.ts +49 -7
- package/src/codex/runtime.ts +31 -10
- package/src/combos/failover.ts +7 -6
- package/src/combos/resolve.ts +91 -14
- package/src/combos/types.ts +18 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +21 -2
- package/src/config/schema/leaf-validators.ts +6 -3
- package/src/config/subagent-models.ts +3 -1
- package/src/generated/compatibility-version.json +275 -163
- package/src/images/artifacts.ts +14 -0
- package/src/integrations/owned-refresh.ts +1 -1
- package/src/integrations/ownership-policy.ts +32 -1
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +9 -0
- package/src/lib/config-ownership.ts +3 -0
- package/src/lib/errors.ts +87 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/request-execution-budget.ts +42 -14
- package/src/lib/request-resend-gate.ts +4 -3
- package/src/lib/retry-delay.ts +7 -1
- package/src/lib/tool-envelope-echo-filter.ts +236 -0
- package/src/lib/upstream-retry.ts +20 -12
- package/src/oauth/index.ts +1 -0
- package/src/oauth/kiro-credentials.ts +14 -1
- package/src/oauth/login-cli.ts +1 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fast-opt-in.ts +31 -0
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/provider-id-rewrite.ts +2 -0
- package/src/providers/quota/vendor-probes-key.ts +8 -2
- package/src/providers/registry/entries-core.ts +45 -2
- package/src/providers/registry/entries-extended.ts +99 -12
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/types.ts +7 -1
- package/src/providers/resolved-model-policy.ts +11 -4
- package/src/providers/service-tier.ts +10 -3
- package/src/responses/code-mode-helper-compat.ts +3 -0
- package/src/responses/parser.ts +45 -3
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/claude-messages.ts +69 -14
- package/src/server/grok-upstream-envelope-echo.ts +120 -0
- package/src/server/index/claude-intercept-lifecycle.ts +24 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index.ts +7 -0
- package/src/server/management/agent-settings-routes.ts +204 -112
- package/src/server/management/claude-desktop-picker-routes.ts +128 -0
- package/src/server/management/combo-routes.ts +59 -2
- package/src/server/management/config-routes.ts +81 -39
- package/src/server/management/context.ts +3 -0
- package/src/server/management/native-integration-routes.ts +169 -93
- package/src/server/management/provider-routes.ts +46 -4
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/routing-profile-routes.ts +9 -0
- package/src/server/management/sidebar-routes.ts +52 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/proxy-liveness.ts +38 -3
- package/src/server/request-log-account-rotation.ts +53 -0
- package/src/server/request-log.ts +3 -16
- package/src/server/responses/adapter-dispatch.ts +17 -1
- package/src/server/responses/agent-task-recovery.ts +49 -26
- package/src/server/responses/codex-ws-exchange.ts +18 -7
- package/src/server/responses/codex-ws-wire.ts +27 -4
- package/src/server/responses/core-combo.ts +21 -3
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-opaque-recovery.ts +3 -2
- package/src/server/responses/encrypted-payload.ts +15 -14
- package/src/server/responses/fetch-helpers.ts +1 -1
- package/src/server/responses/input-admission.ts +10 -6
- package/src/server/responses/native-response-control.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +182 -12
- package/src/server/responses/passthrough-dispatch.ts +102 -44
- package/src/server/responses/policy-fallback.ts +3 -0
- package/src/server/responses/policy-refusal.ts +67 -0
- package/src/server/responses/request-send-budget.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +42 -11
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/system-env-shell.ts +0 -1
- package/src/server/system-env.ts +25 -4
- package/src/service/cli.ts +34 -3
- package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
- package/src/tray/windows-tray.ps1 +188 -7
- package/src/tray/windows.ts +23 -2
- package/src/types/config.ts +52 -12
- package/src/types/provider.ts +22 -5
- package/src/types/tools.ts +11 -5
- package/src/types.ts +1 -0
- package/src/update/async-check.ts +109 -0
- package/src/update/badge.ts +11 -17
- package/src/update/check-types.ts +9 -0
- package/src/update/desktop-badge.ts +102 -0
- package/src/update/index.ts +48 -8
- package/src/update/install-detection.d.mts +29 -2
- package/src/update/install-detection.mjs +213 -7
- package/src/update/job.ts +17 -12
- package/src/update/notify.ts +16 -11
- package/src/update/pnpm-owner-worker.ts +12 -0
- package/src/update/refresh-scheduler.ts +141 -0
- package/src/usage/cost.ts +31 -4
- package/src/usage/expected-prices.ts +57 -0
- package/assets/download-linux.svg +0 -10
- package/assets/download-macos.svg +0 -10
- package/assets/download-windows.svg +0 -10
- package/gui/dist/assets/App-CpuDF3ci.js +0 -50
- package/gui/dist/assets/App-I5AnaSLh.css +0 -1
- package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
- 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
|
+
}
|