🎄 merry christmas: ai sdk v6 beta + tool approval (#1361)

This commit is contained in:
josh 2025-12-19 23:24:24 +00:00 committed by GitHub
parent 6e5b883cf2
commit 4d3ba8d9fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 429 additions and 202 deletions

View file

@ -13,9 +13,7 @@ import {
type ResumableStreamContext, type ResumableStreamContext,
} from "resumable-stream"; } from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth"; import { auth, type UserType } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/visibility-selector";
import { entitlementsByUserType } from "@/lib/ai/entitlements"; import { entitlementsByUserType } from "@/lib/ai/entitlements";
import type { ChatModel } from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers"; import { getLanguageModel } from "@/lib/ai/providers";
import { createDocument } from "@/lib/ai/tools/create-document"; import { createDocument } from "@/lib/ai/tools/create-document";
@ -32,6 +30,7 @@ import {
saveChat, saveChat,
saveMessages, saveMessages,
updateChatTitleById, updateChatTitleById,
updateMessage,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
@ -75,17 +74,8 @@ export async function POST(request: Request) {
} }
try { try {
const { const { id, message, messages, selectedChatModel, selectedVisibilityType } =
id, requestBody;
message,
selectedChatModel,
selectedVisibilityType,
}: {
id: string;
message: ChatMessage;
selectedChatModel: ChatModel["id"];
selectedVisibilityType: VisibilityType;
} = requestBody;
const session = await auth(); const session = await auth();
@ -104,6 +94,9 @@ export async function POST(request: Request) {
return new ChatSDKError("rate_limit:chat").toResponse(); return new ChatSDKError("rate_limit:chat").toResponse();
} }
// Check if this is a tool approval flow (all messages sent)
const isToolApprovalFlow = Boolean(messages);
const chat = await getChatById({ id }); const chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = []; let messagesFromDb: DBMessage[] = [];
let titlePromise: Promise<string> | null = null; let titlePromise: Promise<string> | null = null;
@ -112,9 +105,11 @@ export async function POST(request: Request) {
if (chat.userId !== session.user.id) { if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse(); return new ChatSDKError("forbidden:chat").toResponse();
} }
// Only fetch messages if chat already exists // Only fetch messages if chat already exists and not tool approval
if (!isToolApprovalFlow) {
messagesFromDb = await getMessagesByChatId({ id }); messagesFromDb = await getMessagesByChatId({ id });
} else { }
} else if (message?.role === "user") {
// Save chat immediately with placeholder title // Save chat immediately with placeholder title
await saveChat({ await saveChat({
id, id,
@ -127,7 +122,10 @@ export async function POST(request: Request) {
titlePromise = generateTitleFromUserMessage({ message }); titlePromise = generateTitleFromUserMessage({ message });
} }
const uiMessages = [...convertToUIMessages(messagesFromDb), message]; // Use all messages for tool approval, otherwise DB messages + new message
const uiMessages = isToolApprovalFlow
? (messages as ChatMessage[])
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
const { longitude, latitude, city, country } = geolocation(request); const { longitude, latitude, city, country } = geolocation(request);
@ -138,6 +136,8 @@ export async function POST(request: Request) {
country, country,
}; };
// Only save user messages to the database (not tool approval responses)
if (message?.role === "user") {
await saveMessages({ await saveMessages({
messages: [ messages: [
{ {
@ -150,12 +150,15 @@ export async function POST(request: Request) {
}, },
], ],
}); });
}
const streamId = generateUUID(); const streamId = generateUUID();
await createStreamId({ streamId, chatId: id }); await createStreamId({ streamId, chatId: id });
const stream = createUIMessageStream({ const stream = createUIMessageStream({
execute: ({ writer: dataStream }) => { // Pass original messages for tool approval continuation
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => {
// Handle title generation in parallel // Handle title generation in parallel
if (titlePromise) { if (titlePromise) {
titlePromise.then((title) => { titlePromise.then((title) => {
@ -171,7 +174,7 @@ export async function POST(request: Request) {
const result = streamText({ const result = streamText({
model: getLanguageModel(selectedChatModel), model: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }), system: systemPrompt({ selectedChatModel, requestHints }),
messages: convertToModelMessages(uiMessages), messages: await convertToModelMessages(uiMessages),
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
experimental_activeTools: isReasoningModel experimental_activeTools: isReasoningModel
? [] ? []
@ -215,9 +218,37 @@ export async function POST(request: Request) {
); );
}, },
generateId: generateUUID, generateId: generateUUID,
onFinish: async ({ messages }) => { onFinish: async ({ messages: finishedMessages }) => {
if (isToolApprovalFlow) {
// For tool approval, update existing messages (tool state changed) and save new ones
for (const finishedMsg of finishedMessages) {
const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id);
if (existingMsg) {
// Update existing message with new parts (tool state changed)
await updateMessage({
id: finishedMsg.id,
parts: finishedMsg.parts,
});
} else {
// Save new message
await saveMessages({ await saveMessages({
messages: messages.map((currentMessage) => ({ messages: [
{
id: finishedMsg.id,
role: finishedMsg.role,
parts: finishedMsg.parts,
createdAt: new Date(),
attachments: [],
chatId: id,
},
],
});
}
}
} else if (finishedMessages.length > 0) {
// Normal flow - save all finished messages
await saveMessages({
messages: finishedMessages.map((currentMessage) => ({
id: currentMessage.id, id: currentMessage.id,
role: currentMessage.role, role: currentMessage.role,
parts: currentMessage.parts, parts: currentMessage.parts,
@ -226,21 +257,28 @@ export async function POST(request: Request) {
chatId: id, chatId: id,
})), })),
}); });
}
}, },
onError: () => { onError: () => {
return "Oops, an error occurred!"; return "Oops, an error occurred!";
}, },
}); });
// const streamContext = getStreamContext(); const streamContext = getStreamContext();
// if (streamContext) { if (streamContext) {
// return new Response( try {
// await streamContext.resumableStream(streamId, () => const resumableStream = await streamContext.resumableStream(
// stream.pipeThrough(new JsonToSseTransformStream()) streamId,
// ) () => stream.pipeThrough(new JsonToSseTransformStream())
// ); );
// } if (resumableStream) {
return new Response(resumableStream);
}
} catch (error) {
console.error("Failed to create resumable stream:", error);
}
}
return new Response(stream.pipeThrough(new JsonToSseTransformStream())); return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
} catch (error) { } catch (error) {

View file

@ -14,13 +14,24 @@ const filePartSchema = z.object({
const partSchema = z.union([textPartSchema, filePartSchema]); const partSchema = z.union([textPartSchema, filePartSchema]);
export const postRequestBodySchema = z.object({ const userMessageSchema = z.object({
id: z.string().uuid(),
message: z.object({
id: z.string().uuid(), id: z.string().uuid(),
role: z.enum(["user"]), role: z.enum(["user"]),
parts: z.array(partSchema), parts: z.array(partSchema),
}), });
// For tool approval flows, we accept all messages (more permissive schema)
const messageSchema = z.object({
id: z.string(),
role: z.string(),
parts: z.array(z.any()),
});
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
// Either a single new message or all messages (for tool approvals)
message: userMessageSchema.optional(),
messages: z.array(messageSchema).optional(),
selectedChatModel: z.string(), selectedChatModel: z.string(),
selectedVisibilityType: z.enum(["public", "private"]), selectedVisibilityType: z.enum(["public", "private"]),
}); });

View file

@ -97,7 +97,6 @@ export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation(); const { state } = useConfirmation();
// Only show when approval is requested // Only show when approval is requested
// @ts-expect-error state only available in AI SDK v6
if (state !== "approval-requested") { if (state !== "approval-requested") {
return null; return null;
} }
@ -117,9 +116,7 @@ export const ConfirmationAccepted = ({
// Only show when approved and in response states // Only show when approved and in response states
if ( if (
!approval?.approved || !approval?.approved ||
// @ts-expect-error state only available in AI SDK v6
(state !== "approval-responded" && (state !== "approval-responded" &&
// @ts-expect-error state only available in AI SDK v6
state !== "output-denied" && state !== "output-denied" &&
state !== "output-available") state !== "output-available")
) { ) {
@ -141,9 +138,7 @@ export const ConfirmationRejected = ({
// Only show when rejected and in response states // Only show when rejected and in response states
if ( if (
approval?.approved !== false || approval?.approved !== false ||
// @ts-expect-error state only available in AI SDK v6
(state !== "approval-responded" && (state !== "approval-responded" &&
// @ts-expect-error state only available in AI SDK v6
state !== "output-denied" && state !== "output-denied" &&
state !== "output-available") state !== "output-available")
) { ) {
@ -162,7 +157,6 @@ export const ConfirmationActions = ({
const { state } = useConfirmation(); const { state } = useConfirmation();
// Only show when approval is requested // Only show when approval is requested
// @ts-expect-error state only available in AI SDK v6
if (state !== "approval-requested") { if (state !== "approval-requested") {
return null; return null;
} }

View file

@ -40,7 +40,6 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels: Record<ToolUIPart["state"], string> = { const labels: Record<ToolUIPart["state"], string> = {
"input-streaming": "Pending", "input-streaming": "Pending",
"input-available": "Running", "input-available": "Running",
// @ts-expect-error state only available in AI SDK v6
"approval-requested": "Awaiting Approval", "approval-requested": "Awaiting Approval",
"approval-responded": "Responded", "approval-responded": "Responded",
"output-available": "Completed", "output-available": "Completed",
@ -51,7 +50,6 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
const icons: Record<ToolUIPart["state"], ReactNode> = { const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />, "input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />, "input-available": <ClockIcon className="size-4 animate-pulse" />,
// @ts-expect-error state only available in AI SDK v6
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />, "approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />, "approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />, "output-available": <CheckCircleIcon className="size-4 text-green-600" />,

View file

@ -9,6 +9,7 @@ import type { UIArtifact } from "./artifact";
import { PreviewMessage, ThinkingMessage } from "./message"; import { PreviewMessage, ThinkingMessage } from "./message";
type ArtifactMessagesProps = { type ArtifactMessagesProps = {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string; chatId: string;
status: UseChatHelpers<ChatMessage>["status"]; status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined; votes: Vote[] | undefined;
@ -20,6 +21,7 @@ type ArtifactMessagesProps = {
}; };
function PureArtifactMessages({ function PureArtifactMessages({
addToolApprovalResponse,
chatId, chatId,
status, status,
votes, votes,
@ -45,6 +47,7 @@ function PureArtifactMessages({
> >
{messages.map((message, index) => ( {messages.map((message, index) => (
<PreviewMessage <PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId} chatId={chatId}
isLoading={status === "streaming" && index === messages.length - 1} isLoading={status === "streaming" && index === messages.length - 1}
isReadonly={isReadonly} isReadonly={isReadonly}
@ -64,7 +67,12 @@ function PureArtifactMessages({
))} ))}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{status === "submitted" && <ThinkingMessage key="thinking" />} {status === "submitted" &&
!messages.some((msg) =>
msg.parts?.some(
(part) => "state" in part && part.state === "approval-responded"
)
) && <ThinkingMessage key="thinking" />}
</AnimatePresence> </AnimatePresence>
<motion.div <motion.div

View file

@ -53,6 +53,7 @@ export type UIArtifact = {
}; };
function PureArtifact({ function PureArtifact({
addToolApprovalResponse,
chatId, chatId,
input, input,
setInput, setInput,
@ -69,6 +70,7 @@ function PureArtifact({
selectedVisibilityType, selectedVisibilityType,
selectedModelId, selectedModelId,
}: { }: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string; chatId: string;
input: string; input: string;
setInput: Dispatch<SetStateAction<string>>; setInput: Dispatch<SetStateAction<string>>;
@ -320,6 +322,7 @@ function PureArtifact({
<div className="flex h-full flex-col items-center justify-between"> <div className="flex h-full flex-col items-center justify-between">
<ArtifactMessages <ArtifactMessages
addToolApprovalResponse={addToolApprovalResponse}
artifactStatus={artifact.status} artifactStatus={artifact.status}
chatId={chatId} chatId={chatId}
isReadonly={isReadonly} isReadonly={isReadonly}

View file

@ -85,19 +85,54 @@ export function Chat({
stop, stop,
regenerate, regenerate,
resumeStream, resumeStream,
addToolApprovalResponse,
} = useChat<ChatMessage>({ } = useChat<ChatMessage>({
id, id,
messages: initialMessages, messages: initialMessages,
experimental_throttle: 100, experimental_throttle: 100,
generateId: generateUUID, generateId: generateUUID,
// Auto-continue after tool approval (only for APPROVED tools)
// Denied tools don't need server continuation - state is saved on next user message
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
const lastMessage = currentMessages.at(-1);
// Only continue if a tool was APPROVED (not denied)
const shouldContinue =
lastMessage?.parts?.some(
(part) =>
"state" in part &&
part.state === "approval-responded" &&
"approval" in part &&
(part.approval as { approved?: boolean })?.approved === true
) ?? false;
return shouldContinue;
},
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: "/api/chat", api: "/api/chat",
fetch: fetchWithErrorHandlers, fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) { prepareSendMessagesRequest(request) {
const lastMessage = request.messages.at(-1);
// Check if this is a tool approval continuation:
// - Last message is NOT a user message (meaning no new user input)
// - OR any message has tool parts that were responded to (approved or denied)
const isToolApprovalContinuation =
lastMessage?.role !== "user" ||
request.messages.some((msg) =>
msg.parts?.some((part) => {
const state = (part as { state?: string }).state;
return (
state === "approval-responded" || state === "output-denied"
);
})
);
return { return {
body: { body: {
id: request.id, id: request.id,
message: request.messages.at(-1), // Send all messages for tool approval continuation, otherwise just the last user message
...(isToolApprovalContinuation
? { messages: request.messages }
: { message: lastMessage }),
selectedChatModel: currentModelIdRef.current, selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: visibilityType, selectedVisibilityType: visibilityType,
...request.body, ...request.body,
@ -170,6 +205,7 @@ export function Chat({
/> />
<Messages <Messages
addToolApprovalResponse={addToolApprovalResponse}
chatId={id} chatId={id}
isArtifactVisible={isArtifactVisible} isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly} isReadonly={isReadonly}
@ -203,6 +239,7 @@ export function Chat({
</div> </div>
<Artifact <Artifact
addToolApprovalResponse={addToolApprovalResponse}
attachments={attachments} attachments={attachments}
chatId={id} chatId={id}
input={input} input={input}

View file

@ -102,7 +102,7 @@ export function DocumentPreview({
} }
return ( return (
<div className="relative w-full cursor-pointer"> <div className="relative w-full max-w-[450px] cursor-pointer">
<HitboxLayer <HitboxLayer
hitboxRef={hitboxRef} hitboxRef={hitboxRef}
result={result} result={result}
@ -119,7 +119,7 @@ export function DocumentPreview({
} }
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => ( const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
<div className="w-full"> <div className="w-full max-w-[450px]">
<div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted"> <div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-center gap-3"> <div className="flex flex-row items-center gap-3">
<div className="text-muted-foreground"> <div className="text-muted-foreground">

View file

@ -35,19 +35,25 @@ export type ToolHeaderProps = {
}; };
const getStatusBadge = (status: ToolUIPart["state"]) => { const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels = { const labels: Record<ToolUIPart["state"], string> = {
"input-streaming": "Pending", "input-streaming": "Pending",
"input-available": "Running", "input-available": "Running",
"approval-requested": "Pending",
"approval-responded": "Approved",
"output-available": "Completed", "output-available": "Completed",
"output-error": "Error", "output-error": "Error",
} as const; "output-denied": "Denied",
};
const icons = { const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />, "input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />, "input-available": <ClockIcon className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />, "output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />, "output-error": <XCircleIcon className="size-4 text-red-600" />,
} as const; "output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return ( return (
<Badge <Badge

View file

@ -25,6 +25,7 @@ import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather"; import { Weather } from "./weather";
const PurePreviewMessage = ({ const PurePreviewMessage = ({
addToolApprovalResponse,
chatId, chatId,
message, message,
vote, vote,
@ -34,6 +35,7 @@ const PurePreviewMessage = ({
isReadonly, isReadonly,
requiresScrollPadding: _requiresScrollPadding, requiresScrollPadding: _requiresScrollPadding,
}: { }: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string; chatId: string;
message: ChatMessage; message: ChatMessage;
vote: Vote | undefined; vote: Vote | undefined;
@ -76,9 +78,10 @@ const PurePreviewMessage = ({
), ),
"w-full": "w-full":
(message.role === "assistant" && (message.role === "assistant" &&
message.parts?.some( (message.parts?.some(
(p) => p.type === "text" && p.text?.trim() (p) => p.type === "text" && p.text?.trim()
)) || ) ||
message.parts?.some((p) => p.type.startsWith("tool-")))) ||
mode === "edit", mode === "edit",
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]": "max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
message.role === "user" && mode !== "edit", message.role === "user" && mode !== "edit",
@ -122,7 +125,7 @@ const PurePreviewMessage = ({
<div key={key}> <div key={key}>
<MessageContent <MessageContent
className={cn({ className={cn({
"w-fit break-words rounded-2xl px-3 py-2 text-right text-white": "wrap-break-word w-fit rounded-2xl px-3 py-2 text-right text-white":
message.role === "user", message.role === "user",
"bg-transparent px-0 py-0 text-left": "bg-transparent px-0 py-0 text-left":
message.role === "assistant", message.role === "assistant",
@ -163,22 +166,95 @@ const PurePreviewMessage = ({
if (type === "tool-getWeather") { if (type === "tool-getWeather") {
const { toolCallId, state } = part; const { toolCallId, state } = part;
const approvalId = (part as { approval?: { id: string } })
.approval?.id;
const isDenied =
state === "output-denied" ||
(state === "approval-responded" &&
(part as { approval?: { approved?: boolean } }).approval
?.approved === false);
const widthClass = "w-[min(100%,450px)]";
if (state === "output-available") {
return ( return (
<Tool defaultOpen={true} key={toolCallId}> <div className={widthClass} key={toolCallId}>
<Weather weatherAtLocation={part.output} />
</div>
);
}
if (isDenied) {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader
state="output-denied"
type="tool-getWeather"
/>
<ToolContent>
<div className="px-4 py-3 text-muted-foreground text-sm">
Weather lookup was denied.
</div>
</ToolContent>
</Tool>
</div>
);
}
if (state === "approval-responded") {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getWeather" /> <ToolHeader state={state} type="tool-getWeather" />
<ToolContent> <ToolContent>
{state === "input-available" && ( <ToolInput input={part.input} />
</ToolContent>
</Tool>
</div>
);
}
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
{(state === "input-available" ||
state === "approval-requested") && (
<ToolInput input={part.input} /> <ToolInput input={part.input} />
)} )}
{state === "output-available" && ( {state === "approval-requested" && approvalId && (
<ToolOutput <div className="flex items-center justify-end gap-2 border-t px-4 py-3">
errorText={undefined} <button
output={<Weather weatherAtLocation={part.output} />} className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
/> onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: false,
reason: "User denied weather lookup",
});
}}
type="button"
>
Deny
</button>
<button
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: true,
});
}}
type="button"
>
Allow
</button>
</div>
)} )}
</ToolContent> </ToolContent>
</Tool> </Tool>
</div>
); );
} }
@ -285,22 +361,15 @@ const PurePreviewMessage = ({
export const PreviewMessage = memo( export const PreviewMessage = memo(
PurePreviewMessage, PurePreviewMessage,
(prevProps, nextProps) => { (prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) { if (
return false; prevProps.isLoading === nextProps.isLoading &&
prevProps.message.id === nextProps.message.id &&
prevProps.requiresScrollPadding === nextProps.requiresScrollPadding &&
equal(prevProps.message.parts, nextProps.message.parts) &&
equal(prevProps.vote, nextProps.vote)
) {
return true;
} }
if (prevProps.message.id !== nextProps.message.id) {
return false;
}
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding) {
return false;
}
if (!equal(prevProps.message.parts, nextProps.message.parts)) {
return false;
}
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
return false; return false;
} }
); );

View file

@ -10,6 +10,7 @@ import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message"; import { PreviewMessage, ThinkingMessage } from "./message";
type MessagesProps = { type MessagesProps = {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string; chatId: string;
status: UseChatHelpers<ChatMessage>["status"]; status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined; votes: Vote[] | undefined;
@ -22,6 +23,7 @@ type MessagesProps = {
}; };
function PureMessages({ function PureMessages({
addToolApprovalResponse,
chatId, chatId,
status, status,
votes, votes,
@ -54,6 +56,7 @@ function PureMessages({
{messages.map((message, index) => ( {messages.map((message, index) => (
<PreviewMessage <PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId} chatId={chatId}
isLoading={ isLoading={
status === "streaming" && messages.length - 1 === index status === "streaming" && messages.length - 1 === index
@ -74,7 +77,12 @@ function PureMessages({
/> />
))} ))}
{status === "submitted" && <ThinkingMessage />} {status === "submitted" &&
!messages.some((msg) =>
msg.parts?.some(
(part) => "state" in part && part.state === "approval-responded"
)
) && <ThinkingMessage />}
<div <div
className="min-h-[24px] min-w-[24px] shrink-0" className="min-h-[24px] min-w-[24px] shrink-0"

View file

@ -9,7 +9,7 @@ export const entitlementsByUserType: Record<UserType, Entitlements> = {
* For users without an account * For users without an account
*/ */
guest: { guest: {
maxMessagesPerDay: 10, maxMessagesPerDay: 20,
}, },
/* /*

View file

@ -6,6 +6,11 @@ const mockResponses: Record<string, string> = {
greeting: "Hello! How can I help you today?", greeting: "Hello! How can I help you today?",
}; };
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
function getResponseForPrompt(prompt: unknown): string { function getResponseForPrompt(prompt: unknown): string {
const promptStr = JSON.stringify(prompt).toLowerCase(); const promptStr = JSON.stringify(prompt).toLowerCase();
@ -25,17 +30,14 @@ function getResponseForPrompt(prompt: unknown): string {
const createMockModel = (): LanguageModel => { const createMockModel = (): LanguageModel => {
return { return {
specificationVersion: "v2", specificationVersion: "v3",
provider: "mock", provider: "mock",
modelId: "mock-model", modelId: "mock-model",
defaultObjectGenerationMode: "tool", defaultObjectGenerationMode: "tool",
supportedUrls: [], supportedUrls: {},
supportsImageUrls: false,
supportsStructuredOutputs: false,
doGenerate: async ({ prompt }: { prompt: unknown }) => ({ doGenerate: async ({ prompt }: { prompt: unknown }) => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, usage: mockUsage,
content: [{ type: "text", text: getResponseForPrompt(prompt) }], content: [{ type: "text", text: getResponseForPrompt(prompt) }],
warnings: [], warnings: [],
}), }),
@ -46,24 +48,26 @@ const createMockModel = (): LanguageModel => {
return { return {
stream: new ReadableStream({ stream: new ReadableStream({
async start(controller) { async start(controller) {
controller.enqueue({ type: "text-start", id: "t1" });
for (const word of words) { for (const word of words) {
controller.enqueue({ controller.enqueue({
type: "text-delta", type: "text-delta",
textDelta: `${word} `, id: "t1",
delta: `${word} `,
}); });
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(resolve, 10); setTimeout(resolve, 10);
}); });
} }
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({ controller.enqueue({
type: "finish", type: "finish",
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20 }, usage: mockUsage,
}); });
controller.close(); controller.close();
}, },
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}; };
}, },
} as unknown as LanguageModel; } as unknown as LanguageModel;
@ -71,17 +75,14 @@ const createMockModel = (): LanguageModel => {
const createMockReasoningModel = (): LanguageModel => { const createMockReasoningModel = (): LanguageModel => {
return { return {
specificationVersion: "v2", specificationVersion: "v3",
provider: "mock", provider: "mock",
modelId: "mock-reasoning-model", modelId: "mock-reasoning-model",
defaultObjectGenerationMode: "tool", defaultObjectGenerationMode: "tool",
supportedUrls: [], supportedUrls: {},
supportsImageUrls: false,
supportsStructuredOutputs: false,
doGenerate: async () => ({ doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, usage: mockUsage,
content: [{ type: "text", text: "This is a reasoned response." }], content: [{ type: "text", text: "This is a reasoned response." }],
reasoning: [ reasoning: [
{ type: "text", text: "Let me think through this step by step..." }, { type: "text", text: "Let me think through this step by step..." },
@ -91,62 +92,77 @@ const createMockReasoningModel = (): LanguageModel => {
doStream: () => ({ doStream: () => ({
stream: new ReadableStream({ stream: new ReadableStream({
async start(controller) { async start(controller) {
controller.enqueue({ type: "reasoning-start", id: "r1" });
controller.enqueue({ controller.enqueue({
type: "reasoning", type: "reasoning-delta",
textDelta: "Let me think through this step by step... ", id: "r1",
delta: "Let me think through this step by step... ",
}); });
controller.enqueue({ type: "reasoning-end", id: "r1" });
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(resolve, 10); setTimeout(resolve, 10);
}); });
controller.enqueue({ type: "text-start", id: "t1" });
controller.enqueue({ controller.enqueue({
type: "text-delta", type: "text-delta",
textDelta: "This is a reasoned response.", id: "t1",
delta: "This is a reasoned response.",
}); });
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({ controller.enqueue({
type: "finish", type: "finish",
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20 }, usage: mockUsage,
}); });
controller.close(); controller.close();
}, },
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}), }),
} as unknown as LanguageModel; } as unknown as LanguageModel;
}; };
const createMockTitleModel = (): LanguageModel => { const createMockTitleModel = (): LanguageModel => {
return { return {
specificationVersion: "v2", specificationVersion: "v3",
provider: "mock", provider: "mock",
modelId: "mock-title-model", modelId: "mock-title-model",
defaultObjectGenerationMode: "tool", defaultObjectGenerationMode: "tool",
supportedUrls: [], supportedUrls: {},
supportsImageUrls: false,
supportsStructuredOutputs: false,
doGenerate: async () => ({ doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 }, usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
content: [{ type: "text", text: "Test Conversation" }], content: [{ type: "text", text: "Test Conversation" }],
warnings: [], warnings: [],
}), }),
doStream: () => ({ doStream: () => ({
stream: new ReadableStream({ stream: new ReadableStream({
start(controller) { start(controller) {
controller.enqueue({ type: "text-start", id: "t1" });
controller.enqueue({ controller.enqueue({
type: "text-delta", type: "text-delta",
textDelta: "Test Conversation", id: "t1",
delta: "Test Conversation",
}); });
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({ controller.enqueue({
type: "finish", type: "finish",
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 5, outputTokens: 5 }, usage: {
inputTokens: {
total: 5,
noCache: 5,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
}); });
controller.close(); controller.close();
}, },
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}), }),
} as unknown as LanguageModel; } as unknown as LanguageModel;
}; };

View file

@ -1,12 +1,16 @@
import { simulateReadableStream } from "ai"; import { simulateReadableStream } from "ai";
import { MockLanguageModelV2 } from "ai/test"; import { MockLanguageModelV3 } from "ai/test";
import { getResponseChunksByPrompt } from "@/tests/prompts/utils"; import { getResponseChunksByPrompt } from "@/tests/prompts/utils";
export const chatModel = new MockLanguageModelV2({ const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
export const chatModel = new MockLanguageModelV3({
doGenerate: async () => ({ doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }], content: [{ type: "text", text: "Hello, world!" }],
warnings: [], warnings: [],
}), }),
@ -16,15 +20,13 @@ export const chatModel = new MockLanguageModelV2({
initialDelayInMs: 1000, initialDelayInMs: 1000,
chunks: getResponseChunksByPrompt(prompt), chunks: getResponseChunksByPrompt(prompt),
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}), }),
}); });
export const reasoningModel = new MockLanguageModelV2({ export const reasoningModel = new MockLanguageModelV3({
doGenerate: async () => ({ doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }], content: [{ type: "text", text: "Hello, world!" }],
warnings: [], warnings: [],
}), }),
@ -34,15 +36,13 @@ export const reasoningModel = new MockLanguageModelV2({
initialDelayInMs: 1000, initialDelayInMs: 1000,
chunks: getResponseChunksByPrompt(prompt, true), chunks: getResponseChunksByPrompt(prompt, true),
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}), }),
}); });
export const titleModel = new MockLanguageModelV2({ export const titleModel = new MockLanguageModelV3({
doGenerate: async () => ({ doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, usage: mockUsage,
content: [{ type: "text", text: "This is a test title" }], content: [{ type: "text", text: "This is a test title" }],
warnings: [], warnings: [],
}), }),
@ -57,19 +57,17 @@ export const titleModel = new MockLanguageModelV2({
{ {
type: "finish", type: "finish",
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 }, usage: mockUsage,
}, },
], ],
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}), }),
}); });
export const artifactModel = new MockLanguageModelV2({ export const artifactModel = new MockLanguageModelV3({
doGenerate: async () => ({ doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: "stop", finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }], content: [{ type: "text", text: "Hello, world!" }],
warnings: [], warnings: [],
}), }),
@ -79,6 +77,5 @@ export const artifactModel = new MockLanguageModelV2({
initialDelayInMs: 100, initialDelayInMs: 100,
chunks: getResponseChunksByPrompt(prompt), chunks: getResponseChunksByPrompt(prompt),
}), }),
rawCall: { rawPrompt: null, rawSettings: {} },
}), }),
}); });

View file

@ -40,6 +40,7 @@ export const getWeather = tool({
.describe("City name (e.g., 'San Francisco', 'New York', 'London')") .describe("City name (e.g., 'San Francisco', 'New York', 'London')")
.optional(), .optional(),
}), }),
needsApproval: true,
execute: async (input) => { execute: async (input) => {
let latitude: number; let latitude: number;
let longitude: number; let longitude: number;

View file

@ -9,7 +9,8 @@ config({
const runMigrate = async () => { const runMigrate = async () => {
if (!process.env.POSTGRES_URL) { if (!process.env.POSTGRES_URL) {
throw new Error("POSTGRES_URL is not defined"); console.log("⏭️ POSTGRES_URL not defined, skipping migrations");
process.exit(0);
} }
const connection = postgres(process.env.POSTGRES_URL, { max: 1 }); const connection = postgres(process.env.POSTGRES_URL, { max: 1 });

View file

@ -250,6 +250,20 @@ export async function saveMessages({ messages }: { messages: DBMessage[] }) {
} }
} }
export async function updateMessage({
id,
parts,
}: {
id: string;
parts: DBMessage["parts"];
}) {
try {
return await db.update(message).set({ parts }).where(eq(message.id, id));
} catch (_error) {
throw new ChatSDKError("bad_request:database", "Failed to update message");
}
}
export async function getMessagesByChatId({ id }: { id: string }) { export async function getMessagesByChatId({ id }: { id: string }) {
try { try {
return await db return await db

View file

@ -1,6 +1,6 @@
import type { import type {
CoreAssistantMessage, AssistantModelMessage,
CoreToolMessage, ToolModelMessage,
UIMessage, UIMessage,
UIMessagePart, UIMessagePart,
} from 'ai'; } from 'ai';
@ -63,7 +63,7 @@ export function generateUUID(): string {
}); });
} }
type ResponseMessageWithoutId = CoreToolMessage | CoreAssistantMessage; type ResponseMessageWithoutId = ToolModelMessage | AssistantModelMessage;
type ResponseMessage = ResponseMessageWithoutId & { id: string }; type ResponseMessage = ResponseMessageWithoutId & { id: string };
export function getMostRecentUserMessage(messages: UIMessage[]) { export function getMostRecentUserMessage(messages: UIMessage[]) {

View file

@ -18,9 +18,9 @@
"test": "export PLAYWRIGHT=True && pnpm exec playwright test" "test": "export PLAYWRIGHT=True && pnpm exec playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "^2.0.18", "@ai-sdk/gateway": "2.0.0-beta.85",
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "3.0.0-beta.27",
"@ai-sdk/react": "2.0.109", "@ai-sdk/react": "3.0.0-beta.162",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6", "@codemirror/lang-python": "^6.1.6",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
@ -47,7 +47,7 @@
"@vercel/functions": "^2.0.0", "@vercel/functions": "^2.0.0",
"@vercel/otel": "^1.12.0", "@vercel/otel": "^1.12.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "5.0.108", "ai": "6.0.0-beta.159",
"bcrypt-ts": "^5.0.2", "bcrypt-ts": "^5.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",

80
pnpm-lock.yaml generated
View file

@ -9,14 +9,14 @@ importers:
.: .:
dependencies: dependencies:
'@ai-sdk/gateway': '@ai-sdk/gateway':
specifier: ^2.0.18 specifier: 2.0.0-beta.85
version: 2.0.18(zod@3.25.76) version: 2.0.0-beta.85(zod@3.25.76)
'@ai-sdk/provider': '@ai-sdk/provider':
specifier: 2.0.0 specifier: 3.0.0-beta.27
version: 2.0.0 version: 3.0.0-beta.27
'@ai-sdk/react': '@ai-sdk/react':
specifier: 2.0.109 specifier: 3.0.0-beta.162
version: 2.0.109(react@19.0.1)(zod@3.25.76) version: 3.0.0-beta.162(react@19.0.1)(zod@3.25.76)
'@codemirror/lang-javascript': '@codemirror/lang-javascript':
specifier: ^6.2.2 specifier: ^6.2.2
version: 6.2.3 version: 6.2.3
@ -96,8 +96,8 @@ importers:
specifier: ^12.10.0 specifier: ^12.10.0
version: 12.10.0(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) version: 12.10.0(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
ai: ai:
specifier: 5.0.108 specifier: 6.0.0-beta.159
version: 5.0.108(zod@3.25.76) version: 6.0.0-beta.159(zod@3.25.76)
bcrypt-ts: bcrypt-ts:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.3 version: 5.0.3
@ -303,31 +303,27 @@ importers:
packages: packages:
'@ai-sdk/gateway@2.0.18': '@ai-sdk/gateway@2.0.0-beta.85':
resolution: {integrity: sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==} resolution: {integrity: sha512-1LFCTwweCe1KWyBR/v64zbvNJbAu4JPooa+0JVUclcb90RYQH2FDIQCbxN4H6J8is4yaTkHW5tbLNjHpOkxZuA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.18': '@ai-sdk/provider-utils@4.0.0-beta.53':
resolution: {integrity: sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==} resolution: {integrity: sha512-83/aNTnKfurb4jdaOSfh1KgxY27SuWZbecc3bUfiUxLMtAMBLusgWMaaSbfl2VHufAoGLXIuRYRq4lXlNCdXzw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@2.0.0': '@ai-sdk/provider@3.0.0-beta.27':
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} resolution: {integrity: sha512-g/H1lyBQa5TAolD0t9uW552z0dwo2evMPxE9gu22zkEUvQSkOl8F1C0Jg31sUPTn9xmKnDK+hjWsAsZnOFE9mQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@ai-sdk/react@2.0.109': '@ai-sdk/react@3.0.0-beta.162':
resolution: {integrity: sha512-5qM8KuN7bv7E+g6BXkSAYLFjwIfMSTKOA1prjg1zEShJXJyLSc+Yqkd3EfGibm75b7nJAqJNShurDmR/IlQqFQ==} resolution: {integrity: sha512-vgJPUbHJ+y5Ebs1KmCnnjdebP7dsgW4uBEtPvmJJv/5JW7K4nizEFVOoxb2FF/mU7KbbE8qiprnM/XPRgC/znQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
zod: ^3.25.76 || ^4.1.8
peerDependenciesMeta:
zod:
optional: true
'@alloc/quick-lru@5.2.0': '@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
@ -2179,8 +2175,8 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@standard-schema/spec@1.0.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@ -2579,8 +2575,8 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ai@5.0.108: ai@6.0.0-beta.159:
resolution: {integrity: sha512-Jex3Lb7V41NNpuqJHKgrwoU6BCLHdI1Pg4qb4GJH4jRIDRXUBySJErHjyN4oTCwbiYCeb/8II9EnqSRPq9EifA==} resolution: {integrity: sha512-iwyz0iycu0Tu1of9GLGwiXvNgW6dB+hnFE0dzWubTQFKz0C8nB8gjMwRTiNhNXO6HuN9+uokg9DO7HYM2CRExw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
@ -4534,33 +4530,33 @@ packages:
snapshots: snapshots:
'@ai-sdk/gateway@2.0.18(zod@3.25.76)': '@ai-sdk/gateway@2.0.0-beta.85(zod@3.25.76)':
dependencies: dependencies:
'@ai-sdk/provider': 2.0.0 '@ai-sdk/provider': 3.0.0-beta.27
'@ai-sdk/provider-utils': 3.0.18(zod@3.25.76) '@ai-sdk/provider-utils': 4.0.0-beta.53(zod@3.25.76)
'@vercel/oidc': 3.0.5 '@vercel/oidc': 3.0.5
zod: 3.25.76 zod: 3.25.76
'@ai-sdk/provider-utils@3.0.18(zod@3.25.76)': '@ai-sdk/provider-utils@4.0.0-beta.53(zod@3.25.76)':
dependencies: dependencies:
'@ai-sdk/provider': 2.0.0 '@ai-sdk/provider': 3.0.0-beta.27
'@standard-schema/spec': 1.0.0 '@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6 eventsource-parser: 3.0.6
zod: 3.25.76 zod: 3.25.76
'@ai-sdk/provider@2.0.0': '@ai-sdk/provider@3.0.0-beta.27':
dependencies: dependencies:
json-schema: 0.4.0 json-schema: 0.4.0
'@ai-sdk/react@2.0.109(react@19.0.1)(zod@3.25.76)': '@ai-sdk/react@3.0.0-beta.162(react@19.0.1)(zod@3.25.76)':
dependencies: dependencies:
'@ai-sdk/provider-utils': 3.0.18(zod@3.25.76) '@ai-sdk/provider-utils': 4.0.0-beta.53(zod@3.25.76)
ai: 5.0.108(zod@3.25.76) ai: 6.0.0-beta.159(zod@3.25.76)
react: 19.0.1 react: 19.0.1
swr: 2.3.3(react@19.0.1) swr: 2.3.3(react@19.0.1)
throttleit: 2.1.0 throttleit: 2.1.0
optionalDependencies: transitivePeerDependencies:
zod: 3.25.76 - zod
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@ -6166,7 +6162,7 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
@ -6576,11 +6572,11 @@ snapshots:
acorn@8.15.0: {} acorn@8.15.0: {}
ai@5.0.108(zod@3.25.76): ai@6.0.0-beta.159(zod@3.25.76):
dependencies: dependencies:
'@ai-sdk/gateway': 2.0.18(zod@3.25.76) '@ai-sdk/gateway': 2.0.0-beta.85(zod@3.25.76)
'@ai-sdk/provider': 2.0.0 '@ai-sdk/provider': 3.0.0-beta.27
'@ai-sdk/provider-utils': 3.0.18(zod@3.25.76) '@ai-sdk/provider-utils': 4.0.0-beta.53(zod@3.25.76)
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
zod: 3.25.76 zod: 3.25.76

30
tests/prompts/utils.ts Normal file
View file

@ -0,0 +1,30 @@
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
export function getResponseChunksByPrompt(
_prompt: unknown,
includeReasoning = false
): LanguageModelV3StreamPart[] {
const chunks: LanguageModelV3StreamPart[] = [];
if (includeReasoning) {
chunks.push(
{ type: "reasoning-start", id: "r1" },
{ type: "reasoning-delta", id: "r1", delta: "Let me think about this." },
{ type: "reasoning-end", id: "r1" }
);
}
chunks.push(
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Hello, world!" },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: "stop", usage: mockUsage }
);
return chunks;
}