From 00564e711797cd83787915bd715333afa9e13252 Mon Sep 17 00:00:00 2001 From: Nick Oates Date: Wed, 1 Jul 2026 15:00:50 -0700 Subject: [PATCH 1/5] tests --- .../src/generate-text/generate-text.test.ts | 71 +++++++++++++++++++ .../src/generate-text/parse-tool-call.test.ts | 8 ++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/generate-text/generate-text.test.ts b/packages/ai/src/generate-text/generate-text.test.ts index 87c91b445b3d..9f33c0c42453 100644 --- a/packages/ai/src/generate-text/generate-text.test.ts +++ b/packages/ai/src/generate-text/generate-text.test.ts @@ -9423,6 +9423,77 @@ describe('generateText', () => { `); }); }); + + it('should expose malformed JSON tool input as an object in public results and next-step messages', async () => { + const malformedInput = '{ "city": "San Francisco", }'; + const prepareStepResponseMessages: Array = []; + + const result = await generateText({ + model: new MockLanguageModelV4({ + doGenerate: [ + { + ...dummyResponseValues, + finishReason: { unified: 'tool-calls', raw: undefined }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'cityAttractions', + input: malformedInput, + }, + ], + }, + { + ...dummyResponseValues, + content: [{ type: 'text', text: 'Done.' }], + }, + ], + }), + tools: { + cityAttractions: tool({ + inputSchema: z.object({ city: z.string() }), + }), + }, + prompt: 'What are the tourist attractions in San Francisco?', + stopWhen: isStepCount(2), + prepareStep: ({ responseMessages, stepNumber }) => { + if (stepNumber === 1) { + prepareStepResponseMessages.push([...responseMessages]); + } + + return undefined; + }, + }); + + expect(result.steps).toHaveLength(2); + expect(result.toolCalls[0]).toMatchObject({ + dynamic: true, + invalid: true, + input: { rawInvalidInput: malformedInput }, + }); + expect(result.content[0]).toMatchObject({ + type: 'tool-call', + input: { rawInvalidInput: malformedInput }, + }); + expect(result.responseMessages[0]).toMatchObject({ + role: 'assistant', + content: [ + { + type: 'tool-call', + input: { rawInvalidInput: malformedInput }, + }, + ], + }); + expect(prepareStepResponseMessages[0][0]).toMatchObject({ + role: 'assistant', + content: [ + { + type: 'tool-call', + input: { rawInvalidInput: malformedInput }, + }, + ], + }); + }); }); describe('tools with preliminary results', () => { diff --git a/packages/ai/src/generate-text/parse-tool-call.test.ts b/packages/ai/src/generate-text/parse-tool-call.test.ts index 6fae09532489..dd619c478d73 100644 --- a/packages/ai/src/generate-text/parse-tool-call.test.ts +++ b/packages/ai/src/generate-text/parse-tool-call.test.ts @@ -471,7 +471,9 @@ describe('parseToolCall', () => { "dynamic": true, "error": [AI_InvalidToolInputError: Invalid input for tool testTool: AI_JSONParseError: JSON parsing failed: Text: invalid json. Error message: SyntaxError: Unexpected token 'i', "invalid json" is not valid JSON], - "input": "invalid json", + "input": { + "rawInvalidInput": "invalid json", + }, "invalid": true, "providerExecuted": undefined, "providerMetadata": undefined, @@ -510,7 +512,9 @@ describe('parseToolCall', () => { { "dynamic": true, "error": [AI_ToolCallRepairError: Error repairing tool call: Error: test error], - "input": "invalid json", + "input": { + "rawInvalidInput": "invalid json", + }, "invalid": true, "providerExecuted": undefined, "providerMetadata": undefined, From 46ff99ac3e7e4dbc6f8fe901565d552f9fd0eab7 Mon Sep 17 00:00:00 2001 From: Nick Oates Date: Wed, 1 Jul 2026 15:01:33 -0700 Subject: [PATCH 2/5] code --- packages/ai/src/generate-text/parse-tool-call.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/generate-text/parse-tool-call.ts b/packages/ai/src/generate-text/parse-tool-call.ts index 5066b1085a1e..70dcfe330cc2 100644 --- a/packages/ai/src/generate-text/parse-tool-call.ts +++ b/packages/ai/src/generate-text/parse-tool-call.ts @@ -94,7 +94,9 @@ export async function parseToolCall({ } catch (error) { // use parsed input when possible const parsedInput = await safeParseJSON({ text: toolCall.input }); - const input = parsedInput.success ? parsedInput.value : toolCall.input; + const input = parsedInput.success + ? parsedInput.value + : { rawInvalidInput: toolCall.input }; const tool = tools?.[toolCall.toolName]; // TODO AI SDK 6: special invalid tool call parts From abd82653b0c333626d8f8b54d0ac5a50fc72ddce Mon Sep 17 00:00:00 2001 From: Nick Oates Date: Wed, 1 Jul 2026 15:08:28 -0700 Subject: [PATCH 3/5] example --- .../malformed-tool-call-retry.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts diff --git a/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts b/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts new file mode 100644 index 000000000000..ce91c5061da3 --- /dev/null +++ b/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts @@ -0,0 +1,71 @@ +import { amazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { + generateText, + isStepCount, + tool, + type LanguageModelMiddleware, + wrapLanguageModel, +} from 'ai'; +import { z } from 'zod'; +import { run } from '../../lib/run'; + +const malformedInput = '{ "city": "San Francisco", }'; + +let hasCorruptedToolCall = false; + +const corruptFirstToolCallInput: LanguageModelMiddleware = { + wrapGenerate: async ({ doGenerate }) => { + const result = await doGenerate(); + + if (hasCorruptedToolCall) { + return result; + } + + return { + ...result, + content: result.content.map(part => { + if (part.type !== 'tool-call' || hasCorruptedToolCall) { + return part; + } + + hasCorruptedToolCall = true; + return { ...part, input: malformedInput }; + }), + }; + }, +}; + +run(async () => { + const result = await generateText({ + model: wrapLanguageModel({ + model: amazonBedrock('anthropic.claude-3-5-sonnet-20240620-v1:0'), + middleware: corruptFirstToolCallInput, + }), + tools: { + cityAttractions: tool({ + description: 'Get tourist attractions for a city', + inputSchema: z.object({ + city: z.string(), + }), + execute: async ({ city }) => ({ + city, + attractions: ['Golden Gate Bridge', 'Exploratorium'], + }), + }), + }, + prepareStep: ({ stepNumber }) => + stepNumber === 0 + ? { toolChoice: { type: 'tool', toolName: 'cityAttractions' } } + : undefined, + stopWhen: isStepCount(3), + prompt: + 'Find tourist attractions in San Francisco using the cityAttractions tool.', + }); + + console.log(result.text); + console.log('Tool calls:', JSON.stringify(result.toolCalls, null, 2)); + console.log( + 'Response messages:', + JSON.stringify(result.responseMessages, null, 2), + ); +}); From 7024b730020ca3c6cafe2e2c90e36c6f7232c5f8 Mon Sep 17 00:00:00 2001 From: Nick Oates Date: Wed, 1 Jul 2026 16:19:44 -0700 Subject: [PATCH 4/5] simplify example --- .../malformed-tool-call-retry.ts | 115 ++++++++++-------- 1 file changed, 64 insertions(+), 51 deletions(-) diff --git a/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts b/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts index ce91c5061da3..6579d0a63cb2 100644 --- a/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts +++ b/examples/ai-functions/src/generate-text/amazon-bedrock/malformed-tool-call-retry.ts @@ -1,71 +1,84 @@ import { amazonBedrock } from '@ai-sdk/amazon-bedrock'; import { + convertToModelMessages, generateText, isStepCount, + readUIMessageStream, + streamText, + toUIMessageStream, tool, - type LanguageModelMiddleware, wrapLanguageModel, + type LanguageModelMiddleware, + type UIMessage, } from 'ai'; import { z } from 'zod'; import { run } from '../../lib/run'; -const malformedInput = '{ "city": "San Francisco", }'; - -let hasCorruptedToolCall = false; - -const corruptFirstToolCallInput: LanguageModelMiddleware = { - wrapGenerate: async ({ doGenerate }) => { - const result = await doGenerate(); - - if (hasCorruptedToolCall) { - return result; - } - +// Simulate a model emitting malformed JSON (trailing comma) as its tool input. +const corruptToolCallInput: LanguageModelMiddleware = { + wrapStream: async ({ doStream }) => { + const { stream, ...rest } = await doStream(); return { - ...result, - content: result.content.map(part => { - if (part.type !== 'tool-call' || hasCorruptedToolCall) { - return part; - } - - hasCorruptedToolCall = true; - return { ...part, input: malformedInput }; - }), + ...rest, + stream: stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue( + chunk.type === 'tool-call' + ? { ...chunk, input: '{ "city": "San Francisco", }' } + : chunk, + ); + }, + }), + ), }; }, }; run(async () => { - const result = await generateText({ - model: wrapLanguageModel({ - model: amazonBedrock('anthropic.claude-3-5-sonnet-20240620-v1:0'), - middleware: corruptFirstToolCallInput, + const model = amazonBedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); + + const tools = { + cityAttractions: tool({ + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ city, attractions: ['Golden Gate'] }), }), - tools: { - cityAttractions: tool({ - description: 'Get tourist attractions for a city', - inputSchema: z.object({ - city: z.string(), - }), - execute: async ({ city }) => ({ - city, - attractions: ['Golden Gate Bridge', 'Exploratorium'], - }), - }), - }, - prepareStep: ({ stepNumber }) => - stepNumber === 0 - ? { toolChoice: { type: 'tool', toolName: 'cityAttractions' } } - : undefined, - stopWhen: isStepCount(3), - prompt: - 'Find tourist attractions in San Francisco using the cityAttractions tool.', + }; + + const prompt = 'What are the tourist attractions in San Francisco?'; + + // Turn 1: stream the malformed tool call into a persisted UI message, + // like a `useChat` server -> client flow. + const stream = streamText({ + model: wrapLanguageModel({ model, middleware: corruptToolCallInput }), + tools, + toolChoice: { type: 'tool', toolName: 'cityAttractions' }, + prompt, + }); + + let assistantMessage: UIMessage | undefined; + for await (const uiMessage of readUIMessageStream({ + stream: toUIMessageStream({ stream: stream.stream }), + })) { + assistantMessage = uiMessage; + } + + // Turn 2: replay the persisted conversation. `convertToModelMessages` is the + // unguarded path that passes the raw-string tool input straight to Bedrock, + // which rejects it unless it is a JSON object. + const messages = await convertToModelMessages([ + { id: '1', role: 'user', parts: [{ type: 'text', text: prompt }] }, + assistantMessage!, + ]); + + console.log('Replayed tool-call input:', messages[1].content); + + const result = await generateText({ + model, + tools, + messages, + stopWhen: isStepCount(5), }); - console.log(result.text); - console.log('Tool calls:', JSON.stringify(result.toolCalls, null, 2)); - console.log( - 'Response messages:', - JSON.stringify(result.responseMessages, null, 2), - ); + console.log('Recovered:', result.text); }); From 304857a94903988f356fdb163dc3c16ec3776f5e Mon Sep 17 00:00:00 2001 From: Nick Oates Date: Wed, 1 Jul 2026 16:46:44 -0700 Subject: [PATCH 5/5] add example --- .../chat/bedrock-invalid-tool-call/route.ts | 91 +++++++++++++++++++ .../chat/bedrock-invalid-tool-call/page.tsx | 66 ++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 examples/ai-e2e-next/app/api/chat/bedrock-invalid-tool-call/route.ts create mode 100644 examples/ai-e2e-next/app/chat/bedrock-invalid-tool-call/page.tsx diff --git a/examples/ai-e2e-next/app/api/chat/bedrock-invalid-tool-call/route.ts b/examples/ai-e2e-next/app/api/chat/bedrock-invalid-tool-call/route.ts new file mode 100644 index 000000000000..b4485ef55e7d --- /dev/null +++ b/examples/ai-e2e-next/app/api/chat/bedrock-invalid-tool-call/route.ts @@ -0,0 +1,91 @@ +import { amazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { + convertToModelMessages, + createUIMessageStreamResponse, + isStepCount, + streamText, + tool, + toUIMessageStream, + wrapLanguageModel, + type InferUITools, + type LanguageModelMiddleware, + type UIDataTypes, + type UIMessage, +} from 'ai'; +import { z } from 'zod'; + +export const maxDuration = 30; + +// Simulate a model emitting malformed JSON (trailing comma) as its tool input. +const corruptToolCallInput: LanguageModelMiddleware = { + wrapStream: async ({ doStream }) => { + const { stream, ...rest } = await doStream(); + return { + ...rest, + stream: stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue( + chunk.type === 'tool-call' + ? { ...chunk, input: '{ "city": "San Francisco", }' } + : chunk, + ); + }, + }), + ), + }; + }, +}; + +const tools = { + cityAttractions: tool({ + description: 'Get tourist attractions for a city', + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ + city, + attractions: ['Golden Gate Bridge', 'Exploratorium'], + }), + }), +} as const; + +export type BedrockInvalidToolCallMessage = UIMessage< + never, + UIDataTypes, + InferUITools +>; + +export async function POST(req: Request) { + const { messages }: { messages: BedrockInvalidToolCallMessage[] } = + await req.json(); + + const model = amazonBedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); + + // First turn (no assistant message yet): make the model emit a malformed + // tool call. It is persisted into the chat history with a raw-string input. + // + // Later turns: replay that history back to Bedrock. Bedrock rejects a string + // `toolUse.input` ("Provide a json object...") unless the parse-tool-call fix + // wraps the invalid input in an object. + const isReplay = messages.some(message => message.role === 'assistant'); + + const result = streamText({ + model: isReplay + ? model + : wrapLanguageModel({ model, middleware: corruptToolCallInput }), + tools, + messages: await convertToModelMessages(messages), + toolChoice: isReplay + ? undefined + : { type: 'tool', toolName: 'cityAttractions' }, + stopWhen: isStepCount(isReplay ? 5 : 1), + }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ + stream: result.stream, + // surface the real Bedrock error on the client instead of a generic message + onError: error => + error instanceof Error ? error.message : String(error), + }), + }); +} diff --git a/examples/ai-e2e-next/app/chat/bedrock-invalid-tool-call/page.tsx b/examples/ai-e2e-next/app/chat/bedrock-invalid-tool-call/page.tsx new file mode 100644 index 000000000000..7cfc03956470 --- /dev/null +++ b/examples/ai-e2e-next/app/chat/bedrock-invalid-tool-call/page.tsx @@ -0,0 +1,66 @@ +'use client'; + +import type { BedrockInvalidToolCallMessage } from '@/app/api/chat/bedrock-invalid-tool-call/route'; +import ChatInput from '@/components/chat-input'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; + +export default function Chat() { + const { messages, sendMessage, status, error, regenerate } = + useChat({ + transport: new DefaultChatTransport({ + api: '/api/chat/bedrock-invalid-tool-call', + }), + }); + + return ( +
+
+ 1. Send a message — the model emits a malformed tool call (persisted in + the chat history). +
+ 2. Send a second message — the history is replayed to Bedrock, which + rejects the raw-string tool input. +
+ + {messages.map(message => ( +
+ {`${message.role}: `} + {message.parts.map((part, index) => { + if (part.type === 'text') { + return
{part.text}
; + } + + if (part.type === 'step-start') { + return null; + } + + return ( +
+                {JSON.stringify(part, null, 2)}
+              
+ ); + })} +
+ ))} + + {error && ( +
+
{error.message}
+ +
+ )} + + sendMessage({ text })} /> +
+ ); +}