mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-11 07:45:50 +00:00
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:
@@ -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: () => ({
|
||||
|
||||
Reference in New Issue
Block a user