diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a97d4b57a..2924da611 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index 899ac3cbf..154030e63 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -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: "" }; diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts index 83c91c8fc..9356e1dd9 100644 --- a/packages/ai/test/mistral-http-transport.test.ts +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -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({