fix(ai): flatten Mistral reasoning models' chunked content

`magistral-*` returns `message.content` as a ContentChunk[] rather than a
string, with the thinking text nested one level deeper inside `thinking`
chunks. The camelCase remap did not touch it, so a non-streamed magistral
response reached the caller as an array with no `reasoning` — the one case
left where a provider did not produce the equalized shape this branch
promises. Streaming had the matching bug: the chunk array was handed to
addText, which would have stringified it into the text stream.

Both paths now split chunked content into a string `content` plus a
`reasoning` string, joining multiple thinking chunks with a blank line as the
Responses handler and the Anthropic coercer do. The streaming fix rides the
existing Mistral-only `chunk_but_like_actually` hook, so no new deviation is
introduced.

The conformance matrix had no Mistral reasoning fixture, which is why it
missed this; it now has one carrying chunked content, verified to fail
without the flattening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
404oops
2026-08-28 01:08:48 +02:00
co-authored by Claude Opus 5
parent 3b8de6a90b
commit 74640d4e83
3 changed files with 245 additions and 2 deletions
@@ -459,6 +459,75 @@ describe('MistralAIProvider.complete non-stream output', () => {
});
});
it('flattens a magistral chunked content array into string content + reasoning', async () => {
// Mistral's reasoning models return `content` as a chunk array with
// the thinking text nested inside `thinking` chunks. Left alone it
// reaches the caller as an array with no `reasoning`, breaking the
// one-shape-per-provider guarantee.
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce({
choices: [
{
message: {
role: 'assistant',
content: [
{
type: 'thinking',
thinking: [
{ type: 'text', text: 'step one.' },
],
},
{
type: 'thinking',
thinking: [
{ type: 'text', text: 'step two.' },
],
},
{ type: 'text', text: 'the answer' },
],
},
finishReason: 'stop',
},
],
usage: { promptTokens: 1, completionTokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'magistral-small-latest',
messages: [{ role: 'user', content: 'think' }],
}),
)) as { message: Record<string, unknown> };
expect(result.message.content).toBe('the answer');
// Multiple thinking chunks join with a blank line, matching the
// Responses handler and the Anthropic coercer.
expect(result.message.reasoning).toBe('step one.\n\nstep two.');
});
it('leaves plain string content untouched', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce({
choices: [
{
message: { role: 'assistant', content: 'plain' },
finishReason: 'stop',
},
],
usage: { promptTokens: 1, completionTokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
}),
)) as { message: Record<string, unknown> };
expect(result.message.content).toBe('plain');
expect('reasoning' in result.message).toBe(false);
});
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce({
@@ -571,6 +640,78 @@ describe('MistralAIProvider.complete streaming', () => {
});
});
it('flattens chunked delta.content into text and reasoning events', async () => {
const { provider } = makeProvider();
streamMock.mockReturnValueOnce(
asAsyncIterable([
{
data: {
choices: [
{
delta: {
content: [
{
type: 'thinking',
thinking: [
{
type: 'text',
text: 'thinking…',
},
],
},
],
},
},
],
},
},
{
data: {
choices: [
{
delta: {
content: [
{ type: 'text', text: 'answer' },
],
},
},
],
},
},
{
data: {
choices: [{ delta: {} }],
usage: { promptTokens: 1, completionTokens: 1 },
},
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'magistral-small-latest',
messages: [{ role: 'user', content: 'think' }],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
// The thinking chunk becomes a reasoning event, not stringified text.
expect(
events.filter((e) => e.type === 'reasoning').map((e) => e.reasoning),
).toEqual(['thinking…']);
expect(events.filter((e) => e.type === 'text').map((e) => e.text)).toEqual(
['answer'],
);
});
it('builds a tool_use block from camelCase delta.toolCalls deltas', async () => {
const { provider } = makeProvider();
streamMock.mockReturnValueOnce(
@@ -30,6 +30,51 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { MISTRAL_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
/**
* Mistral's reasoning models (`magistral-*`) return `content` as a chunk array
* rather than a string, with the thinking text nested one level deeper inside
* `thinking` chunks. Split it into the string content + `reasoning` string
* every other provider produces. Text nested in a chunk is joined; a `thinking`
* chunk's own chunks are flattened the same way.
*/
const flattenChunkText = (value: unknown): string => {
if (typeof value === 'string') return value;
if (!Array.isArray(value)) return '';
return value
.map((chunk) => {
if (typeof chunk === 'string') return chunk;
const c = chunk as Record<string, unknown>;
return typeof c?.text === 'string' ? c.text : '';
})
.join('');
};
const splitMistralContentChunks = (
content: unknown[],
): { text: string; reasoning: string } => {
const textParts: string[] = [];
const reasoningParts: string[] = [];
for (const chunk of content) {
if (typeof chunk === 'string') {
textParts.push(chunk);
continue;
}
const c = chunk as Record<string, unknown>;
if (c?.type === 'thinking') {
const thinking = flattenChunkText(c.thinking);
if (thinking) reasoningParts.push(thinking);
continue;
}
// Non-text chunks (`reference`, images) carry nothing to surface as
// message content and are dropped, same as the Anthropic coercer.
if (typeof c?.text === 'string') textParts.push(c.text);
}
return {
text: textParts.join(''),
reasoning: reasoningParts.join('\n\n'),
};
};
// Mistral's finish reasons mapped to the OpenAI vocabulary; values without
// an OpenAI analog (e.g. `error`) pass through unmapped.
const MISTRAL_FINISH_REASON_MAP: Record<string, string> = {
@@ -179,6 +224,17 @@ export class MistralAIProvider implements IChatProvider {
}));
}
if (message) delete message.toolCalls;
if (message && Array.isArray(message.content)) {
const { text, reasoning } = splitMistralContentChunks(
message.content,
);
// Null content alongside tool calls is OpenAI's own
// convention for a tool-only turn.
message.content = text === '' ? null : text;
if (reasoning && message.reasoning === undefined) {
message.reasoning = reasoning;
}
}
}
}
@@ -199,8 +255,32 @@ export class MistralAIProvider implements IChatProvider {
return snake_usage;
},
chunk_but_like_actually: (chunk: unknown) =>
(chunk as any).data,
// Mistral wraps each event; unwrap it, then flatten a
// reasoning model's chunked `delta.content` so the shared
// stream handler sees the string content + `reasoning` delta
// it expects from every other provider.
chunk_but_like_actually: (chunk: unknown) => {
const data = (chunk as { data?: unknown }).data as
| {
choices?: {
delta?: Record<string, unknown>;
}[];
}
| undefined;
if (!data || !Array.isArray(data.choices)) return data;
for (const choice of data.choices) {
const delta = choice?.delta;
if (!delta || !Array.isArray(delta.content)) continue;
const { text, reasoning } = splitMistralContentChunks(
delta.content,
);
delta.content = text;
if (reasoning && delta.reasoning === undefined) {
delta.reasoning = reasoning;
}
}
return data;
},
index_tool_calls_from_stream_choice: (choice: {
delta?: unknown;
}) => (choice.delta as any).toolCalls,
@@ -484,6 +484,28 @@ const fixtures: Record<
],
usage: { promptTokens: 3, completionTokens: 5 },
}),
// Mistral's reasoning models (magistral) return `content` as a chunk
// array, with the thinking text nested one level deeper inside
// `thinking` chunks. Without the provider's flattening this reaches
// the caller as an array with no `reasoning` at all.
reasoning: () => ({
choices: [
{
message: {
role: 'assistant',
content: [
{
type: 'thinking',
thinking: [{ type: 'text', text: REASONING }],
},
{ type: 'text', text: TEXT },
],
},
finishReason: 'stop',
},
],
usage: { promptTokens: 3, completionTokens: 5 },
}),
},
anthropic: {
text: () => ({