refactor: Rework LLM client types and implementations (#2417)

Refactor the LLM client, responses shape and web client to support
streaming and regular json request in a single helper.
This commit is contained in:
Giuseppe authored and GitHub committed 2023-10-05 09:28:10 +02:00
1 parent 07af1654e1
commit 99be84b56b
9 files changed
+396 -376

No files matched your search

@@ -1,6 +1,10 @@
import { z } from "zod";
import untruncateJson from "untruncate-json";
import { copywriter, requestStream } from "@webstudio-is/ai";
import {
copywriter,
request,
type StreamingTextResponseType,
} from "@webstudio-is/ai";
import { restAiCopy } from "~/shared/router-utils";
import {
Box,
@@ -45,24 +49,6 @@ const patchTextInstance = (textInstance: copywriter.TextInstance) => {
});
};
const onChunk = (completion: string) => {
try {
const jsonResponse = z
.array(copywriter.TextInstanceSchema)
.parse(JSON.parse(untruncateJson(completion)));
const currenTextInstance = jsonResponse.pop();
if (currenTextInstance === undefined) {
return;
}
patchTextInstance(currenTextInstance);
} catch {
/**/
}
};
const $textInstances = computed(
[instancesStore, selectedInstanceStore],
(instances, selectedInstance) => {
@@ -120,7 +106,7 @@ export const Copywriter = () => {
abort.current = new AbortController();
setIsLoading(true);
requestStream(
request<StreamingTextResponseType>(
[
restAiCopy(),
{
@@ -134,12 +120,28 @@ export const Copywriter = () => {
},
],
{
onChunk,
onChunk: (id, { decoded }) => {
try {
const jsonResponse = z
.array(copywriter.TextInstanceSchema)
.parse(JSON.parse(untruncateJson(decoded)));
const currenTextInstance = jsonResponse.pop();
if (currenTextInstance === undefined) {
return;
}
patchTextInstance(currenTextInstance);
} catch {
/**/
}
},
}
).then((result) => {
abort.current = null;
if (typeof result !== "string") {
alert("Error " + result.type);
if (result.success === false) {
alert(result.data.message);
}
setIsLoading(false);
});
+40 -26
View File
@@ -1,6 +1,7 @@
import type { ActionArgs } from "@remix-run/node";
import {
copywriter,
createErrorResponse,
createGptModel,
type GPTModelMessageFormat,
} from "@webstudio-is/ai";
@@ -17,19 +18,24 @@ const RequestSchema = copywriter.ContextSchema.extend({
export const action = async function action({ request }: ActionArgs) {
if (isFeatureEnabled("aiCopy") === false) {
return {
success: false,
type: "featureDisabled",
status: 503,
message: "The feature is not available",
id: "ai",
...createErrorResponse({
error: "featureDisabled",
status: 503,
message: "The feature is not available",
debug: "aiCopy feature disabled",
}),
};
}
if (env.OPENAI_KEY === undefined) {
return {
success: false,
type: "invalidApiKey",
status: 401,
message: "",
id: "ai",
...createErrorResponse({
error: "ai.invalidApiKey",
status: 401,
debug: "Invalid OpenAI API key",
}),
};
}
@@ -38,10 +44,12 @@ export const action = async function action({ request }: ActionArgs) {
env.OPENAI_ORG.startsWith("org-") === false
) {
return {
success: false,
type: "invalidOrg",
status: 401,
message: "",
id: "ai",
...createErrorResponse({
error: "ai.invalidOrg",
status: 401,
debug: "Invalid OpenAI API organization",
}),
};
}
@@ -49,18 +57,15 @@ export const action = async function action({ request }: ActionArgs) {
if (parsed.success === false) {
return {
success: false,
type: "invalidRequest",
status: 400,
message:
process.env.NODE_ENV === "development"
? parsed.error.errors
: "Invalid request data",
id: "ai",
...createErrorResponse({
error: "ai.invalidRequest",
status: 401,
debug: "Invalid request data",
}),
};
}
// @todo add rate limiting
const { projectId, prompt, textInstances } = parsed.data;
// Permissions check
@@ -72,10 +77,13 @@ export const action = async function action({ request }: ActionArgs) {
if (canEdit === false) {
return {
success: false,
type: "unauthorized",
status: 401,
message: "You don't have edit access to this project",
id: "ai",
...createErrorResponse({
error: "unauthorized",
status: 401,
message: "You don't have edit access to this project",
debug: "Unauthorized access attempt",
}),
};
}
// End of Permissions check
@@ -89,11 +97,17 @@ export const action = async function action({ request }: ActionArgs) {
const chain = copywriter.createChain<GPTModelMessageFormat>();
return chain({
const response = await chain({
model,
context: {
prompt,
textInstances,
},
});
if (response.success) {
return response.data;
}
return response;
};
+48 -30
View File
@@ -1,13 +1,11 @@
import { z } from "zod";
import type {
Model as BaseModel,
ModelMessage,
ChainStream,
} from "../../types";
import type { Model as BaseModel, ModelMessage, Chain } from "../../types";
import { formatPrompt } from "../../utils/format-prompt";
import { prompt as promptSystemTemplate } from "./__generated__/copy.system.prompt";
import { prompt as promptUserTemplate } from "./__generated__/copy.user.prompt";
import type { Instance, Instances } from "@webstudio-is/sdk";
import { createErrorResponse } from "../../utils/create-error-response";
import type { StreamingTextResponseType } from "../../utils/streaming-text-response";
/**
* Copywriter chain.
@@ -40,51 +38,71 @@ export type Context = z.infer<typeof ContextSchema>;
export const ResponseSchema = z.array(TextInstanceSchema);
export type Response = z.infer<typeof ResponseSchema>;
export const createChain = <ModelMessageFormat>(): ChainStream<
export const createChain = <ModelMessageFormat>(): Chain<
BaseModel<ModelMessageFormat>,
Context
Context,
StreamingTextResponseType
> =>
async function chain({ model, context }) {
const { prompt, textInstances } = context;
const id = "copywriter";
const llmMessages: ModelMessage[] = [
["system", promptSystemTemplate],
[
"user",
formatPrompt(
{
prompt,
text_nodes: JSON.stringify(textInstances),
},
promptUserTemplate
),
],
];
if (textInstances.length === 0) {
const message = "No text nodes found for the instance";
return {
success: false,
type: "genericError",
status: 404,
message: "No text nodes found for the instance",
id,
...createErrorResponse({
status: 404,
error: "ai.copywriter.textNodesNotFound",
message,
debug: message,
}),
llmMessages,
};
}
try {
z.array(TextInstanceSchema).parse(textInstances);
} catch (error) {
const message = "Invalid nodes list";
return {
success: false,
type: "parseError",
status: 500,
message: "Invalid text nodes list",
id,
...createErrorResponse({
status: 404,
error: "ai.parseError",
message,
debug: message,
}),
llmMessages,
};
}
const systemMessage: ModelMessage = ["system", promptSystemTemplate];
const messages = model.generateMessages(llmMessages);
const userMessage: ModelMessage = [
"user",
formatPrompt(
{
prompt,
text_nodes: JSON.stringify(textInstances),
},
promptUserTemplate
),
];
const messages = model.generateMessages([systemMessage, userMessage]);
return model.requestStream({
const response = await model.completionStream({
id,
messages,
});
return {
...response,
llmMessages,
};
};
export const collectTextInstances = ({
+2 -1
View File
@@ -11,7 +11,8 @@ export {
/** Utils */
export * from "./utils/request";
export * from "./utils/create-error-response";
export * from "./utils/streaming-text-response";
/** Types */
export * from "./types";
export * from "./utils/request";
+97 -144
View File
@@ -1,15 +1,13 @@
import OpenAI from "openai";
import {
OpenAIStream,
StreamingTextResponse as _StreamingTextResponse,
} from "ai";
import { OpenAIStream } from "ai";
import type {
Model as BaseModel,
ErrorResponse,
ModelCompletion,
ModelCompletionStream,
ModelGenerateMessages,
ModelRequest,
ModelRequestStream,
} from "../types";
import { createErrorResponse } from "../utils/create-error-response";
import { StreamingTextResponse } from "../utils/streaming-text-response";
export type Model = BaseModel<ModelMessageFormat>;
export type ModelMessageFormat = OpenAI.Chat.Completions.ChatCompletionMessage;
@@ -25,8 +23,8 @@ export type ModelConfig = {
export const create = function createModel(config: ModelConfig): Model {
return {
generateMessages,
request: createRequest(config),
requestStream: createRequestStream(config),
completion: createCompletion(config),
completionStream: createCompletionStream(config),
};
};
@@ -36,96 +34,62 @@ export const generateMessages: ModelGenerateMessages<ModelMessageFormat> = (
return messages.map(([role, content]) => ({ role, content }));
};
const createRequest = (config: ModelConfig): ModelRequest<ModelMessageFormat> =>
async function request({ messages }) {
const response = await fetch(
config.endpoint ?? "https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${config.apiKey}`,
"OpenAI-Organization": config.organization,
},
body: JSON.stringify({
model: config.model ?? "gpt-3.5-turbo",
messages,
temperature: config.temperature,
}),
}
);
if (response.ok === false) {
return errorResponse(response);
}
const completion: OpenAI.Chat.Completions.ChatCompletion =
await response.json();
return {
success: true,
choices: completion.choices.map(
(choice) => choice?.message?.content || ""
),
tokens: {
prompt: completion.usage?.prompt_tokens ?? 0,
completion: completion.usage?.completion_tokens ?? 0,
},
};
};
export const errorResponse = (response: Response): ErrorResponse => {
let errorType = "ai.genericError";
if ([401, 429, 500, 503].includes(response.status)) {
errorType =
OpenAIErrors[response.status as 401 | 429 | 500 | 503][
response.statusText
] ?? "ai.genericError";
}
return {
success: false,
type: errorType,
status: response.status,
message: response.statusText,
};
};
const OpenAIErrors: Record<401 | 429 | 500 | 503, { [key in string]: string }> =
{
401: {
"Invalid Authentication": "ai.invalidAuth",
"Incorrect API key provided": "ai.invalidApiKey",
"You must be a member of an organization to use the API": "ai.invalidOrg",
},
429: {
"Rate limit reached for requests": "ai.rateLimit",
"You exceeded your current quota, please check your plan and billing details":
"ai.quotaExceed",
},
500: {
"The server had an error while processing your request":
"ai.genericError",
},
503: {
"The engine is currently overloaded, please try again later":
"ai.overloaded",
},
};
const createRequestStream = (
export const createCompletion = (
config: ModelConfig
): ModelRequestStream<ModelMessageFormat> =>
async function requestStream({ messages }) {
): ModelCompletion<ModelMessageFormat> =>
async function completion({
id,
messages,
}: {
id: string;
messages: ModelMessageFormat[];
}) {
try {
const openai = new OpenAI({
apiKey: config.apiKey,
organization: config.organization,
});
const completion = await openai.chat.completions.create({
model: config.model ?? "gpt-3.5-turbo",
temperature: config.temperature,
messages,
});
return {
id,
type: "json",
success: true,
tokens: {
prompt: completion.usage?.prompt_tokens || 0,
completion: completion.usage?.completion_tokens || 0,
},
data: {
choices: completion.choices.map(
(choice) => choice?.message?.content || ""
),
},
} as const;
} catch (error) {
return errorToResponse(id, error);
}
};
export const createCompletionStream = (
config: ModelConfig
): ModelCompletionStream<ModelMessageFormat> =>
async function completeStream({
id,
messages,
}: {
id: string;
messages: ModelMessageFormat[];
}) {
try {
const openai = new OpenAI({
apiKey: config.apiKey,
organization: config.organization,
});
// Use polyfilled TransformStream because in Webstudio Builder
// globalThis.fetch is overriden to @remix-run/web-fetch.
//
@@ -149,62 +113,51 @@ const createRequestStream = (
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
return {
id,
type: "stream",
success: true,
data: new StreamingTextResponse(stream),
tokens: {
prompt: -1,
completion: -1,
},
} as const;
} catch (error) {
let response = new Response(null, {
status: 500,
statusText:
process.env.NODE_ENV === "development" &&
error != null &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
? error.message
: "",
});
if (error instanceof OpenAI.APIError) {
const { status, message } = error;
response = new Response(null, {
status,
statusText: message,
});
}
return errorResponse(response);
return errorToResponse(id, error);
}
};
// vercel/ai's StreamingTextResponse does not include request.headers.raw()
// which @vercel/remix uses when deployed on vercel.
// Therefore we use a custom one.
export class StreamingTextResponse extends _StreamingTextResponse {
constructor(res: ReadableStream, init?: ResponseInit) {
super(res, init);
this.getRequestHeaders();
}
const errorToResponse = (id: string, error: unknown) => {
let status = 500;
let debug =
error != null &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
? error.message
: "";
getRequestHeaders() {
return addRawHeaders(this.headers);
}
}
const addRawHeaders = function addRawHeaders(headers: Headers) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
headers.raw = function () {
const rawHeaders: { [k in string]: string[] } = {};
const headerEntries = headers.entries();
for (const [key, value] of headerEntries) {
const headerKey = key.toLowerCase();
// eslint-disable-next-line no-prototype-builtins
if (rawHeaders.hasOwnProperty(headerKey)) {
rawHeaders[headerKey].push(value);
} else {
rawHeaders[headerKey] = [value];
}
if (error instanceof OpenAI.APIError) {
if (typeof error.status === "number") {
status = error.status;
}
return rawHeaders;
};
return headers;
debug += `\n ${error.message}`;
}
return {
id,
...createErrorResponse({
status,
error: getErrorType(error),
message: "Something went wrong",
debug,
}),
} as const;
};
const getErrorType = (error: unknown) => {
if (error instanceof OpenAI.APIError) {
return `ai.${error.name}`;
}
return `ai.unknownError`;
};
+49 -53
View File
@@ -1,3 +1,40 @@
import type { StreamingTextResponseType } from "./utils/streaming-text-response";
/**
* Generic Response types used both by Models and Chains.
*/
// @todo Convert the response types to Zod
// so that responses can be parsed and validated on the client.
export type Tokens = {
prompt: number;
completion: number;
};
export type SuccessResponse<ResponseData> = {
type: ResponseData extends StreamingTextResponseType ? "stream" : "json";
success: true;
tokens: Tokens;
data: ResponseData;
};
export type ErrorResponse = {
type: "json";
success: false;
tokens: Tokens;
data: {
status: number;
error: string;
message: string;
debug?: string;
};
};
type Response<ResponseData> = {
id: string;
} & (SuccessResponse<ResponseData> | ErrorResponse);
/**
* Models types.
*
@@ -6,41 +43,27 @@
*/
export type ModelMessage = ["system" | "user" | "assistant", string];
export type ModelMessages = Array<ModelMessage>;
export type Model<ModelMessageFormat> = {
// Turns ModelMessages into a model-specific messages format.
generateMessages: ModelGenerateMessages<ModelMessageFormat>;
// Regular request.
request: ModelRequest<ModelMessageFormat>;
// Streaming.
requestStream: ModelRequestStream<ModelMessageFormat>;
completion: ModelCompletion<ModelMessageFormat>;
completionStream: ModelCompletionStream<ModelMessageFormat>;
};
export type ModelGenerateMessages<ModelMessage> = (
messages: ModelMessages
) => ModelMessage[];
export type ModelGenerateMessages<ModelMessageFormat> = (
messages: ModelMessage[]
) => ModelMessageFormat[];
export type ModelRequestSuccess = {
success: true;
choices: string[];
tokens: {
prompt: number;
completion: number;
};
};
export type ModelRequest<ModelMessageFormat> = ({
messages,
}: {
export type ModelCompletion<ModelMessageFormat> = (args: {
id: string;
messages: ReturnType<ModelGenerateMessages<ModelMessageFormat>>;
}) => Promise<ModelRequestSuccess | ErrorResponse>;
}) => Promise<Response<{ choices: string[] }>>;
export type ModelRequestStream<ModelMessageFormat> = ({
messages,
}: {
export type ModelCompletionStream<ModelMessageFormat> = (args: {
id: string;
messages: ReturnType<ModelGenerateMessages<ModelMessageFormat>>;
}) => Promise<StreamingTextResponse | ErrorResponse>;
}) => Promise<Response<StreamingTextResponseType>>;
/**
* Chains types.
@@ -54,34 +77,7 @@ export type ModelRequestStream<ModelMessageFormat> = ({
* zod types must have a Schema suffix. For example ResponseSchema.
*/
import type { StreamingTextResponse } from "ai";
/* Used on any kind of error response */
export type ErrorResponse = {
success: false;
type: "generic_error" | string;
status: number;
message: string;
};
export type SuccessResponse<ResponseData> = {
success: true;
data: ResponseData;
};
export type ChainResponseSuccess<ResponseData> = {
success: true;
tokens: number;
data: ResponseData;
llmMessages: ModelMessages[];
};
export type Chain<Model, Context, ResponseData> = (args: {
model: Model;
context: Context;
}) => Promise<ChainResponseSuccess<ResponseData> | ErrorResponse>;
export type ChainStream<Model, Context> = (request: {
model: Model;
context: Context;
}) => Promise<StreamingTextResponse | ErrorResponse>;
}) => Promise<Response<ResponseData> & { llmMessages: ModelMessage[] }>;
@@ -0,0 +1,28 @@
import type { ErrorResponse, Tokens } from "../types";
export const createErrorResponse = ({
status,
error,
message,
debug,
tokens,
}: {
status?: number;
error?: string;
message?: string;
debug?: string;
tokens?: Tokens;
}): ErrorResponse => ({
type: "json",
success: false,
tokens: tokens || {
prompt: -1,
completion: -1,
},
data: {
status: status || 500,
error: error || "ai.unknownError",
message: message || "Something went wrong",
debug: debug || undefined,
},
});
+70 -99
View File
@@ -1,123 +1,94 @@
import { createChunkDecoder } from "ai";
import type { ErrorResponse, SuccessResponse } from "../types";
import { createErrorResponse } from "./create-error-response";
import { StreamingTextResponse } from "./streaming-text-response";
type RequestOptions = {
retry?: number;
onChunk?: (
operationId: string,
data: { decoded: string; value: Uint8Array | undefined; done: boolean }
) => void;
};
export const request = function request<ResponseData>(
export const request = <ResponseData>(
fetchArgs: Parameters<typeof fetch>,
options: RequestOptions = { retry: 0 }
): Promise<SuccessResponse<ResponseData> | ErrorResponse> {
return fetch(...fetchArgs)
.then((res) => {
if (res.ok === false) {
return {
success: false,
type: "generic_error",
status: res.status,
message: res.statusText,
} as ErrorResponse;
}
return res.json();
})
.catch((error) => {
const signal = fetchArgs[1]?.signal;
const isAborted = signal?.aborted === true;
if (
isAborted === false &&
typeof options.retry === "number" &&
options.retry > 0
) {
return requestStream(fetchArgs, {
...options,
retry: options.retry - 1,
});
}
return {
success: false,
type: isAborted ? "aborted" : "generic_error",
status: 500,
message: "",
} as ErrorResponse;
});
};
type RequestStreamOptions = {
onChunk?: (completion: string) => void;
retry?: number;
};
export const requestStream = async function requestStream(
fetchArgs: Parameters<typeof fetch>,
options: RequestStreamOptions = {
retry: 0,
}
): Promise<string | ErrorResponse> {
{ onChunk }: RequestOptions
) => {
const signal = fetchArgs[1]?.signal;
return fetch(...fetchArgs)
.then(async (response) => {
if (response.ok === false || response.body == null) {
if (response.status !== 200) {
return {
success: false,
type: "generic_error",
status: response.status,
message: response.body
? response.statusText
: "The response is empty",
} as ErrorResponse;
}
if (response.headers.get("Content-Type")?.includes("application/json")) {
const json: ErrorResponse = await response.json();
return json;
id: "",
...createErrorResponse({
status: response.status,
error: "ai.unhandledError",
debug: response.statusText,
}),
} as const;
}
let completion = "";
const reader = response.body.getReader();
const decoder = createChunkDecoder();
const isStream =
(response.headers.get("content-type") || "").startsWith("text/plain") &&
response.body instanceof ReadableStream;
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (isStream) {
// @todo Add delimiter to text streaming response to extract the operation id.
const operationId = "copywriter";
if (done) {
break;
let completion = "";
const reader = response.body.getReader();
const decoder = createChunkDecoder();
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
completion += decoder(value);
if (typeof onChunk === "function") {
onChunk(operationId, {
decoded: completion,
value,
done,
});
}
if (signal?.aborted === true) {
reader.cancel();
break;
}
}
completion += decoder(value);
if (typeof options.onChunk === "function") {
options.onChunk(completion);
}
if (signal?.aborted === true) {
reader.cancel();
break;
}
return {
id: operationId,
type: "stream",
success: true,
data: new StreamingTextResponse(
new Blob([completion], { type: "text/plain" }).stream()
),
tokens: { prompt: -1, completion: -1 },
} as const;
}
return completion;
// @todo Convert the response types to Zod
// so that responses can be parsed and validated on the client.
return (await response.json()) as
| SuccessResponse<ResponseData>
| ErrorResponse;
})
.catch((error) => {
const isAborted = signal?.aborted === true;
if (
isAborted === false &&
typeof options.retry === "number" &&
options.retry > 0
) {
return requestStream(fetchArgs, {
...options,
retry: options.retry - 1,
});
}
return {
success: false,
type: isAborted ? "aborted" : "generic_error",
status: 500,
message: "",
} as ErrorResponse;
id: "",
...createErrorResponse({
status: 500,
error: "ai.unhandledError",
debug: error.message + "\n" + error.stack,
}),
} as const;
});
};
@@ -0,0 +1,37 @@
import { StreamingTextResponse as _StreamingTextResponse } from "ai";
export type StreamingTextResponseType = StreamingTextResponse;
// vercel/ai's StreamingTextResponse does not include request.headers.raw()
// which @vercel/remix uses when deployed on vercel.
// Therefore we use a custom one.
export class StreamingTextResponse extends _StreamingTextResponse {
constructor(res: ReadableStream, init?: ResponseInit) {
super(res, init);
this.getRequestHeaders();
}
getRequestHeaders() {
return addRawHeaders(this.headers);
}
}
const addRawHeaders = function addRawHeaders(headers: Headers) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
headers.raw = function () {
const rawHeaders: { [k in string]: string[] } = {};
const headerEntries = headers.entries();
for (const [key, value] of headerEntries) {
const headerKey = key.toLowerCase();
// eslint-disable-next-line no-prototype-builtins
if (rawHeaders.hasOwnProperty(headerKey)) {
rawHeaders[headerKey].push(value);
} else {
rawHeaders[headerKey] = [value];
}
}
return rawHeaders;
};
return headers;
};