fix(ai): ignore empty Mistral content deltas

GLM models on Mistral send empty content deltas at the start of a
response, alongside tool call fragments, and sometimes mid-thinking.
Each one opened an empty text block, and a mid-thinking one split
thinking into two blocks, which Mistral rejects on replay with
"Expected at most one leading ThinkChunk".

closes #9674
This commit is contained in:
David Brailovsky 2026-09-25 16:00:51 +02:00
parent c01f687e5b
commit 8930b9ec0b
3 changed files with 47 additions and 0 deletions

View file

@ -24,6 +24,7 @@
- Fixed 1-hour Anthropic cache writes reported by Vercel AI Gateway in streaming deltas being priced at the 5-minute rate ([#9210](https://github.com/earendil-works/pi/issues/9210)).
- Fixed model-level `samplingParams` being dropped by direct `stream()`/`complete()` calls on OpenAI-compatible APIs ([#9506](https://github.com/earendil-works/pi/issues/9506)).
- Fixed Mistral GLM models producing empty text blocks and split thinking blocks from empty content deltas, which could make later requests fail with "Expected at most one leading ThinkChunk" ([#9674](https://github.com/earendil-works/pi/issues/9674)).
## [0.87.1] - 2026-09-22

View file

@ -626,6 +626,9 @@ async function consumeChatStream(
for (const item of contentItems) {
if (typeof item === "string") {
const textDelta = sanitizeSurrogates(item);
// GLM models on Mistral send empty content deltas around thinking and tool calls.
// Opening a block for them splits thinking into multiple blocks, which Mistral rejects on replay.
if (!textDelta) continue;
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
@ -667,6 +670,7 @@ async function consumeChatStream(
if (item.type === "text") {
const textDelta = sanitizeSurrogates(item.text ?? "");
if (!textDelta) continue;
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };

View file

@ -322,6 +322,48 @@ describe("Mistral HTTP transport", () => {
expect(message.usage).toMatchObject({ input: 7, output: 4, cacheRead: 3, cacheWrite: 0, totalTokens: 14 });
});
// #9674: GLM models on Mistral send empty content deltas at the start, around tool calls,
// and sometimes mid-thinking. They must not open blocks or split thinking.
it("ignores empty content deltas", async () => {
const model = getModel("mistral", "zai-glm-5-3");
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
});
const thinking = (text: string) => ({ type: "thinking", thinking: [{ type: "text", text }] });
const toolCall = (args: string, first: boolean) => ({
index: 0,
...(first ? { id: "abc123456" } : {}),
function: { name: first ? "read" : "", arguments: args },
});
const deltas = [
{ content: "" },
{ content: [thinking("first part,")] },
{ content: "" },
{ content: [{ type: "text", text: "" }] },
{ content: [thinking(" second part."), { type: "text", text: "Reading." }] },
{ content: "", tool_calls: [toolCall("", true)] },
{ content: "", tool_calls: [toolCall('{"path":', false)] },
{ content: "", tool_calls: [toolCall('"a.txt"}', false)] },
{ content: "" },
];
const events = deltas.map((delta, i) => ({
id: "response-1",
model: model.id,
choices: [{ index: 0, finish_reason: i === deltas.length - 1 ? "tool_calls" : null, delta }],
}));
const message = await streamMistral(model, context, {
apiKey: "test",
fetch: async () => createSseResponse(events),
}).result();
expect(message.content).toEqual([
{ type: "thinking", thinking: "first part, second part." },
{ type: "text", text: "Reading." },
{ type: "toolCall", id: "abc123456", name: "read", arguments: { path: "a.txt" } },
]);
});
it("forwards each parsed SSE payload before normalizing it", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context = normalizeContext({