mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 17:41:38 +00:00
fix(agents): cap DeepSeek DSML recovery buffer at 256 KB (#117175)
Co-authored-by: Sebastien Tardif <1413412+SebTardif@users.noreply.github.com>
This commit is contained in:
@@ -881,63 +881,153 @@ const DEEPSEEK_DSML_TOOL_OPEN_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) =>
|
||||
const DEEPSEEK_DSML_TOOL_CLOSE_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) =>
|
||||
DEEPSEEK_DSML_TOOL_KINDS.map((kind) => `</${bar}DSML${bar}${kind}>`),
|
||||
);
|
||||
const DEEPSEEK_DSML_INVOKE_OPEN_PREFIXES = DEEPSEEK_DSML_BARS.map(
|
||||
(bar) => `<${bar}DSML${bar}invoke`,
|
||||
);
|
||||
const DEEPSEEK_DSML_INVOKE_CLOSE_TOKENS = DEEPSEEK_DSML_BARS.map(
|
||||
(bar) => `</${bar}DSML${bar}invoke>`,
|
||||
);
|
||||
const DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN = Math.max(
|
||||
...DEEPSEEK_DSML_TOOL_OPEN_TOKENS.map((token) => token.length),
|
||||
);
|
||||
const DEEPSEEK_DSML_RECOVERY_MAX_BOUNDARY_LEN = Math.max(
|
||||
...DEEPSEEK_DSML_TOOL_OPEN_TOKENS.map((token) => token.length),
|
||||
...DEEPSEEK_DSML_TOOL_CLOSE_TOKENS.map((token) => token.length),
|
||||
...DEEPSEEK_DSML_INVOKE_OPEN_PREFIXES.map((token) => token.length),
|
||||
...DEEPSEEK_DSML_INVOKE_CLOSE_TOKENS.map((token) => token.length),
|
||||
);
|
||||
|
||||
// Match MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES / MAX_POST_TOOL_CALL_BUFFER_BYTES.
|
||||
const MAX_DSML_RECOVERY_BUFFER_BYTES = 256_000;
|
||||
const DEEPSEEK_DSML_SCAN_BATCH_CHARS = 64 * 1_024;
|
||||
|
||||
type DeepSeekDsmlToolBlockScanState = {
|
||||
offset: number;
|
||||
mode: "outer" | "invoke-open" | "invoke-body";
|
||||
invokeOpenStart: number;
|
||||
};
|
||||
|
||||
function createDeepSeekDsmlToolCallRecoverer() {
|
||||
let buffer = "";
|
||||
let bufferBytes = 0;
|
||||
let bufferEndsWithHighSurrogate = false;
|
||||
let pendingScanChars = 0;
|
||||
let activeOpenToken: string | null = null;
|
||||
let blockScanState: DeepSeekDsmlToolBlockScanState = {
|
||||
offset: 0,
|
||||
mode: "outer",
|
||||
invokeOpenStart: -1,
|
||||
};
|
||||
const resetBlockScan = () => {
|
||||
activeOpenToken = null;
|
||||
pendingScanChars = 0;
|
||||
blockScanState = { offset: 0, mode: "outer", invokeOpenStart: -1 };
|
||||
};
|
||||
|
||||
const consume = (final: boolean): DeepSeekDsmlRecoveredPart[] => {
|
||||
const output: DeepSeekDsmlRecoveredPart[] = [];
|
||||
while (buffer) {
|
||||
const open = findEarliestStringToken(buffer, DEEPSEEK_DSML_TOOL_OPEN_TOKENS);
|
||||
const open = activeOpenToken
|
||||
? { index: 0, token: activeOpenToken }
|
||||
: findEarliestStringToken(buffer, DEEPSEEK_DSML_TOOL_OPEN_TOKENS);
|
||||
if (!open) {
|
||||
resetBlockScan();
|
||||
if (final) {
|
||||
output.push({ kind: "text", text: buffer });
|
||||
buffer = "";
|
||||
bufferBytes = 0;
|
||||
bufferEndsWithHighSurrogate = false;
|
||||
return output;
|
||||
}
|
||||
const keep = longestDeepSeekDsmlToolOpenPrefixSuffixLength(buffer);
|
||||
const emitLength = buffer.length - keep;
|
||||
if (emitLength > 0) {
|
||||
output.push({ kind: "text", text: buffer.slice(0, emitLength) });
|
||||
buffer = buffer.slice(emitLength);
|
||||
const emitted = buffer.slice(0, emitLength);
|
||||
output.push({ kind: "text", text: emitted });
|
||||
bufferBytes -= Buffer.byteLength(emitted, "utf8");
|
||||
buffer = buffer.slice(emitted.length);
|
||||
if (!buffer) {
|
||||
bufferEndsWithHighSurrogate = false;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
if (open.index > 0) {
|
||||
output.push({ kind: "text", text: buffer.slice(0, open.index) });
|
||||
buffer = buffer.slice(open.index);
|
||||
const prefix = buffer.slice(0, open.index);
|
||||
output.push({ kind: "text", text: prefix });
|
||||
bufferBytes -= Buffer.byteLength(prefix, "utf8");
|
||||
buffer = buffer.slice(prefix.length);
|
||||
resetBlockScan();
|
||||
}
|
||||
|
||||
const afterOpen = buffer.slice(open.token.length);
|
||||
const close = findEarliestStringToken(afterOpen, DEEPSEEK_DSML_TOOL_CLOSE_TOKENS);
|
||||
activeOpenToken = open.token;
|
||||
if (blockScanState.offset === 0) {
|
||||
blockScanState.offset = open.token.length;
|
||||
}
|
||||
const blockScan = scanDeepSeekDsmlToolBlock(
|
||||
buffer,
|
||||
open.token.replace("<", "</"),
|
||||
open.token.length,
|
||||
blockScanState,
|
||||
);
|
||||
if (blockScan.kind === "nested-open") {
|
||||
throw new Error("Nested DeepSeek DSML recovery wrappers are not supported");
|
||||
}
|
||||
const close = blockScan.kind === "close" ? blockScan : null;
|
||||
if (!close) {
|
||||
if (final) {
|
||||
output.push({ kind: "text", text: buffer });
|
||||
buffer = "";
|
||||
bufferBytes = 0;
|
||||
bufferEndsWithHighSurrogate = false;
|
||||
return output;
|
||||
}
|
||||
if (bufferBytes > MAX_DSML_RECOVERY_BUFFER_BYTES) {
|
||||
throw new Error("Exceeded DeepSeek DSML recovery buffer limit");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const body = afterOpen.slice(0, close.index);
|
||||
const blockLength = open.token.length + close.index + close.token.length;
|
||||
resetBlockScan();
|
||||
const body = buffer.slice(open.token.length, close.index);
|
||||
const blockText = buffer.slice(0, close.index + close.token.length);
|
||||
const blockBytes = Buffer.byteLength(blockText, "utf8");
|
||||
if (blockBytes > MAX_DSML_RECOVERY_BUFFER_BYTES) {
|
||||
throw new Error("Exceeded DeepSeek DSML recovery buffer limit");
|
||||
}
|
||||
const recoveredToolCalls = parseDeepSeekDsmlToolCallBlock(body);
|
||||
if (recoveredToolCalls.length > 0) {
|
||||
output.push(...recoveredToolCalls);
|
||||
} else {
|
||||
output.push({ kind: "text", text: buffer.slice(0, blockLength) });
|
||||
output.push({ kind: "text", text: blockText });
|
||||
}
|
||||
bufferBytes -= Buffer.byteLength(blockText, "utf8");
|
||||
buffer = buffer.slice(blockText.length);
|
||||
if (!buffer) {
|
||||
bufferEndsWithHighSurrogate = false;
|
||||
}
|
||||
buffer = buffer.slice(blockLength);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
return {
|
||||
push(chunk: string) {
|
||||
const append = utf8ByteLengthForAppend(bufferEndsWithHighSurrogate, chunk);
|
||||
bufferBytes += append.bytes;
|
||||
bufferEndsWithHighSurrogate = append.endsWithHighSurrogate;
|
||||
buffer += chunk;
|
||||
pendingScanChars += chunk.length;
|
||||
if (
|
||||
activeOpenToken &&
|
||||
pendingScanChars < DEEPSEEK_DSML_SCAN_BATCH_CHARS &&
|
||||
!chunk.includes("<") &&
|
||||
!chunk.includes(">") &&
|
||||
bufferBytes <= MAX_DSML_RECOVERY_BUFFER_BYTES
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
pendingScanChars = 0;
|
||||
return consume(false);
|
||||
},
|
||||
flush() {
|
||||
@@ -948,23 +1038,23 @@ function createDeepSeekDsmlToolCallRecoverer() {
|
||||
|
||||
function parseDeepSeekDsmlToolCallBlock(body: string): RecoveredDeepSeekDsmlToolCall[] {
|
||||
const toolCalls: RecoveredDeepSeekDsmlToolCall[] = [];
|
||||
const invokeOpenRegex = /<[||]DSML[||]invoke\b([^>]*)>/g;
|
||||
const invokeOpenRegex = /<[||]DSML[||]invoke\b([^<>]*)>/g;
|
||||
let openMatch: RegExpExecArray | null;
|
||||
while ((openMatch = invokeOpenRegex.exec(body)) !== null) {
|
||||
const invokeName = parseXmlAttribute(openMatch[1] ?? "", "name");
|
||||
if (!invokeName) {
|
||||
continue;
|
||||
}
|
||||
const invokeBodyStart = openMatch.index + openMatch[0].length;
|
||||
const invokeClose = findEarliestStringToken(body.slice(invokeBodyStart), [
|
||||
"</|DSML|invoke>",
|
||||
"</|DSML|invoke>",
|
||||
]);
|
||||
if (!invokeClose) {
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
const invokeBody = body.slice(invokeBodyStart, invokeBodyStart + invokeClose.index);
|
||||
invokeOpenRegex.lastIndex = invokeBodyStart + invokeClose.index + invokeClose.token.length;
|
||||
const invokeName = parseXmlAttribute(openMatch[1] ?? "", "name");
|
||||
if (!invokeName) {
|
||||
continue;
|
||||
}
|
||||
const parsedArguments = parseDeepSeekDsmlInvokeArguments(invokeBody);
|
||||
if (!parsedArguments) {
|
||||
continue;
|
||||
@@ -1043,10 +1133,10 @@ function decodeDeepSeekDsmlText(value: string): string {
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
function findEarliestStringToken(text: string, tokens: readonly string[]) {
|
||||
function findEarliestStringToken(text: string, tokens: readonly string[], fromIndex = 0) {
|
||||
let best: { index: number; token: string } | null = null;
|
||||
for (const token of tokens) {
|
||||
const index = text.indexOf(token);
|
||||
const index = text.indexOf(token, fromIndex);
|
||||
if (index !== -1 && (!best || index < best.index)) {
|
||||
best = { index, token };
|
||||
}
|
||||
@@ -1054,6 +1144,108 @@ function findEarliestStringToken(text: string, tokens: readonly string[]) {
|
||||
return best;
|
||||
}
|
||||
|
||||
function scanDeepSeekDsmlToolBlock(
|
||||
text: string,
|
||||
closeToken: string,
|
||||
contentStartIndex: number,
|
||||
state: DeepSeekDsmlToolBlockScanState,
|
||||
):
|
||||
| { kind: "close"; index: number; token: string }
|
||||
| { kind: "nested-open"; index: number; token: string }
|
||||
| { kind: "incomplete" } {
|
||||
while (state.offset < text.length) {
|
||||
if (state.mode === "invoke-open") {
|
||||
const nextOpen = text.indexOf("<", state.offset);
|
||||
const nextClose = text.indexOf(">", state.offset);
|
||||
if (nextClose === -1 && nextOpen === -1) {
|
||||
state.offset = text.length;
|
||||
return { kind: "incomplete" };
|
||||
}
|
||||
if (nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose)) {
|
||||
state.mode = "outer";
|
||||
state.offset = nextOpen;
|
||||
state.invokeOpenStart = -1;
|
||||
continue;
|
||||
}
|
||||
const invokeOpenTag = text.slice(state.invokeOpenStart, nextClose + 1);
|
||||
if (!/^<[||]DSML[||]invoke\b[^<>]*>$/.test(invokeOpenTag)) {
|
||||
state.mode = "outer";
|
||||
state.offset = state.invokeOpenStart + 1;
|
||||
state.invokeOpenStart = -1;
|
||||
continue;
|
||||
}
|
||||
state.mode = "invoke-body";
|
||||
state.offset = nextClose + 1;
|
||||
state.invokeOpenStart = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.mode === "invoke-body") {
|
||||
const invokeClose = findEarliestStringToken(
|
||||
text,
|
||||
DEEPSEEK_DSML_INVOKE_CLOSE_TOKENS,
|
||||
state.offset,
|
||||
);
|
||||
if (!invokeClose) {
|
||||
state.offset = Math.max(0, text.length - DEEPSEEK_DSML_RECOVERY_MAX_BOUNDARY_LEN + 1);
|
||||
return { kind: "incomplete" };
|
||||
}
|
||||
state.mode = "outer";
|
||||
state.offset = invokeClose.index + invokeClose.token.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolOpen = findEarliestStringToken(text, DEEPSEEK_DSML_TOOL_OPEN_TOKENS, state.offset);
|
||||
const toolCloseIndex = text.indexOf(closeToken, state.offset);
|
||||
const invokeOpen = findEarliestStringToken(
|
||||
text,
|
||||
DEEPSEEK_DSML_INVOKE_OPEN_PREFIXES,
|
||||
state.offset,
|
||||
);
|
||||
const next = [
|
||||
toolOpen ? { kind: "nested-open" as const, ...toolOpen } : null,
|
||||
toolCloseIndex === -1
|
||||
? null
|
||||
: { kind: "close" as const, index: toolCloseIndex, token: closeToken },
|
||||
invokeOpen ? { kind: "invoke-open" as const, ...invokeOpen } : null,
|
||||
]
|
||||
.filter((candidate) => candidate !== null)
|
||||
.toSorted((left, right) => left.index - right.index)[0];
|
||||
if (!next) {
|
||||
state.offset = Math.max(
|
||||
contentStartIndex,
|
||||
text.length - DEEPSEEK_DSML_RECOVERY_MAX_BOUNDARY_LEN + 1,
|
||||
);
|
||||
return { kind: "incomplete" };
|
||||
}
|
||||
if (next.kind === "invoke-open") {
|
||||
state.mode = "invoke-open";
|
||||
state.invokeOpenStart = next.index;
|
||||
state.offset = next.index + next.token.length;
|
||||
continue;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
return { kind: "incomplete" };
|
||||
}
|
||||
|
||||
function utf8ByteLengthForAppend(bufferEndsWithHighSurrogate: boolean, chunk: string) {
|
||||
let bytes = Buffer.byteLength(chunk, "utf8");
|
||||
if (!chunk) {
|
||||
return { bytes, endsWithHighSurrogate: bufferEndsWithHighSurrogate };
|
||||
}
|
||||
const nextCodeUnit = chunk.charCodeAt(0);
|
||||
if (bufferEndsWithHighSurrogate && nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) {
|
||||
// Each isolated surrogate counts as three UTF-8 bytes; the joined scalar is four.
|
||||
bytes -= 2;
|
||||
}
|
||||
const finalCodeUnit = chunk.charCodeAt(chunk.length - 1);
|
||||
return {
|
||||
bytes,
|
||||
endsWithHighSurrogate: finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff,
|
||||
};
|
||||
}
|
||||
|
||||
function longestDeepSeekDsmlToolOpenPrefixSuffixLength(text: string) {
|
||||
const maxLength = Math.min(text.length, DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN - 1);
|
||||
for (let length = maxLength; length > 0; length -= 1) {
|
||||
|
||||
@@ -275,6 +275,402 @@ describe("openai transport stream", () => {
|
||||
expect(JSON.stringify(events)).not.toContain("DSML");
|
||||
});
|
||||
|
||||
it("rejects an oversized DeepSeek DSML block when the crossing chunk contains its close", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const events: CapturedStreamEvent[] = [];
|
||||
|
||||
const prefix = '<|DSML|tool_calls><|DSML|invoke name="read">{"path":"';
|
||||
const suffix = '"}</|DSML|invoke></|DSML|tool_calls>';
|
||||
const padding = "x".repeat(256_001 - Buffer.byteLength(prefix + suffix, "utf8"));
|
||||
const content = prefix + padding + suffix + " after";
|
||||
expect(Buffer.byteLength(prefix + padding + suffix, "utf8")).toBe(256_001);
|
||||
const chunks = Array.from({ length: Math.ceil(content.length / 4096) }, (_, index) =>
|
||||
content.slice(index * 4096, (index + 1) * 4096),
|
||||
);
|
||||
|
||||
await expect(
|
||||
testing.processOpenAICompletionsStream(
|
||||
streamChunks(
|
||||
chunks.map((contentChunk, index) =>
|
||||
makeCompletionsChunk(
|
||||
{ content: contentChunk },
|
||||
index === chunks.length - 1 ? "stop" : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
output,
|
||||
model,
|
||||
{ push: (event) => events.push(event as CapturedStreamEvent) },
|
||||
),
|
||||
).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit");
|
||||
expect(events.filter((event) => event.type?.startsWith("toolcall_"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an oversized DeepSeek DSML recovery buffer using UTF-8 bytes", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
|
||||
// 200k "é": .length under 256k string units, UTF-8 bytes over 256k.
|
||||
const multibyteBody =
|
||||
'<|DSML|invoke name="session_status"><|DSML|parameter name="key" string="true">' +
|
||||
"\u00E9".repeat(200_000) +
|
||||
"</|DSML|parameter></|DSML|invoke>";
|
||||
|
||||
await expect(
|
||||
testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk(
|
||||
{
|
||||
content: "<|DSML|tool_calls>" + multibyteBody,
|
||||
},
|
||||
"stop",
|
||||
),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
),
|
||||
).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit");
|
||||
});
|
||||
|
||||
it("counts split surrogate pairs exactly at the DeepSeek DSML recovery cap", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const prefix = '<|DSML|tool_calls><|DSML|invoke name="read">{"path":"';
|
||||
const suffix = '"}</|DSML|invoke>';
|
||||
const outerClose = "</|DSML|tool_calls>";
|
||||
const emoji = "\u{1f600}";
|
||||
const padding = "x".repeat(
|
||||
256_000 - Buffer.byteLength(prefix + emoji + suffix + outerClose, "utf8"),
|
||||
);
|
||||
const beforeSplit = prefix + padding + "\uD83D";
|
||||
const afterSplit = "\uDE00" + suffix;
|
||||
expect(Buffer.byteLength(beforeSplit + afterSplit + outerClose, "utf8")).toBe(256_000);
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk({ content: beforeSplit }),
|
||||
makeCompletionsChunk({ content: afterSplit }),
|
||||
makeCompletionsChunk({ content: outerClose }, "stop"),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: expect.stringMatching(/^call_[0-9a-f]{24}$/),
|
||||
name: "read",
|
||||
arguments: { path: padding + emoji },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces an oversized DeepSeek DSML recovery buffer as a transport error", async () => {
|
||||
const server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
for (const chunk of [
|
||||
makeCompletionsChunk({
|
||||
content: "<|DSML|tool_calls>" + "x".repeat(300_000),
|
||||
}),
|
||||
makeCompletionsChunk({}, "stop"),
|
||||
]) {
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
res.end("data: [DONE]\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Missing loopback server address");
|
||||
}
|
||||
const model = makeCompletionsModel({
|
||||
...createDeepSeekCompletionsModel(),
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
});
|
||||
const stream = createOpenAICompletionsTransportStreamFn()(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [{ role: "user", content: "Read the file", timestamp: Date.now() }],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ apiKey: "test-key" } as never,
|
||||
);
|
||||
|
||||
const events: Array<{
|
||||
type: string;
|
||||
reason?: string;
|
||||
error?: { errorMessage?: string; content?: unknown[] };
|
||||
}> = [];
|
||||
for await (const event of stream as AsyncIterable<(typeof events)[number]>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: expect.objectContaining({
|
||||
errorMessage: "Exceeded DeepSeek DSML recovery buffer limit",
|
||||
content: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events.filter((event) => event.type === "toolcall_start")).toEqual([]);
|
||||
expect(events.filter((event) => event.type === "toolcall_delta")).toEqual([]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails before a later DSML call after overflow can be authorized", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const laterCall =
|
||||
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/repro.md"}</|DSML|invoke></|DSML|tool_calls>';
|
||||
|
||||
await expect(
|
||||
testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk({
|
||||
content: "<|DSML|tool_calls>" + "x".repeat(256_001),
|
||||
}),
|
||||
makeCompletionsChunk({
|
||||
content: "</|DSML|function_calls> after " + laterCall,
|
||||
}),
|
||||
makeCompletionsChunk({}, "tool_calls"),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
),
|
||||
).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit");
|
||||
});
|
||||
|
||||
it("does not carry surrogate accounting across emitted visible text", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
|
||||
await expect(
|
||||
testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk({ content: "\ud83d" }),
|
||||
makeCompletionsChunk({ content: "\ude00" }),
|
||||
makeCompletionsChunk({
|
||||
content: "<|DSML|tool_calls>" + "x".repeat(256_001),
|
||||
}),
|
||||
makeCompletionsChunk({}, "stop"),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
),
|
||||
).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit");
|
||||
});
|
||||
|
||||
it("rejects a nested DSML wrapper before the original outer close", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const events: CapturedStreamEvent[] = [];
|
||||
|
||||
await expect(
|
||||
testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk({ content: "<|DSML|tool_calls><|DSML|tool_" }),
|
||||
makeCompletionsChunk({
|
||||
content:
|
||||
'calls><|DSML|invoke name="read">{"path":"/tmp/nested.md"}</|DSML|invoke></|DSML|tool_calls>',
|
||||
}),
|
||||
makeCompletionsChunk({ content: "</|DSML|tool_calls>" }, "stop"),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{
|
||||
push(event) {
|
||||
events.push(event as CapturedStreamEvent);
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Nested DeepSeek DSML recovery wrappers are not supported");
|
||||
expect(events.filter((event) => event.type?.startsWith("toolcall_") === true)).toEqual([]);
|
||||
expect(output.content.some((part) => part.type === "toolCall")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not accept a different DSML wrapper kind as the outer close", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk({
|
||||
content:
|
||||
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/mismatch.md"}</|DSML|invoke></|DSML|function_calls>',
|
||||
}),
|
||||
makeCompletionsChunk({}, "stop"),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("stop");
|
||||
expect(output.content.some((part) => part.type === "toolCall")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not rewind across the outer opener after a short first body chunk", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
makeCompletionsChunk({ content: "<|DSML|tool_calls>\n" }),
|
||||
makeCompletionsChunk({
|
||||
content:
|
||||
'<|DSML|invoke name="read">{"path":"/tmp/fragmented.md"}</|DSML|invoke></|DSML|tool_calls>',
|
||||
}),
|
||||
makeCompletionsChunk({}, "stop"),
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: expect.stringMatching(/^call_[0-9a-f]{24}$/),
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/fragmented.md" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats DSML-looking text inside a parameter value as payload", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const content =
|
||||
'<|DSML|tool_calls><|DSML|invoke name="message">' +
|
||||
'<|DSML|parameter name="text" string="true">' +
|
||||
"literal <|DSML|tool_calls> marker" +
|
||||
"</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>";
|
||||
|
||||
const chunks = Array.from(content, (char) => makeCompletionsChunk({ content: char }));
|
||||
chunks.push(makeCompletionsChunk({}, "stop"));
|
||||
await testing.processOpenAICompletionsStream(streamChunks(chunks), output, model, {
|
||||
push() {},
|
||||
});
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: expect.stringMatching(/^call_[0-9a-f]{24}$/),
|
||||
name: "message",
|
||||
arguments: { text: "literal <|DSML|tool_calls> marker" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores an incomplete invoke-like prefix before a valid invoke", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const content =
|
||||
"<|DSML|tool_calls>literal <|DSML|invoke marker " +
|
||||
'<|DSML|invoke name="read">{"path":"/tmp/valid.md"}</|DSML|invoke>' +
|
||||
"</|DSML|tool_calls>";
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks(
|
||||
Array.from(content, (char) => makeCompletionsChunk({ content: char })).concat([
|
||||
makeCompletionsChunk({}, "stop"),
|
||||
]),
|
||||
),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: expect.stringMatching(/^call_[0-9a-f]{24}$/),
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/valid.md" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not recover a nested call hidden inside a nameless invoke", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const events: CapturedStreamEvent[] = [];
|
||||
const content =
|
||||
"<|DSML|tool_calls><|DSML|invoke><|DSML|tool_calls>" +
|
||||
'<|DSML|invoke name="read">{"path":"/tmp/bypass"}</|DSML|invoke>' +
|
||||
"</|DSML|tool_calls>";
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([makeCompletionsChunk({ content }), makeCompletionsChunk({}, "stop")]),
|
||||
output,
|
||||
model,
|
||||
{
|
||||
push(event) {
|
||||
events.push(event as CapturedStreamEvent);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("stop");
|
||||
expect(events.filter((event) => event.type?.startsWith("toolcall_") === true)).toEqual([]);
|
||||
expect(output.content.some((part) => part.type === "toolCall")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats DSML-looking text inside JSON arguments as payload", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
const content =
|
||||
'<|DSML|tool_calls><|DSML|invoke name="message">' +
|
||||
'{"text":"literal <|DSML|tool_calls> marker"}' +
|
||||
"</|DSML|invoke></|DSML|tool_calls>";
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([makeCompletionsChunk({ content }, "stop")]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: expect.stringMatching(/^call_[0-9a-f]{24}$/),
|
||||
name: "message",
|
||||
arguments: { text: "literal <|DSML|tool_calls> marker" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ finishReason: "length", stopReason: "length" },
|
||||
{ finishReason: "content_filter", stopReason: "error" },
|
||||
|
||||
Reference in New Issue
Block a user