Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 64 additions & 13 deletions src/adapters/openai-chat/serialized-tool-call-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,69 @@ export interface StructuredToolCallReference {
argumentsText: string;
}

const BLOCK_HEADER = /<tool_call>\s*<function=([^>\r\n]+)>/y;
/** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */
const NEXT_BLOCK_HEADER = /\n<tool_call>\s*<function=[^>\r\n]+>/g;
const FUNCTION_CLOSE = "</function>";
const PARAMETER_CLOSE = "</parameter>";

function trimmedEnd(text: string, from: number, to: number): number {
while (to > from && /\s/.test(text[to - 1]!)) to--;
return to;
}

function endsWithAt(text: string, from: number, to: number, suffix: string): boolean {
return to - suffix.length >= from && text.startsWith(suffix, to - suffix.length);
}

/**
* The block starting at `offset`, read by delimiter scan so an unterminated block costs linear time.
* MiMo's echo may close a freeform body with a stray `</parameter>` and may omit `</function>`
* (#5724), the grammar the Command Code reader accepts too. The first `</tool_call>` preceded by
* `</function>` closes the block, so a body can still carry a literal `</tool_call>` or header;
* with none before the next line-start block header, the first `</tool_call>` does. That header only
* bounds an unclosed candidate: with no close at all before it, it is body text, and a closed
* `</function></tool_call>` after it still ends the block.
*/
function blockAt(text: string, offset: number): SerializedToolCall | undefined {
BLOCK_HEADER.lastIndex = offset;
const header = BLOCK_HEADER.exec(text);
if (!header) return undefined;
const bodyStart = offset + header[0].length;
NEXT_BLOCK_HEADER.lastIndex = bodyStart;
const next = NEXT_BLOCK_HEADER.exec(text);
const limit = next ? next.index + 1 : text.length;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let unclosed: SerializedToolCall | undefined;
for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed);
close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) {
let bodyEnd = trimmedEnd(text, bodyStart, close);
const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE);
if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length);
if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length;
const call = {
name: header[1]!.trim(),
body: text.slice(bodyStart, bodyEnd),
start: offset,
end: close + CLOSE_TAG.length,
};
if (closed) return call;
if (close < limit) unclosed ??= call;
}
return unclosed;
}

/** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */
function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] {
const pattern = /<tool_call>\s*<function=([^>\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y;
const calls: SerializedToolCall[] = [];
let offset = 0;
while (offset < text.length) {
const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true);
offset += split.emit.length;
if (!split.hasOpenTag) break;
pattern.lastIndex = offset;
const match = pattern.exec(text);
const match = blockAt(text, offset);
if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call.
calls.push({
name: match[1]!.trim(),
body: match[2]!,
start: match.index,
end: match.index + match[0].length,
});
offset = pattern.lastIndex;
calls.push(match);
offset = match.end;
context = { fence: null, lineStart: false };
}
return calls;
Expand Down Expand Up @@ -279,6 +323,11 @@ function inputFromArguments(argumentsText: string): string | undefined {
}
}

/** One wrapping newline after the function header is template layout, not input (vLLM `_trim_wrapping_newlines`). */
function freeformBody(value: string): string {
return value.replace(/^\r?\n/, "").trimEnd();
}

/** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */
function duplicatedSerializedToolCallRanges(
text: string,
Expand All @@ -287,9 +336,11 @@ function duplicatedSerializedToolCallRanges(
): { start: number; end: number }[] {
if (structuredCalls.length === 0) return [];
return callsIn(text, context).filter(call => {
const body = call.body.trimEnd();
return structuredCalls.some(structured =>
structured.names.has(call.name) && inputFromArguments(structured.argumentsText)?.trimEnd() === body);
const body = freeformBody(call.body);
return structuredCalls.some(structured => {
const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText) : undefined;
return input !== undefined && freeformBody(input) === body;
});
});
}

Expand Down
13 changes: 13 additions & 0 deletions structure/decisions/ADR-5724-serialized-tool-call-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ADR-5724 — decision recorded under "Serialized tool-call content"

- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#serialized-tool-call-content)

## Decision record

- Intent: Remove MiMo's duplicated tool-call echo on OpenAI Chat routes when it arrives in the shapes the model actually emits, not only the canonical one (#5724).
- Prior constraint: ADR-5548 suppresses a block only when its function name and freeform body agree with a structured call in the same response; mismatched markup stays byte-exact.
- Observed shapes: MiMo's echo can omit `</function>` (`<tool_call><function=exec>BODY</parameter></tool_call>`) and can put a template newline after the function header. The Command Code reader already accepts both (#5637); the Chat reconciler did not, so those echoes stayed visible beside the call that ran.
- Alternatives considered: Keep two regular expressions and try the closed form first (backtracks quadratically on a long unterminated body, and rejecting a closed match on any inner `<tool_call>` hides a body that merely contains that string); drop anything shaped like a tool call (discards real text); restore calls from markup when no structured call exists (a new behaviour this route has no evidence for).
- Choice: Read each block by delimiter scan. The first `</tool_call>` preceded by `</function>` closes the block; if none appears before the next block header at the start of a line (`<tool_call>` followed by `<function=`, where a separate bare block can begin), the first `</tool_call>` does. A stray `</parameter>` before the close is markup, and one leading newline in the body is template layout.
- Why: It accepts the same grammar on both MiMo routes, keeps a body that contains literal tool-call tags (even a full header) intact, and costs linear time. The agreement rule from ADR-5548 is unchanged, so no new text can disappear without a matching structured call.
- Consequences: The two echo shapes are removed when they duplicate a structured call, streamed and buffered. Markup with no structured call is still shown and still runs nothing.
9 changes: 8 additions & 1 deletion structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,13 @@ entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bar
start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged.
It holds a possible serialized block, resumes ordinary text delivery when the header cannot match,
and removes the block only when its function name and
freeform body match a structured call's parsed `input` in the same response. If the gateway also prefixes the structured call's JSON
freeform body match a structured call's parsed `input` in the same response.
A block may close a freeform body with a stray `</parameter>` and may omit `</function>`, and one
newline after the function header is template layout, so MiMo's echoes of those shapes match too
(#5724). Blocks are read by delimiter scan in linear time: the first `</tool_call>` preceded by
`</function>` closes the block, and only when none appears before the next block header at the start
of a line does the first `</tool_call>` close it, so a body can still carry literal tool-call tags.
If the gateway also prefixes the structured call's JSON
arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body,
prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact.
Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures
Expand All @@ -339,6 +345,7 @@ matching and repair rules; regression coverage enters through `/v1/responses` in
`tests/responses/responses-chat-tool-call-content.test.ts`.

> Decision record: [ADR-5548](../decisions/ADR-5548-serialized-tool-call-content.md)
> Decision record: [ADR-5724](../decisions/ADR-5724-serialized-tool-call-content.md)

## Kimi Coding Plan prompt-cache affinity

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat";
import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content";
import { createTestTranslatorBudget } from "../../helpers/translator-budget";
import type { AdapterEvent } from "../../../src/types";
import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget";

const provider = { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "key" } as const;

Expand Down Expand Up @@ -61,3 +62,75 @@ test("an open serialized block charges only its appended bytes", () => {
expect(buffer.flush([])).toBe(open + body);
expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 });
});
describe("MiMo echo variants (#5724)", () => {
const script = 'const r = await tools.exec_command({cmd:"Get-Content a.txt"}); text(r.output);';
const call = (input: string) => ({ index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input }) } });

async function streamed(content: string, input: string): Promise<AdapterEvent[]> {
const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider));
adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } });
const frames = [
{ choices: [{ delta: { content: content.slice(0, 30) } }] },
{ choices: [{ delta: { content: content.slice(30) } }] },
{ choices: [{ delta: { tool_calls: [call(input)] } }] },
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
];
const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n";
const events: AdapterEvent[] = [];
for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event);
return events;
}
async function buffered(content: string, input: string): Promise<AdapterEvent[]> {
return createOpenAIChatAdapter(provider).parseResponse!(Response.json({
choices: [{ message: { content, tool_calls: [call(input)] }, finish_reason: "tool_calls" }],
}), createTestTranslatorBudget());
}
const visible = (events: AdapterEvent[]): string => events
.map(event => (event.type === "text_delta" ? event.text : ""))
.join("");

test.each([
["the header is followed by a template newline", `<tool_call><function=exec>\n${script}\n</parameter></function></tool_call>`],
["the echo omits </function>", `<tool_call><function=exec>${script}</parameter></tool_call>`],
])("a matching block is removed when %s", async (_label, block) => {
for (const events of [await streamed(`Reading.\n${block}`, script), await buffered(`Reading.\n${block}`, script)]) {
expect(visible(events)).toBe("Reading.\n");
expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1);
}
});

test("an unclosed block with a different body stays visible", async () => {
const block = "<tool_call><function=exec>text('other');</parameter></tool_call>";
for (const events of [await streamed(block, script), await buffered(block, script)]) {
expect(visible(events)).toBe(block);
}
});

test("a closed block whose body carries literal tool-call tags is still matched whole", async () => {
for (const input of [
"text('</tool_call>');",
"text('<tool_call>');",
'text("<tool_call><function=exec>");',
'const s = `\n<tool_call><function=exec>`;\ntext(s);',
]) {
const block = `<tool_call><function=exec>${input}</parameter></function></tool_call>`;
for (const events of [await streamed(block, input), await buffered(block, input)]) {
expect(visible(events)).toBe("");
}
}
});

test("an unclosed block followed by a closed block is read as two blocks", async () => {
const first = "text('a');";
const second = "text('b');";
const content = `<tool_call><function=exec>${first}</parameter></tool_call>\n<tool_call><function=exec>${second}</parameter></function></tool_call>`;
const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({
choices: [{
message: { content, tool_calls: [call(first), { ...call(second), index: 1, id: "call_exec_2" }] },
finish_reason: "tool_calls",
}],
}), createTestTranslatorBudget());
expect(visible(events)).toBe("\n");
expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(2);
});
});
Loading