langschema
Version:
one-line LLM output parsers for JS/TS
333 lines (320 loc) • 11.3 kB
text/typescript
import {OpenAIApi, Configuration, CreateChatCompletionRequest} from 'openai'
import {z, type ZodError} from 'zod'
import {zodToJsonSchema} from "zod-to-json-schema"
interface GenericPromptOptions {
gpt4?: boolean
}
type AtLeastOne<T> = [T, ...T[]];
async function backoff<T>(
retries: number,
fn: () => Promise<T>,
delay = 500
): Promise<T> {
try {
return await fn();
} catch (error) {
if (retries === 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return backoff(retries - 1, fn, delay * 2);
}
}
function buildLLM() {
const openai = new OpenAIApi(new Configuration({apiKey: process.env.OPENAI_API_KEY}))
return {
createChatCompletion(request: CreateChatCompletionRequest) {
return backoff(10, () => openai.createChatCompletion(request), 500)
}
}
}
function buildLLMOptions(promptOptions?: GenericPromptOptions) {
return {
temperature: 0,
model: promptOptions?.gpt4 ? "gpt-4" : "gpt-3.5-turbo"
}
}
/**
* This function parses the given input into a given Zod type using the OpenAI API. The
* input type can be ANY Zod type, not just an object - a boolean, a number, an enum,
* etc. are all valid inputs.
*
* @export
* @param {string} prompt - The input to parse
* @param zodType - The Zod type to parse the response into.
* @param {GenericPromptOptions} [promptOptions] - Optional settings for the prompt.
* @returns {Promise<T>} A promise that resolves to the parsed value.
*
* @throws {ZodError} If the parsed response does not match the expected structure.
*
* @async
*/
export async function asZodType<T>(prompt: string, zodType: z.ZodType<T>, promptOptions?: GenericPromptOptions): Promise<T> {
if (!prompt) {
return zodType.parse("")
}
const openai = buildLLM()
const llmOptions = buildLLMOptions(promptOptions)
let wrapperZod: any
let shouldWrap = (zodType._def as any).typeName !== "ZodObject"
if (shouldWrap) {
wrapperZod = z.object({value: zodType})
} else {
wrapperZod = zodType
}
const jsonSchema = zodToJsonSchema(wrapperZod, "wrapper").definitions?.wrapper
const result = await openai.createChatCompletion({
...llmOptions,
messages: [
{
role: "system",
content: "Follow the user's instructions exactly, and respond with ONLY what the user requests in valid JSON format. No extraneous information."
},
{
role: "user",
content: prompt
}
],
function_call: {name: "answer"},
functions: [
{
name: "answer",
description: "Answer the user's question",
parameters: jsonSchema
}
]
})
const evaluated = wrapperZod.parse(JSON.parse(result.data.choices[0].message!.function_call!.arguments!))
return shouldWrap ? (evaluated.value as T) : evaluated as T
}
/**
* Asynchronously handles a binary prompt to return a boolean answer.
*
* This function creates a Large Language Model (LLM) from the provided options
* and prompts the user with a message. It then returns a boolean value based on the
* user's answer.
*
* @export
* @param {string} prompt - The prompt message to display to the user.
* @param {GenericPromptOptions} [promptOptions] - Optional settings for the prompt.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating the user's response.
*
* @throws {ZodError} If the parsed response does not match the expected structure.
*
* @async
*/
export async function bool(prompt: string, promptOptions?: GenericPromptOptions): Promise<boolean> {
if (!prompt) {
return false
}
const openai = buildLLM()
const llmOptions = buildLLMOptions(promptOptions)
const result = await openai.createChatCompletion({
...llmOptions,
messages: [
{
role: "system",
content: 'Answer the following question with a true or false.'
},
{
role: "user",
content: prompt
}
],
function_call: {name: "answer"},
functions: [
{
name: "answer",
description: "Answer the user's question",
parameters: {
type: "object",
required: ["value"],
description: "An object containing a boolean value.",
properties: {
value: {
type: "boolean",
description: "The boolean value to return.",
},
},
},
}
]
})
const zBooleanAnswer = z.object({value: z.boolean()})
const answer = JSON.parse(result.data.choices[0].message?.function_call?.arguments as string)
return zBooleanAnswer.parse(answer).value
}
/**
* Asynchronously handles a categorical prompt and returns the classified category
*
* This function creates a Large Language Model (LLM) from the provided options
* and prompts the user with a message. It then returns the selected category,
* which must be one of the provided allowed values.
*
* @export
* @param {string} prompt - The user's question to classify
* @param {AtLeastOne<string>} allowedValues - Array of allowable categorical values.
* @param {GenericPromptOptions} [promptOptions] - Optional settings for the prompt.
* @returns {Promise<string>} A promise that resolves to a string indicating the user's selected category.
*
* @throws {Error} If no prompt is provided.
* @throws {ZodError} If the parsed response does not match the expected structure or is not one of the allowed values.
*
* @async
*/
export async function categorize(prompt: string, allowedValues: AtLeastOne<string>, promptOptions?: GenericPromptOptions): Promise<string> {
if (!prompt) {
throw new Error("Prompt is required")
}
const openai = buildLLM()
const llmOptions = buildLLMOptions(promptOptions)
const result = await openai.createChatCompletion({
...llmOptions,
messages: [
{
role: "system",
content: `Answer the following question with one of the following allowed values: ${allowedValues.join(", ")}. You MUST use the exact spelling and capitalization of the values.`
},
{
role: "user",
content: prompt
}
],
function_call: {name: "answer"},
functions: [
{
name: "answer",
description: "Answer the user's question",
parameters: {
type: "object",
required: ["value"],
properties: {
value: {
type: "string",
enum: allowedValues,
description: "The value to use, MUST be one of the allowed values",
},
},
},
}
]
})
const returnedValue = JSON.parse(result.data.choices[0].message?.function_call?.arguments as string)
const zStringAnswer = z.object({value: z.enum(allowedValues)})
return zStringAnswer.parse(returnedValue).value
}
/**
* Asynchronously handles a list prompt and returns an array of selected values.
*
* This function creates a Large Language Model (LLM) from the provided options
* and prompts the user with a message. The user is expected to select a minimum
* and maximum number of values from the allowed list, and the function returns an array
* of these values.
*
* @export
* @param {string} prompt - The prompt message to display to the user.
* @param {null | AtLeastOne<string>} allowedValues - Array of allowable values. Null indicates that any string is allowed.
* @param {number} [minValues=1] - The minimum number of values the user must select.
* @param {number} [maxValues=5] - The maximum number of values the user can select.
* @param {GenericPromptOptions} [promptOptions] - Optional settings for the prompt.
* @returns {Promise<string[]>} A promise that resolves to an array of strings indicating the user's selected values.
*
* @throws {Error} If no prompt is provided, if minValues is not less than maxValues, or if minValues is not greater than zero.
* @throws {ZodError} If the parsed response does not match the expected structure or is not one of the allowed values.
*
* @async
*/
export async function list(prompt: string, allowedValues: null | AtLeastOne<string>, minValues = 1, maxValues = 5, promptOptions?: GenericPromptOptions): Promise<string[]> {
if (minValues >= maxValues) {
throw new Error("minValues must be less than maxValues")
}
if (minValues < 0) {
throw new Error("minValues must be greater than zero")
}
if (!prompt) {
return []
}
const llmOptions = buildLLMOptions(promptOptions)
const zeroMessage = minValues === 0 ? "You may also answer with no values." : ""
const multipleMessage = minValues > 1 ? "You may also answer with multiple values." : ""
const allowedValuesMessage = allowedValues ? ` of the following allowed values: ${allowedValues.join(", ")}. You MUST use the exact spelling and capitalization of the values` : ""
const itemsType: any = {type: "string"}
if (allowedValues) {
itemsType.enum = allowedValues
}
const openai = buildLLM()
const result = await openai.createChatCompletion({
...llmOptions,
messages: [
{
role: "system",
content: `Answer the following question with AT LEAST ${minValues} and AT MOST ${maxValues}${allowedValuesMessage}. ${multipleMessage}${zeroMessage}`
},
{
role: "user",
content: prompt
}
],
function_call: {name: "answer"},
functions: [
{
name: "answer",
description: "Answer the user's question",
parameters: {
type: "object",
required: ["value"],
properties: {
value: {
type: "array",
description: "The values to use",
minItems: minValues,
maxItems: maxValues,
items: itemsType
},
},
},
}
]
})
const returnedValue = JSON.parse(result.data.choices[0].message?.function_call?.arguments as string)
const zStringArrayAnswer = z.object({
value: z.array(allowedValues ? z.enum(allowedValues) : z.string())
})
const parsedValue = zStringArrayAnswer.parse(returnedValue).value
if (parsedValue.length < minValues) {
throw new Error(`You must provide at least ${minValues} values`)
} else if (parsedValue.length > maxValues) {
parsedValue.splice(maxValues)
}
return parsedValue
}
/**
* Asynchronously handles a string prompt and returns any string response. No ouptut validation is performed.
*
* @export
* @param {string} prompt - The prompt message to display to the user.
* @returns {Promise<string>} A promise that resolves to a string indicating the content of the user's response.
*
* @throws {Error} If the LLM fails to create or call.
*
* @async
*/
export async function string(prompt: string): Promise<string> {
if (!prompt) {
return ""
}
const openai = buildLLM()
const llmOptions = buildLLMOptions()
const result = await openai.createChatCompletion({
...llmOptions,
messages: [
{
role: "system",
content: "You will follow the user's instructions exactly. You will respond with ONLY what the user requests, and NO extraneous information like 'Sure, here you go:', or 'That's a great question!', etc."
},
{
role: "user",
content: prompt
}
],
})
return result.data.choices[0].message!.content!
}