Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/structured-output-truncation-other-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai-anthropic': patch
'@tanstack/ai-gemini': patch
'@tanstack/ai-mistral': patch
'@tanstack/ai-ollama': patch
'@tanstack/ai-bedrock': patch
---

Structured output now reports a truncation error when the response stops at the output token limit. Before, you got a JSON parse error, or the partial result came back as valid data. `openai-base` and `ai-openrouter` already do this (#1426).
8 changes: 8 additions & 0 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,14 @@ export class AnthropicTextAdapter<
},
)

// A forced tool call cut off at the output cap carries partial input,
// which would read like a schema failure (issue #1426).
if (response.stop_reason === 'max_tokens') {
throw new Error(
'anthropic.structuredOutput: the response was cut off because the maximum token limit was reached (stop_reason=max_tokens); raise modelOptions.max_tokens',
)
}

// Extract the tool use content from the response
let parsed: unknown = null
let rawText = ''
Expand Down
36 changes: 36 additions & 0 deletions packages/ai-anthropic/tests/anthropic-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AdapterYieldChunk,
type UIMessage,
} from '@tanstack/ai'
import { resolveDebugOption } from '@tanstack/ai/adapter-internals'
import { createAnthropicChatWithClient } from '../src'
import { AnthropicTextAdapter } from '../src/adapters/text'
import type { AnthropicTextProviderOptions } from '../src/adapters/text'
Expand Down Expand Up @@ -825,6 +826,41 @@ describe('Anthropic adapter option mapping', () => {
expect(payload.max_tokens).toBeLessThanOrEqual(21_333)
})

it('reports a max_tokens stop as truncation in structuredOutput', async () => {
// A forced tool call cut off at the cap arrives with partial input.
mocks.betaMessagesCreate.mockResolvedValueOnce({
id: 'msg_structured',
type: 'message',
role: 'assistant',
model: 'claude-opus-4-1',
content: [
{
type: 'tool_use',
id: 'toolu_structured_output',
name: 'structured_output',
input: {},
},
],
stop_reason: 'max_tokens',
usage: { input_tokens: 10, output_tokens: 100 },
})

await expect(
createAdapter('claude-opus-4-1').structuredOutput({
chatOptions: {
model: 'claude-opus-4-1',
messages: [{ role: 'user', content: 'recommend a guitar as json' }],
logger: resolveDebugOption(false),
},
outputSchema: {
type: 'object',
properties: { recommendation: { type: 'string' } },
required: ['recommendation'],
},
}),
).rejects.toThrow(/maximum token limit was reached/)
})

it('native combined mode (#605): wires outputSchema into output_format alongside tools on Claude 4.5+', async () => {
// Final-turn JSON the model emits when output_format is in play.
const finalJson = JSON.stringify({ city: 'Berlin', temp: 18 })
Expand Down
23 changes: 23 additions & 0 deletions packages/ai-bedrock/src/adapters/converse-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,13 @@ export class BedrockConverseTextAdapter<
toolConfig: buildStructuredToolConfig(outputSchema),
}
const res = await this.send(input)
// A forced tool call cut off at the output cap carries partial input,
// which would read like a schema failure (issue #1426).
if (res.stopReason === 'max_tokens') {
throw new Error(
`${this.name}.structuredOutput: the response was cut off because the maximum token limit was reached (stopReason=max_tokens); raise modelOptions.max_completion_tokens`,
)
}
const structured = extractStructuredToolInput(res)
if (structured === undefined) {
throw new Error(
Expand Down Expand Up @@ -439,6 +446,22 @@ export class BedrockConverseTextAdapter<
}
}

// Same truncation check as `structuredOutput()`: report the token limit
// before the empty-content and parse errors (issue #1426).
if (finishReason === 'length') {
const message = `${this.name}.structuredOutputStream: the response was cut off because the maximum token limit was reached (stopReason=max_tokens); raise modelOptions.max_completion_tokens`
yield {
type: EventType.RUN_ERROR,
runId,
model: chatOptions.model,
timestamp: Date.now(),
message,
code: 'max_tokens',
error: { message, code: 'max_tokens' },
}
return
}

if (accumulatedRaw.length === 0) {
yield {
type: EventType.RUN_ERROR,
Expand Down
55 changes: 55 additions & 0 deletions packages/ai-bedrock/tests/converse/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,61 @@ describe('BedrockConverseTextAdapter', () => {
).toBe('stop')
})

it('reports a max_tokens stop as truncation in structuredOutput', async () => {
const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0')
a.nonStreamOutput = {
output: {
message: {
role: 'assistant',
content: [
{
toolUse: { toolUseId: 's', name: 'structured_output', input: {} },
},
],
},
},
stopReason: 'max_tokens',
} as unknown as ConverseCommandOutput
await expect(
a.structuredOutput({
chatOptions: textOptions({
messages: [{ role: 'user', content: 'go' }],
}),
outputSchema: { type: 'object', properties: { n: { type: 'number' } } },
}),
).rejects.toThrow(/maximum token limit was reached/)
})

it('reports a max_tokens stop as truncation in structuredOutputStream', async () => {
const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0')
a.streamEvents = [
{ messageStart: { role: 'assistant' } },
{
contentBlockStart: {
start: { toolUse: { toolUseId: 's', name: 'structured_output' } },
contentBlockIndex: 0,
},
},
{
contentBlockDelta: {
delta: { toolUse: { input: '{"n":' } },
contentBlockIndex: 0,
},
},
{ contentBlockStop: { contentBlockIndex: 0 } },
{ messageStop: { stopReason: 'max_tokens' } },
]
const events: Array<AdapterYieldChunk> = []
for await (const c of a.structuredOutputStream({
chatOptions: textOptions({ messages: [{ role: 'user', content: 'go' }] }),
outputSchema: { type: 'object', properties: { n: { type: 'number' } } },
})) {
events.push(c)
}
const runError = events.find((e) => e.type === EventType.RUN_ERROR)
expect((runError as { code?: string } | undefined)?.code).toBe('max_tokens')
})

it('folds trailing metadata usage into the structuredOutputStream RUN_FINISHED (#1278)', async () => {
const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0')
a.streamEvents = [
Expand Down
8 changes: 8 additions & 0 deletions packages/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,14 @@ export class GeminiTextAdapter<
},
})

// A response cut off at the output cap is a truncated JSON document;
// report it before the parse error (issue #1426).
if (result.candidates?.[0]?.finishReason === FinishReason.MAX_TOKENS) {
throw new Error(
'gemini.structuredOutput: the response was cut off because the maximum token limit was reached (finishReason=MAX_TOKENS); raise modelOptions.maxOutputTokens',
)
}

// Extract text content from the response
const rawText = this.extractTextFromResponse(result)

Expand Down
22 changes: 22 additions & 0 deletions packages/ai-gemini/tests/gemini-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,28 @@ describe('GeminiAdapter through AI', () => {
expect(structuredCompleteEvent(events)).toBeDefined()
})

it('reports a MAX_TOKENS stop as truncation in structuredOutput', async () => {
mocks.generateContentSpy.mockResolvedValue({
candidates: [
{
content: { parts: [{ text: '{"city":' }] },
finishReason: 'MAX_TOKENS',
},
],
})

await expect(
createTextAdapter().structuredOutput({
chatOptions: {
model: 'gemini-2.5-pro',
messages: [{ role: 'user', content: 'Return a city' }],
logger: testLogger,
},
outputSchema: cityJsonSchema,
}),
).rejects.toThrow(/maximum token limit was reached/)
})

it('emits empty-response when the structured stream has no text', async () => {
mocks.generateContentStreamSpy.mockResolvedValue(
createStream([
Expand Down
8 changes: 8 additions & 0 deletions packages/ai-mistral/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,14 @@ export class MistralTextAdapter<
},
})

// A response cut off at the output cap is a truncated JSON document;
// report it before the parse error (issue #1426).
if (response.choices[0]?.finishReason === 'length') {
throw new Error(
'mistral.structuredOutput: the response was cut off because the maximum token limit was reached (finish_reason=length); raise modelOptions.max_tokens',
)
}

const rawText = response.choices[0]?.message?.content
const textContent = typeof rawText === 'string' ? rawText : ''

Expand Down
22 changes: 22 additions & 0 deletions packages/ai-mistral/tests/mistral-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,28 @@ describe('Mistral adapters', () => {
)
})

it('reports a length stop as truncation in structured output', async () => {
mockComplete.mockReset().mockResolvedValue({
choices: [
{ message: { content: '{"value":' }, finishReason: '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(/maximum token limit was reached/)
})

it('throws if MISTRAL_API_KEY is not set when using mistralText', () => {
vi.stubEnv('MISTRAL_API_KEY', '')

Expand Down
8 changes: 8 additions & 0 deletions packages/ai-ollama/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export class OllamaTextAdapter<TModel extends string> extends BaseTextAdapter<
format: outputSchema,
})

// A response cut off at the output cap is a truncated JSON document;
// report it before the parse error (issue #1426).
if (response.done_reason === 'length') {
throw new Error(
'ollama.structuredOutput: the response was cut off because the maximum token limit was reached (done_reason=length); raise modelOptions.options.num_predict',
)
}

const rawText = response.message.content

// Parse the JSON response
Expand Down
17 changes: 17 additions & 0 deletions packages/ai-ollama/tests/text-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,23 @@ describe('OllamaTextAdapter.structuredOutput', () => {
).rejects.toThrow(/Failed to parse structured output/)
})

it('reports a length stop as truncation, not a parse failure', async () => {
chatMock.mockResolvedValueOnce({
message: { role: 'assistant', content: '{"result":' },
done_reason: 'length',
})
const adapter = createOllamaChat('llama3.2')
await expect(
adapter.structuredOutput({
chatOptions: {
logger: testLogger,
messages: [{ role: 'user', content: 'q' }],
},
outputSchema: { type: 'object', properties: {} },
} as any),
).rejects.toThrow(/maximum token limit was reached/)
})

it('surfaces upstream errors as structured-output errors', async () => {
chatMock.mockRejectedValueOnce(new Error('network down'))
const adapter = createOllamaChat('llama3.2')
Expand Down
Loading
Loading