365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import {
|
|
convertToModelMessages,
|
|
createUIMessageStream,
|
|
JsonToSseTransformStream,
|
|
smoothStream,
|
|
stepCountIs,
|
|
streamText,
|
|
} from 'ai';
|
|
import { auth, type UserType } from '@/app/(auth)/auth';
|
|
import { type RequestHints, systemPrompt } from '@/lib/ai/prompts';
|
|
import {
|
|
createStreamId,
|
|
deleteChatById,
|
|
getChatById,
|
|
getMessageCountByUserId,
|
|
getMessagesByChatId,
|
|
saveChat,
|
|
saveMessages,
|
|
} from '@/lib/db/queries';
|
|
import { updateChatLastContextById } from '@/lib/db/queries';
|
|
import { convertToUIMessages, generateUUID } from '@/lib/utils';
|
|
import { generateTitleFromUserMessage } from '../../actions';
|
|
import { createDocument } from '@/lib/ai/tools/create-document';
|
|
import { updateDocument } from '@/lib/ai/tools/update-document';
|
|
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
|
|
import { getWeather } from '@/lib/ai/tools/get-weather';
|
|
import { isProductionEnvironment } from '@/lib/constants';
|
|
import { myProvider } from '@/lib/ai/providers';
|
|
import { entitlementsByUserType } from '@/lib/ai/entitlements';
|
|
import { postRequestBodySchema, type PostRequestBody } from './schema';
|
|
import { geolocation } from '@vercel/functions';
|
|
import {
|
|
createResumableStreamContext,
|
|
type ResumableStreamContext,
|
|
} from 'resumable-stream';
|
|
import { after } from 'next/server';
|
|
import { ChatSDKError } from '@/lib/errors';
|
|
import type { ChatMessage } from '@/lib/types';
|
|
import type { ChatModel } from '@/lib/ai/models';
|
|
import type { VisibilityType } from '@/components/visibility-selector';
|
|
import { unstable_cache as cache } from 'next/cache';
|
|
import { fetchModels } from 'tokenlens/fetch';
|
|
import { getUsage } from 'tokenlens/helpers';
|
|
import type { ModelCatalog } from 'tokenlens/core';
|
|
import type { AppUsage } from '@/lib/usage';
|
|
|
|
export const maxDuration = 60;
|
|
|
|
let globalStreamContext: ResumableStreamContext | null = null;
|
|
|
|
const getTokenlensCatalog = cache(
|
|
async (): Promise<ModelCatalog | undefined> => {
|
|
try {
|
|
return await fetchModels();
|
|
} catch (err) {
|
|
console.warn(
|
|
'TokenLens: catalog fetch failed, using default catalog',
|
|
err,
|
|
);
|
|
return undefined; // tokenlens helpers will fall back to defaultCatalog
|
|
}
|
|
},
|
|
['tokenlens-catalog'],
|
|
{ revalidate: 24 * 60 * 60 }, // 24 hours
|
|
);
|
|
|
|
export function getStreamContext() {
|
|
if (!globalStreamContext) {
|
|
try {
|
|
globalStreamContext = createResumableStreamContext({
|
|
waitUntil: after,
|
|
});
|
|
} catch (error: any) {
|
|
if (error.message.includes('REDIS_URL')) {
|
|
console.log(
|
|
' > Resumable streams are disabled due to missing REDIS_URL',
|
|
);
|
|
} else {
|
|
console.error(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return globalStreamContext;
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
let requestBody: PostRequestBody;
|
|
|
|
try {
|
|
const json = await request.json();
|
|
requestBody = postRequestBodySchema.parse(json);
|
|
} catch (_) {
|
|
return new ChatSDKError('bad_request:api').toResponse();
|
|
}
|
|
|
|
try {
|
|
const {
|
|
id,
|
|
message,
|
|
selectedChatModel,
|
|
selectedVisibilityType,
|
|
}: {
|
|
id: string;
|
|
message: ChatMessage;
|
|
selectedChatModel: ChatModel['id'];
|
|
selectedVisibilityType: VisibilityType;
|
|
} = requestBody;
|
|
|
|
const session = await auth();
|
|
|
|
if (!session?.user) {
|
|
return new ChatSDKError('unauthorized:chat').toResponse();
|
|
}
|
|
|
|
const userType: UserType = session.user.type;
|
|
|
|
const messageCount = await getMessageCountByUserId({
|
|
id: session.user.id,
|
|
differenceInHours: 24,
|
|
});
|
|
|
|
if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) {
|
|
return new ChatSDKError('rate_limit:chat').toResponse();
|
|
}
|
|
|
|
const chat = await getChatById({ id });
|
|
|
|
if (!chat) {
|
|
let title = 'New Chat';
|
|
try {
|
|
const generatedTitle = await generateTitleFromUserMessage({
|
|
message,
|
|
});
|
|
title = generatedTitle;
|
|
} catch (titleError) {
|
|
if (
|
|
titleError instanceof Error &&
|
|
(titleError.message?.includes('AI Gateway authentication failed') ||
|
|
titleError.message?.includes('No authentication provided') ||
|
|
titleError.message?.includes('AI Gateway requires a valid credit card'))
|
|
) {
|
|
throw titleError;
|
|
}
|
|
console.warn('Title generation failed, using default:', titleError);
|
|
}
|
|
|
|
await saveChat({
|
|
id,
|
|
userId: session.user.id,
|
|
title,
|
|
visibility: selectedVisibilityType,
|
|
});
|
|
} else {
|
|
if (chat.userId !== session.user.id) {
|
|
return new ChatSDKError('forbidden:chat').toResponse();
|
|
}
|
|
}
|
|
|
|
const messagesFromDb = await getMessagesByChatId({ id });
|
|
const uiMessages = [...convertToUIMessages(messagesFromDb), message];
|
|
|
|
const { longitude, latitude, city, country } = geolocation(request);
|
|
|
|
const requestHints: RequestHints = {
|
|
longitude,
|
|
latitude,
|
|
city,
|
|
country,
|
|
};
|
|
|
|
await saveMessages({
|
|
messages: [
|
|
{
|
|
chatId: id,
|
|
id: message.id,
|
|
role: 'user',
|
|
parts: message.parts,
|
|
attachments: [],
|
|
createdAt: new Date(),
|
|
},
|
|
],
|
|
});
|
|
|
|
const streamId = generateUUID();
|
|
await createStreamId({ streamId, chatId: id });
|
|
|
|
let finalMergedUsage: AppUsage | undefined;
|
|
|
|
try {
|
|
await myProvider.languageModel(selectedChatModel).doGenerate({
|
|
prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }],
|
|
maxOutputTokens: 1,
|
|
});
|
|
} catch (testError) {
|
|
if (
|
|
testError instanceof Error &&
|
|
(testError.message?.includes('AI Gateway authentication failed') ||
|
|
testError.message?.includes('No authentication provided') ||
|
|
testError.message?.includes('AI Gateway requires a valid credit card'))
|
|
) {
|
|
throw testError;
|
|
}
|
|
}
|
|
|
|
const stream = createUIMessageStream({
|
|
execute: ({ writer: dataStream }) => {
|
|
const result = streamText({
|
|
model: myProvider.languageModel(selectedChatModel),
|
|
system: systemPrompt({ selectedChatModel, requestHints }),
|
|
messages: convertToModelMessages(uiMessages),
|
|
stopWhen: stepCountIs(5),
|
|
experimental_activeTools:
|
|
selectedChatModel === 'chat-model-reasoning'
|
|
? []
|
|
: [
|
|
'getWeather',
|
|
'createDocument',
|
|
'updateDocument',
|
|
'requestSuggestions',
|
|
],
|
|
experimental_transform: smoothStream({ chunking: 'word' }),
|
|
tools: {
|
|
getWeather,
|
|
createDocument: createDocument({ session, dataStream }),
|
|
updateDocument: updateDocument({ session, dataStream }),
|
|
requestSuggestions: requestSuggestions({
|
|
session,
|
|
dataStream,
|
|
}),
|
|
},
|
|
experimental_telemetry: {
|
|
isEnabled: isProductionEnvironment,
|
|
functionId: 'stream-text',
|
|
},
|
|
onFinish: async ({ usage }) => {
|
|
try {
|
|
const providers = await getTokenlensCatalog();
|
|
const modelId =
|
|
myProvider.languageModel(selectedChatModel).modelId;
|
|
if (!modelId) {
|
|
finalMergedUsage = usage;
|
|
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
|
|
return;
|
|
}
|
|
|
|
if (!providers) {
|
|
finalMergedUsage = usage;
|
|
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
|
|
return;
|
|
}
|
|
|
|
const summary = getUsage({ modelId, usage, providers });
|
|
finalMergedUsage = { ...usage, ...summary, modelId } as AppUsage;
|
|
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
|
|
} catch (err) {
|
|
console.warn('TokenLens enrichment failed', err);
|
|
finalMergedUsage = usage;
|
|
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
|
|
}
|
|
},
|
|
});
|
|
|
|
result.consumeStream();
|
|
|
|
dataStream.merge(
|
|
result.toUIMessageStream({
|
|
sendReasoning: true,
|
|
}),
|
|
);
|
|
},
|
|
generateId: generateUUID,
|
|
onFinish: async ({ messages }) => {
|
|
await saveMessages({
|
|
messages: messages.map((message) => ({
|
|
id: message.id,
|
|
role: message.role,
|
|
parts: message.parts,
|
|
createdAt: new Date(),
|
|
attachments: [],
|
|
chatId: id,
|
|
})),
|
|
});
|
|
|
|
if (finalMergedUsage) {
|
|
try {
|
|
await updateChatLastContextById({
|
|
chatId: id,
|
|
context: finalMergedUsage,
|
|
});
|
|
} catch (err) {
|
|
console.warn('Unable to persist last usage for chat', id, err);
|
|
}
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
// Check for Vercel AI Gateway authentication errors
|
|
if (
|
|
error instanceof Error &&
|
|
(error.message?.includes('AI Gateway authentication failed') ||
|
|
error.message?.includes('No authentication provided') ||
|
|
error.message?.includes('AI Gateway requires a valid credit card'))
|
|
) {
|
|
throw error;
|
|
}
|
|
return 'Oops, an error occurred!';
|
|
},
|
|
});
|
|
|
|
const streamContext = getStreamContext();
|
|
|
|
if (streamContext) {
|
|
return new Response(
|
|
await streamContext.resumableStream(streamId, () =>
|
|
stream.pipeThrough(new JsonToSseTransformStream()),
|
|
),
|
|
);
|
|
} else {
|
|
return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ChatSDKError) {
|
|
return error.toResponse();
|
|
}
|
|
|
|
if (
|
|
error instanceof Error &&
|
|
(error.message?.includes(
|
|
'AI Gateway requires a valid credit card on file to service requests',
|
|
) ||
|
|
error.message?.includes('AI Gateway authentication failed') ||
|
|
error.message?.includes('No authentication provided'))
|
|
) {
|
|
return new ChatSDKError('bad_request:activate_gateway').toResponse();
|
|
}
|
|
|
|
console.error('Unhandled error in chat API:', error);
|
|
return new ChatSDKError('offline:chat').toResponse();
|
|
}
|
|
}
|
|
|
|
export async function DELETE(request: Request) {
|
|
const { searchParams } = new URL(request.url);
|
|
const id = searchParams.get('id');
|
|
|
|
if (!id) {
|
|
return new ChatSDKError('bad_request:api').toResponse();
|
|
}
|
|
|
|
const session = await auth();
|
|
|
|
if (!session?.user) {
|
|
return new ChatSDKError('unauthorized:chat').toResponse();
|
|
}
|
|
|
|
const chat = await getChatById({ id });
|
|
|
|
if (chat?.userId !== session.user.id) {
|
|
return new ChatSDKError('forbidden:chat').toResponse();
|
|
}
|
|
|
|
const deletedChat = await deleteChatById({ id });
|
|
|
|
return Response.json(deletedChat, { status: 200 });
|
|
}
|