Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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<typeof tools>
>;

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),
}),
});
}
66 changes: 66 additions & 0 deletions examples/ai-e2e-next/app/chat/bedrock-invalid-tool-call/page.tsx
Original file line number Diff line number Diff line change
@@ -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<BedrockInvalidToolCallMessage>({
transport: new DefaultChatTransport({
api: '/api/chat/bedrock-invalid-tool-call',
}),
});

return (
<div className="flex flex-col py-24 mx-auto w-full max-w-md stretch">
<div className="mb-4 text-sm text-gray-500">
1. Send a message — the model emits a malformed tool call (persisted in
the chat history).
<br />
2. Send a second message — the history is replayed to Bedrock, which
rejects the raw-string tool input.
</div>

{messages.map(message => (
<div key={message.id} className="mb-4 whitespace-pre-wrap">
<strong>{`${message.role}: `}</strong>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <div key={index}>{part.text}</div>;
}

if (part.type === 'step-start') {
return null;
}

return (
<pre
key={index}
className="p-2 mt-1 text-xs text-gray-500 bg-gray-100 rounded overflow-x-auto"
>
{JSON.stringify(part, null, 2)}
</pre>
);
})}
</div>
))}

{error && (
<div className="mt-4">
<div className="text-red-500">{error.message}</div>
<button
type="button"
className="px-4 py-2 mt-4 text-blue-500 rounded-md border border-blue-500"
onClick={() => regenerate()}
>
Retry
</button>
</div>
)}

<ChatInput status={status} onSubmit={text => sendMessage({ text })} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { amazonBedrock } from '@ai-sdk/amazon-bedrock';
import {
convertToModelMessages,
generateText,
isStepCount,
readUIMessageStream,
streamText,
toUIMessageStream,
tool,
wrapLanguageModel,
type LanguageModelMiddleware,
type UIMessage,
} from 'ai';
import { z } from 'zod';
import { run } from '../../lib/run';

// 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,
);
},
}),
),
};
},
};

run(async () => {
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'] }),
}),
};

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('Recovered:', result.text);
});
71 changes: 71 additions & 0 deletions packages/ai/src/generate-text/generate-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelMessage[]> = [];

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', () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/ai/src/generate-text/parse-tool-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/ai/src/generate-text/parse-tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ export async function parseToolCall<TOOLS extends ToolSet>({
} 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
Expand Down
Loading