diff --git a/.changeset/mistral-model-length-finish-reason.md b/.changeset/mistral-model-length-finish-reason.md new file mode 100644 index 0000000000..a96b0445ff --- /dev/null +++ b/.changeset/mistral-model-length-finish-reason.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mistral': patch +--- + +A Mistral response that stops with `finish_reason` `model_length` (the model's context window is exhausted) now reports `finishReason: 'length'` on `RUN_FINISHED` and `structuredOutput` reports it as a context-window truncation (shorten the prompt or use a larger-context model, not raise `max_tokens`). Before, a streamed `model_length` stop was reported as a normal `stop`, and `structuredOutput` surfaced a JSON parse error instead of the truncation error. diff --git a/packages/ai-mistral/src/adapters/text.ts b/packages/ai-mistral/src/adapters/text.ts index 8d7699dd6c..2c6377db9e 100644 --- a/packages/ai-mistral/src/adapters/text.ts +++ b/packages/ai-mistral/src/adapters/text.ts @@ -497,7 +497,16 @@ export class MistralTextAdapter< // A response cut off at the output cap is a truncated JSON document; // report it before the parse error (issue #1426). The errors carry the // usage, because the provider already billed the tokens. - if (response.choices[0]?.finishReason === 'length') { + const structuredFinishReason = response.choices[0]?.finishReason + if (structuredFinishReason === 'model_length') { + throw Object.assign( + new Error( + 'mistral.structuredOutput: the response was cut off because the model reached the end of its context window (finish_reason=model_length); shorten the prompt or use a model with a larger context window', + ), + usage && { usage }, + ) + } + if (structuredFinishReason === 'length') { throw Object.assign( new Error( 'mistral.structuredOutput: the response was cut off because the maximum token limit was reached (finish_reason=length); raise modelOptions.max_tokens', @@ -813,7 +822,7 @@ export class MistralTextAdapter< const computedFinishReason = finishReason === 'tool_calls' || hasEmittedToolCall ? 'tool_calls' - : finishReason === 'length' + : finishReason === 'length' || finishReason === 'model_length' ? 'length' : 'stop' diff --git a/packages/ai-mistral/tests/mistral-adapter.test.ts b/packages/ai-mistral/tests/mistral-adapter.test.ts index d188481a8c..a884e44751 100644 --- a/packages/ai-mistral/tests/mistral-adapter.test.ts +++ b/packages/ai-mistral/tests/mistral-adapter.test.ts @@ -228,6 +228,28 @@ describe('Mistral adapters', () => { ) }) + it("reports a 'model_length' stop as truncation in structured output", async () => { + mockComplete.mockReset().mockResolvedValue({ + choices: [ + { message: { content: '{"value":' }, finishReason: 'model_length' }, + ], + }) + const adapter = createMistralText('mistral-large-latest', 'test-api-key') + + await expect( + adapter.structuredOutput({ + chatOptions: chatOpts({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Return structured output' }], + }), + outputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }), + ).rejects.toThrow(/end of its context window.*finish_reason=model_length/) + }) + it('reports a length stop as truncation in structured output', async () => { mockComplete.mockReset().mockResolvedValue({ choices: [ @@ -476,6 +498,37 @@ describe('Mistral AG-UI event emission', () => { } }) + it("reports Mistral's 'model_length' stop as a length finish", async () => { + setupMockStream([ + { + id: 'cmpl-123', + model: 'mistral-large-latest', + choices: [ + { index: 0, delta: { content: 'Hello' }, finishReason: null }, + ], + }, + { + id: 'cmpl-123', + model: 'mistral-large-latest', + choices: [{ index: 0, delta: {}, finishReason: 'model_length' }], + }, + ]) + const adapter = createMistralText('mistral-large-latest', 'test-api-key') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream( + chatOpts({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Hello' }], + }), + )) { + chunks.push(chunk) + } + + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinishedChunk).toMatchObject({ finishReason: 'length' }) + }) + it('emits AG-UI tool call events', async () => { const streamChunks = [ {