mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
c01f687e5b
commit
8930b9ec0b
3 changed files with 47 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "" };
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue