AI SDK Plus
Adds a new generateObjectPlus function that is a enhanced version of the default generateObject function with some new concepts.
At a Glance
Self-Healing Error Recovery
Automatically fixes validation and JSON errors
Conversational Continuity
Follow-up conversations with context memory
Tool Calling + Structured Output
Use tools while maintaining structured responses
Key Features
Self-Healing Error Recovery
Automatically catches and recovers from schema validation failures, malformed JSON, and other common LLM output issues. This has dramatically reduced production failures in my enterprise deployments. The max number of retries can be adjusted with the maxRetries
parameter. Self-healing works independently of maxSteps
- even with maxSteps: 1
, the retry mechanism will still activate to fix validation errors.
// The LLM doesn't get passed the refine part
// of the parameters in the schema, so we trick
// it into failing as we say pass 1 and 100 which
// will fail the refine, so you can see the self-healing.
const { object, sessionMessages } = await generateObjectPlus({
model: openai('gpt-4.1-nano'),
prompt: 'Return 1 and 100',
schema: z.object({
value: z.number().refine((value) => value > 10, {
message: 'Value must be greater than 10',
}),
value2: z.number().refine((value) => value < 10, {
message: 'Value must be less than 10',
}),
}),
})
// ⬇️ Here is the message history to show you how self healing fixed the validation issues automatically.
console.log(JSON.stringify(sessionMessages, null, 2))
// [
// {
// "role": "user",
// "content": "Return 1 and 100"
// },
// ⬇️ The AI first returns 1 and 100 as instructed in the prompt.
// {
// "role": "assistant",
// "content": [{"type": "text", "text": "{\"value\":1,\"value2\":100}"}]
// },
// ⬇️ The zod schema validation fails and the AI is automatically asked to fix the issues by the AI SDK Plus.
// {
// "role": "user",
// "content": "Your response had validation errors:\n- value: Value must be greater than 10\n- value2: Value must be less than 10\nPlease fix these issues and try again."
// },
// ⬇️❤️🩹 The AI fixes the issues and returns 11 and 9. You never know it failed in your code and can happily use the correctly returned object.
// {
// "role": "assistant",
// "content": [{"type": "text", "text": "{\"value\":11,\"value2\":9}"}]
// }
// ]
console.log(object)
// { value: 11, value2: 9 }
// The LLM doesn't get passed the refine part
// of the parameters in the schema, so we trick
// it into failing as we say pass 1 and 100 which
// will fail the refine, so you can see the self-healing.
const { object, sessionMessages } = await generateObjectPlus({
model: openai('gpt-4.1-nano'),
prompt: 'Return 1 and 100',
schema: z.object({
value: z.number().refine((value) => value > 10, {
message: 'Value must be greater than 10',
}),
value2: z.number().refine((value) => value < 10, {
message: 'Value must be less than 10',
}),
}),
})
// ⬇️ Here is the message history to show you how self healing fixed the validation issues automatically.
console.log(JSON.stringify(sessionMessages, null, 2))
// [
// {
// "role": "user",
// "content": "Return 1 and 100"
// },
// ⬇️ The AI first returns 1 and 100 as instructed in the prompt.
// {
// "role": "assistant",
// "content": [{"type": "text", "text": "{\"value\":1,\"value2\":100}"}]
// },
// ⬇️ The zod schema validation fails and the AI is automatically asked to fix the issues by the AI SDK Plus.
// {
// "role": "user",
// "content": "Your response had validation errors:\n- value: Value must be greater than 10\n- value2: Value must be less than 10\nPlease fix these issues and try again."
// },
// ⬇️❤️🩹 The AI fixes the issues and returns 11 and 9. You never know it failed in your code and can happily use the correctly returned object.
// {
// "role": "assistant",
// "content": [{"type": "text", "text": "{\"value\":11,\"value2\":9}"}]
// }
// ]
console.log(object)
// { value: 11, value2: 9 }
Conversational Continuity - askFollowUp
Calling generateObjectPlus
returns an askFollowUp
function that maintains conversation history and context, similar to if you use ChatGPT you don't create a new chat for every message. This enables natural back-and-forth interactions where the AI can reference previous messages and build upon earlier context.
const handleConversation = async ({
conversationId,
emailHistory,
}: {
conversationId: string
emailHistory: string
}) => {
const {
object: { followUpEmailNeeded },
askFollowUp,
} = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
prompt: `Here is our email history: ${emailHistory}
Is any of our questions unanswered and do we need to send them a follow up email?`,
schema: z.object({
followUpEmailNeeded: z.boolean(),
unansweredQuestions: z.array(z.string()),
}),
})
// If the ai doesn't think we need to send a follow up email, we can
// mark the conversation as done and don't even bother generating a email.
if (!followUpEmailNeeded) {
await markConversationAsDone({ conversationId })
return
}
// This sends a new message in the same chat/thread so the ai still
// has the email history and what it thought are the unanswered questions
const {
object: { subject, emailBody },
} = await askFollowUp({
prompt: 'Ok then pls generate a email that i can send them',
schema: z.object({
subject: z.string(),
emailBody: z.string(),
}),
})
await sendFollowUpEmail({ subject, emailBody, conversationId })
}
const handleConversation = async ({
conversationId,
emailHistory,
}: {
conversationId: string
emailHistory: string
}) => {
const {
object: { followUpEmailNeeded },
askFollowUp,
} = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
prompt: `Here is our email history: ${emailHistory}
Is any of our questions unanswered and do we need to send them a follow up email?`,
schema: z.object({
followUpEmailNeeded: z.boolean(),
unansweredQuestions: z.array(z.string()),
}),
})
// If the ai doesn't think we need to send a follow up email, we can
// mark the conversation as done and don't even bother generating a email.
if (!followUpEmailNeeded) {
await markConversationAsDone({ conversationId })
return
}
// This sends a new message in the same chat/thread so the ai still
// has the email history and what it thought are the unanswered questions
const {
object: { subject, emailBody },
} = await askFollowUp({
prompt: 'Ok then pls generate a email that i can send them',
schema: z.object({
subject: z.string(),
emailBody: z.string(),
}),
})
await sendFollowUpEmail({ subject, emailBody, conversationId })
}
Tool Calling with Structured Output
Unlike the standard generateObject, this supports tool calling via the maxSteps
parameter while maintaining structured output. Essential for building multi-step AI agents that need both tool access and reliable data structures.
const { object } = await generateObjectPlus({
model: openai('gpt-4o-mini'),
schema: z.object({
temperature: z.number(),
condition: z.string(),
}),
maxSteps: 5, // Enable tool calling
tools: {
getWeather: {
description: 'Get current weather',
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => ({ temp: 22, condition: 'sunny' })
}
},
prompt: 'What is the weather in Paris?'
})
const { object } = await generateObjectPlus({
model: openai('gpt-4o-mini'),
schema: z.object({
temperature: z.number(),
condition: z.string(),
}),
maxSteps: 5, // Enable tool calling
tools: {
getWeather: {
description: 'Get current weather',
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => ({ temp: 22, condition: 'sunny' })
}
},
prompt: 'What is the weather in Paris?'
})
Installation
To use it, you can copy the code below and use it in your project. Self healing is enabled by default but you can customize how many retries it is allowed by passing maxRetries (default 5).
import { CoreMessage, generateText, Output } from 'ai'
import { TypeOf, z, ZodIssue, ZodObject } from 'zod'
/* ----------------------------------------------------------------------------
* Types & helpers
* ------------------------------------------------------------------------- */
/**
* Parameter bag of `generateText` – prevents duplication below.
*/
type GenerateTextParams = Parameters<typeof generateText>[0]
/**
* Error shape emitted by the AI SDK (condensed to what we need here).
*/
interface AIError {
response?: { messages?: CoreMessage[]; text?: string }
value?: unknown
cause?: {
name?: string
cause?: {
issues?: ZodIssue[]
}
}
[key: string]: any
}
/**
* Given a schema `T`, derive the concrete object type – or `undefined` when no
* schema is supplied (i.e., `undefined`).
*/
export type OutputType<T extends ZodObject<any> | undefined> =
T extends ZodObject<any> ? TypeOf<T> : undefined
/**
* Successful return shape. The function throws on error, so no `error` field.
*/
export interface GenerateObjectResult<T> {
/** Validated object from the model (never null when schema provided, undefined when no schema). */
object: T
/** Full conversation history (system ➜ prompt ➜ all messages). */
sessionMessages: CoreMessage[]
/** Raw response payload from the SDK (for advanced inspection). */
response?: unknown
/** Raw `experimental_output` from the SDK, if requested. */
experimental_output?: unknown
/**
* Helper to push a follow-up user prompt into the *same* session without
* re-building all args manually. Optionally accepts a new schema for validation.
*/
askFollowUp: <
TNewSchema extends ZodObject<any> | undefined = undefined
>(opts: {
prompt: string
schema?: TNewSchema
}) => Promise<GenerateObjectResult<OutputType<TNewSchema>>>
}
/* ------------------------------------------------------------------------- *
* Utility: Build the conversation array that gets passed to the model.
* ------------------------------------------------------------------------- */
const createSessionMessages = ({
system,
prompt,
messages = [],
}: {
system?: string
prompt?: string
messages?: CoreMessage[]
}): CoreMessage[] => {
const session: CoreMessage[] = []
if (system) session.push({ role: 'system', content: system })
if (prompt) session.push({ role: 'user', content: prompt })
session.push(...messages)
return session
}
/* ------------------------------------------------------------------------- *
* Utility: Clean JSON from markdown code blocks
* Attempts to extract JSON from markdown code blocks (```json{...}```)
* ------------------------------------------------------------------------- */
const cleanJsonFromMarkdown = ({ text }: { text: string }): string => {
// If no text is provided, return empty string
if (!text) return ''
console.log(
'Cleaning JSON from markdown, raw text:',
text.substring(0, 100) + '...'
)
// Check for code blocks with json tag - this pattern is more flexible
const jsonCodeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/
const match = jsonCodeBlockRegex.exec(text)
if (match && match[1]) {
const cleanedContent = match[1].trim()
console.log(
'Extracted JSON from markdown:',
cleanedContent.substring(0, 100) + '...'
)
return cleanedContent
}
// If no code blocks found, return the original text
return text
}
/* ------------------------------------------------------------------------- *
* Utility: Balance brackets in potentially malformed JSON
* ------------------------------------------------------------------------- */
const balanceBrackets = ({ text }: { text: string }): string => {
try {
// Count opening and closing brackets/braces
const openBraces = (text.match(/\{/g) || []).length
const closeBraces = (text.match(/\}/g) || []).length
const openBrackets = (text.match(/\[/g) || []).length
const closeBrackets = (text.match(/\]/g) || []).length
let result = text
// Balance braces if needed
if (openBraces > closeBraces) {
const missingBraces = openBraces - closeBraces
result += '}'.repeat(missingBraces)
console.log(`Added ${missingBraces} closing braces to balance JSON`)
}
// Balance brackets if needed
if (openBrackets > closeBrackets) {
const missingBrackets = openBrackets - closeBrackets
result += ']'.repeat(missingBrackets)
console.log(`Added ${missingBrackets} closing brackets to balance JSON`)
}
return result
} catch (e) {
console.log('Error in balanceBrackets:', e)
return text
}
}
/* ------------------------------------------------------------------------- *
* Utility: Try to manually parse JSON safely
* ------------------------------------------------------------------------- */
const tryParseJson = ({
text,
}: {
text: string
}): { success: boolean; result: any } => {
try {
// Clean the text first to remove any markdown code block markers
const cleanedText = cleanJsonFromMarkdown({ text })
// Remove any special characters that might cause parsing issues
const sanitizedText = cleanedText
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // Ensure property names are quoted
.replace(/\u2028/g, '') // Remove line separator character
.replace(/\u2029/g, '') // Remove paragraph separator character
console.log(
'Sanitized JSON (first 100 chars):',
sanitizedText.substring(0, 100) + '...'
)
// First try to parse as is
try {
const result = JSON.parse(sanitizedText)
console.log('Successfully parsed JSON manually on first attempt')
return { success: true, result }
} catch (parseError) {
console.log(
'First parse attempt failed, trying to balance brackets:',
parseError
)
// If that fails, try to balance brackets and parse again
const balancedText = balanceBrackets({ text })
const result = JSON.parse(balancedText)
console.log('Successfully parsed JSON manually after balancing brackets')
return { success: true, result }
}
} catch (e) {
console.log('All JSON parsing attempts failed:', e)
return { success: false, result: null }
}
}
/* ------------------------------------------------------------------------- *
* Utility: Turn Zod issues into a friendly bullet list we can send back to
* the model as feedback.
* ------------------------------------------------------------------------- */
const formatZodErrors = ({ issues }: { issues: ZodIssue[] }): string =>
[
'Your response had validation errors:',
...issues.map((i) => `- ${i.path.join('.')}: ${i.message}`),
'Please fix these issues and try again.',
].join('\n')
/* ------------------------------------------------------------------------- *
* Utility: Create askFollowUp helper function
* ------------------------------------------------------------------------- */
const createAskFollowUp = ({
maxRetries,
sessionMessages,
restParams,
}: {
maxRetries: number
sessionMessages: CoreMessage[]
restParams: Omit<GenerateTextParams, 'messages' | 'prompt' | 'system'>
}) => {
return async <TNewSchema extends ZodObject<any> | undefined = undefined>({
prompt: followUpPrompt,
schema: newSchema,
}: {
prompt: string
schema?: TNewSchema
}): Promise<GenerateObjectResult<OutputType<TNewSchema>>> => {
const result = await generateObjectPlus({
schema: newSchema,
maxRetries,
...restParams,
messages: [...sessionMessages, { role: 'user', content: followUpPrompt }],
})
return result as GenerateObjectResult<OutputType<TNewSchema>>
}
}
/* ----------------------------------------------------------------------------
* generateObjectPlus
* ------------------------------------------------------------------------- */
/**
* Safer, schema-aware alternative to `generateText` / `generateObject`.
*
* @template TSchema Optional Zod schema that the output must satisfy.
*
* @param schema Zod schema used to validate the model output.
* @param maxRetries Number of validation retries (default `2`).
*
* @throws Re-throws the last SDK error when validation never succeeds or when
* the error is unrelated to validation – i.e. behaves just like
* `generateObject`.
*/
export const generateObjectPlus = async <
TSchema extends ZodObject<any> | undefined = undefined
>({
schema,
prompt,
messages,
system,
maxRetries = 5,
currentRetry = 0,
...rest
}: GenerateTextParams & {
schema?: TSchema
maxRetries?: number
currentRetry?: number
}): Promise<GenerateObjectResult<OutputType<TSchema>>> => {
/* 1️⃣ Compose the conversation history for this invocation. */
const sessionMessages = createSessionMessages({
system,
prompt,
messages: messages as CoreMessage[],
})
try {
/* 2️⃣ Send the request. */
const generateArgs: GenerateTextParams = {
...rest,
prompt,
messages,
system,
experimental_output:
rest.experimental_output ??
(schema ? Output.object({ schema }) : undefined),
}
const result = await generateText(generateArgs)
/* Add any assistant messages returned to our log. */
if (result?.response?.messages)
sessionMessages.push(...result.response.messages)
/* 3️⃣ Success – build the helper & return. */
type Out = OutputType<TSchema>
const askFollowUp = createAskFollowUp({
maxRetries,
sessionMessages,
restParams: rest,
})
return {
...result,
object: (schema
? typeof result.experimental_output === 'object'
? (result.experimental_output as Out)
: undefined
: undefined) as Out,
sessionMessages,
askFollowUp,
}
} catch (err) {
/* 4️⃣ Error handling. */
const error = err as AIError
/* Preserve any assistant messages that came along with the error. */
if (error.response?.messages)
sessionMessages.push(...error.response.messages)
const validationIssues = error.cause?.cause?.issues
const isValidationError = error.cause?.name === 'AI_TypeValidationError'
const isJSONParseError =
error['vercel.ai.error.AI_JSONParseError'] === true ||
error['vercel.ai.error.AI_NoObjectGeneratedError'] === true ||
(typeof error.message === 'string' &&
error.message.toLowerCase().includes('parse'))
const isAbortError =
error.code === 'ABORT_ERR' ||
error.name === 'AbortError' ||
(typeof error.message === 'string' &&
error.message.toLowerCase().includes('abort'))
/* 4a.1 ▸ Try to manually recover from JSON parse errors by cleaning markdown */
if (isJSONParseError) {
console.log(
'Detected JSON parse error, attempting to fix markdown issues'
)
// Get text from the error response if available
const responseText =
error.response?.text ||
error.text ||
(typeof error.message === 'string' ? error.message : '')
if (responseText) {
console.log('Response text found, attempting to clean and parse')
const { success, result: parsedJson } = tryParseJson({
text: responseText,
})
if (success) {
console.log(
'Successfully parsed the JSON, checking schema validation'
)
if (schema) {
// Try to validate the cleaned JSON against the schema
try {
const validated = schema.parse(parsedJson)
console.log(
'Successfully recovered and validated JSON from markdown code blocks'
)
// Create a success result manually
const askFollowUp = createAskFollowUp({
maxRetries,
sessionMessages,
restParams: rest,
})
const successResult: GenerateObjectResult<OutputType<TSchema>> = {
object: validated as OutputType<TSchema>,
sessionMessages,
response: error.response,
askFollowUp,
}
return successResult
} catch (validationErr) {
// If validation fails, log the specific validation errors
console.log(
'Cleaned JSON validation failed, showing validation errors:'
)
if (validationErr instanceof z.ZodError) {
console.log(JSON.stringify(validationErr.errors, null, 2))
} else {
console.log('Unknown validation error:', validationErr)
}
console.log('Continuing with retry flow')
}
} else {
// If no schema is provided but we have valid JSON, return it directly
console.log('No schema provided, returning undefined for object')
const askFollowUp = createAskFollowUp({
maxRetries,
sessionMessages,
restParams: rest,
})
const successResult: GenerateObjectResult<OutputType<TSchema>> = {
object: undefined as OutputType<TSchema>,
sessionMessages,
response: error.response,
askFollowUp,
}
return successResult
}
} else {
console.log('Failed to parse JSON')
}
} else {
console.log('No response text found in error object')
}
}
/* 4a ▸ Retry on validation errors while we have attempts left. */
if (isValidationError && validationIssues && currentRetry < maxRetries) {
const errorMessage = formatZodErrors({ issues: validationIssues })
console.log(
`Trying to fix Zod validation error ${currentRetry + 1}/${maxRetries}`
)
return generateObjectPlus<TSchema>({
schema,
maxRetries,
currentRetry: currentRetry + 1,
...rest,
messages: [...sessionMessages, { role: 'user', content: errorMessage }],
})
}
/* 4b ▸ Retry on JSON parse errors while we have attempts left. */
if (isJSONParseError && currentRetry < maxRetries) {
const jsonErrorMessage =
'Your response had a JSON parsing error. Please provide a valid JSON response without using code blocks or markdown formatting. Ensure your response is plain JSON without any backticks or ```json markers.'
console.log(
`Trying to fix JSON parse error ${currentRetry + 1}/${maxRetries}`
)
return generateObjectPlus<TSchema>({
schema,
maxRetries,
currentRetry: currentRetry + 1,
...rest,
messages: [
...sessionMessages,
{ role: 'user', content: jsonErrorMessage },
],
})
}
/* 4c ▸ Retry on AbortError while we have attempts left. */
if (isAbortError && currentRetry < maxRetries) {
console.log(`Trying to fix AbortError ${currentRetry + 1}/${maxRetries}`)
return generateObjectPlus<TSchema>({
schema,
maxRetries,
currentRetry: currentRetry + 1,
...rest,
messages: sessionMessages,
})
}
/* 4d ▸ Otherwise propagate the error (mirrors `generateObject`). */
;(error as any).sessionMessages = sessionMessages // attach context
throw error
}
}
import { CoreMessage, generateText, Output } from 'ai'
import { TypeOf, z, ZodIssue, ZodObject } from 'zod'
/* ----------------------------------------------------------------------------
* Types & helpers
* ------------------------------------------------------------------------- */
/**
* Parameter bag of `generateText` – prevents duplication below.
*/
type GenerateTextParams = Parameters<typeof generateText>[0]
/**
* Error shape emitted by the AI SDK (condensed to what we need here).
*/
interface AIError {
response?: { messages?: CoreMessage[]; text?: string }
value?: unknown
cause?: {
name?: string
cause?: {
issues?: ZodIssue[]
}
}
[key: string]: any
}
/**
* Given a schema `T`, derive the concrete object type – or `undefined` when no
* schema is supplied (i.e., `undefined`).
*/
export type OutputType<T extends ZodObject<any> | undefined> =
T extends ZodObject<any> ? TypeOf<T> : undefined
/**
* Successful return shape. The function throws on error, so no `error` field.
*/
export interface GenerateObjectResult<T> {
/** Validated object from the model (never null when schema provided, undefined when no schema). */
object: T
/** Full conversation history (system ➜ prompt ➜ all messages). */
sessionMessages: CoreMessage[]
/** Raw response payload from the SDK (for advanced inspection). */
response?: unknown
/** Raw `experimental_output` from the SDK, if requested. */
experimental_output?: unknown
/**
* Helper to push a follow-up user prompt into the *same* session without
* re-building all args manually. Optionally accepts a new schema for validation.
*/
askFollowUp: <
TNewSchema extends ZodObject<any> | undefined = undefined
>(opts: {
prompt: string
schema?: TNewSchema
}) => Promise<GenerateObjectResult<OutputType<TNewSchema>>>
}
/* ------------------------------------------------------------------------- *
* Utility: Build the conversation array that gets passed to the model.
* ------------------------------------------------------------------------- */
const createSessionMessages = ({
system,
prompt,
messages = [],
}: {
system?: string
prompt?: string
messages?: CoreMessage[]
}): CoreMessage[] => {
const session: CoreMessage[] = []
if (system) session.push({ role: 'system', content: system })
if (prompt) session.push({ role: 'user', content: prompt })
session.push(...messages)
return session
}
/* ------------------------------------------------------------------------- *
* Utility: Clean JSON from markdown code blocks
* Attempts to extract JSON from markdown code blocks (```json{...}```)
* ------------------------------------------------------------------------- */
const cleanJsonFromMarkdown = ({ text }: { text: string }): string => {
// If no text is provided, return empty string
if (!text) return ''
console.log(
'Cleaning JSON from markdown, raw text:',
text.substring(0, 100) + '...'
)
// Check for code blocks with json tag - this pattern is more flexible
const jsonCodeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/
const match = jsonCodeBlockRegex.exec(text)
if (match && match[1]) {
const cleanedContent = match[1].trim()
console.log(
'Extracted JSON from markdown:',
cleanedContent.substring(0, 100) + '...'
)
return cleanedContent
}
// If no code blocks found, return the original text
return text
}
/* ------------------------------------------------------------------------- *
* Utility: Balance brackets in potentially malformed JSON
* ------------------------------------------------------------------------- */
const balanceBrackets = ({ text }: { text: string }): string => {
try {
// Count opening and closing brackets/braces
const openBraces = (text.match(/\{/g) || []).length
const closeBraces = (text.match(/\}/g) || []).length
const openBrackets = (text.match(/\[/g) || []).length
const closeBrackets = (text.match(/\]/g) || []).length
let result = text
// Balance braces if needed
if (openBraces > closeBraces) {
const missingBraces = openBraces - closeBraces
result += '}'.repeat(missingBraces)
console.log(`Added ${missingBraces} closing braces to balance JSON`)
}
// Balance brackets if needed
if (openBrackets > closeBrackets) {
const missingBrackets = openBrackets - closeBrackets
result += ']'.repeat(missingBrackets)
console.log(`Added ${missingBrackets} closing brackets to balance JSON`)
}
return result
} catch (e) {
console.log('Error in balanceBrackets:', e)
return text
}
}
/* ------------------------------------------------------------------------- *
* Utility: Try to manually parse JSON safely
* ------------------------------------------------------------------------- */
const tryParseJson = ({
text,
}: {
text: string
}): { success: boolean; result: any } => {
try {
// Clean the text first to remove any markdown code block markers
const cleanedText = cleanJsonFromMarkdown({ text })
// Remove any special characters that might cause parsing issues
const sanitizedText = cleanedText
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // Ensure property names are quoted
.replace(/\u2028/g, '') // Remove line separator character
.replace(/\u2029/g, '') // Remove paragraph separator character
console.log(
'Sanitized JSON (first 100 chars):',
sanitizedText.substring(0, 100) + '...'
)
// First try to parse as is
try {
const result = JSON.parse(sanitizedText)
console.log('Successfully parsed JSON manually on first attempt')
return { success: true, result }
} catch (parseError) {
console.log(
'First parse attempt failed, trying to balance brackets:',
parseError
)
// If that fails, try to balance brackets and parse again
const balancedText = balanceBrackets({ text })
const result = JSON.parse(balancedText)
console.log('Successfully parsed JSON manually after balancing brackets')
return { success: true, result }
}
} catch (e) {
console.log('All JSON parsing attempts failed:', e)
return { success: false, result: null }
}
}
/* ------------------------------------------------------------------------- *
* Utility: Turn Zod issues into a friendly bullet list we can send back to
* the model as feedback.
* ------------------------------------------------------------------------- */
const formatZodErrors = ({ issues }: { issues: ZodIssue[] }): string =>
[
'Your response had validation errors:',
...issues.map((i) => `- ${i.path.join('.')}: ${i.message}`),
'Please fix these issues and try again.',
].join('\n')
/* ------------------------------------------------------------------------- *
* Utility: Create askFollowUp helper function
* ------------------------------------------------------------------------- */
const createAskFollowUp = ({
maxRetries,
sessionMessages,
restParams,
}: {
maxRetries: number
sessionMessages: CoreMessage[]
restParams: Omit<GenerateTextParams, 'messages' | 'prompt' | 'system'>
}) => {
return async <TNewSchema extends ZodObject<any> | undefined = undefined>({
prompt: followUpPrompt,
schema: newSchema,
}: {
prompt: string
schema?: TNewSchema
}): Promise<GenerateObjectResult<OutputType<TNewSchema>>> => {
const result = await generateObjectPlus({
schema: newSchema,
maxRetries,
...restParams,
messages: [...sessionMessages, { role: 'user', content: followUpPrompt }],
})
return result as GenerateObjectResult<OutputType<TNewSchema>>
}
}
/* ----------------------------------------------------------------------------
* generateObjectPlus
* ------------------------------------------------------------------------- */
/**
* Safer, schema-aware alternative to `generateText` / `generateObject`.
*
* @template TSchema Optional Zod schema that the output must satisfy.
*
* @param schema Zod schema used to validate the model output.
* @param maxRetries Number of validation retries (default `2`).
*
* @throws Re-throws the last SDK error when validation never succeeds or when
* the error is unrelated to validation – i.e. behaves just like
* `generateObject`.
*/
export const generateObjectPlus = async <
TSchema extends ZodObject<any> | undefined = undefined
>({
schema,
prompt,
messages,
system,
maxRetries = 5,
currentRetry = 0,
...rest
}: GenerateTextParams & {
schema?: TSchema
maxRetries?: number
currentRetry?: number
}): Promise<GenerateObjectResult<OutputType<TSchema>>> => {
/* 1️⃣ Compose the conversation history for this invocation. */
const sessionMessages = createSessionMessages({
system,
prompt,
messages: messages as CoreMessage[],
})
try {
/* 2️⃣ Send the request. */
const generateArgs: GenerateTextParams = {
...rest,
prompt,
messages,
system,
experimental_output:
rest.experimental_output ??
(schema ? Output.object({ schema }) : undefined),
}
const result = await generateText(generateArgs)
/* Add any assistant messages returned to our log. */
if (result?.response?.messages)
sessionMessages.push(...result.response.messages)
/* 3️⃣ Success – build the helper & return. */
type Out = OutputType<TSchema>
const askFollowUp = createAskFollowUp({
maxRetries,
sessionMessages,
restParams: rest,
})
return {
...result,
object: (schema
? typeof result.experimental_output === 'object'
? (result.experimental_output as Out)
: undefined
: undefined) as Out,
sessionMessages,
askFollowUp,
}
} catch (err) {
/* 4️⃣ Error handling. */
const error = err as AIError
/* Preserve any assistant messages that came along with the error. */
if (error.response?.messages)
sessionMessages.push(...error.response.messages)
const validationIssues = error.cause?.cause?.issues
const isValidationError = error.cause?.name === 'AI_TypeValidationError'
const isJSONParseError =
error['vercel.ai.error.AI_JSONParseError'] === true ||
error['vercel.ai.error.AI_NoObjectGeneratedError'] === true ||
(typeof error.message === 'string' &&
error.message.toLowerCase().includes('parse'))
const isAbortError =
error.code === 'ABORT_ERR' ||
error.name === 'AbortError' ||
(typeof error.message === 'string' &&
error.message.toLowerCase().includes('abort'))
/* 4a.1 ▸ Try to manually recover from JSON parse errors by cleaning markdown */
if (isJSONParseError) {
console.log(
'Detected JSON parse error, attempting to fix markdown issues'
)
// Get text from the error response if available
const responseText =
error.response?.text ||
error.text ||
(typeof error.message === 'string' ? error.message : '')
if (responseText) {
console.log('Response text found, attempting to clean and parse')
const { success, result: parsedJson } = tryParseJson({
text: responseText,
})
if (success) {
console.log(
'Successfully parsed the JSON, checking schema validation'
)
if (schema) {
// Try to validate the cleaned JSON against the schema
try {
const validated = schema.parse(parsedJson)
console.log(
'Successfully recovered and validated JSON from markdown code blocks'
)
// Create a success result manually
const askFollowUp = createAskFollowUp({
maxRetries,
sessionMessages,
restParams: rest,
})
const successResult: GenerateObjectResult<OutputType<TSchema>> = {
object: validated as OutputType<TSchema>,
sessionMessages,
response: error.response,
askFollowUp,
}
return successResult
} catch (validationErr) {
// If validation fails, log the specific validation errors
console.log(
'Cleaned JSON validation failed, showing validation errors:'
)
if (validationErr instanceof z.ZodError) {
console.log(JSON.stringify(validationErr.errors, null, 2))
} else {
console.log('Unknown validation error:', validationErr)
}
console.log('Continuing with retry flow')
}
} else {
// If no schema is provided but we have valid JSON, return it directly
console.log('No schema provided, returning undefined for object')
const askFollowUp = createAskFollowUp({
maxRetries,
sessionMessages,
restParams: rest,
})
const successResult: GenerateObjectResult<OutputType<TSchema>> = {
object: undefined as OutputType<TSchema>,
sessionMessages,
response: error.response,
askFollowUp,
}
return successResult
}
} else {
console.log('Failed to parse JSON')
}
} else {
console.log('No response text found in error object')
}
}
/* 4a ▸ Retry on validation errors while we have attempts left. */
if (isValidationError && validationIssues && currentRetry < maxRetries) {
const errorMessage = formatZodErrors({ issues: validationIssues })
console.log(
`Trying to fix Zod validation error ${currentRetry + 1}/${maxRetries}`
)
return generateObjectPlus<TSchema>({
schema,
maxRetries,
currentRetry: currentRetry + 1,
...rest,
messages: [...sessionMessages, { role: 'user', content: errorMessage }],
})
}
/* 4b ▸ Retry on JSON parse errors while we have attempts left. */
if (isJSONParseError && currentRetry < maxRetries) {
const jsonErrorMessage =
'Your response had a JSON parsing error. Please provide a valid JSON response without using code blocks or markdown formatting. Ensure your response is plain JSON without any backticks or ```json markers.'
console.log(
`Trying to fix JSON parse error ${currentRetry + 1}/${maxRetries}`
)
return generateObjectPlus<TSchema>({
schema,
maxRetries,
currentRetry: currentRetry + 1,
...rest,
messages: [
...sessionMessages,
{ role: 'user', content: jsonErrorMessage },
],
})
}
/* 4c ▸ Retry on AbortError while we have attempts left. */
if (isAbortError && currentRetry < maxRetries) {
console.log(`Trying to fix AbortError ${currentRetry + 1}/${maxRetries}`)
return generateObjectPlus<TSchema>({
schema,
maxRetries,
currentRetry: currentRetry + 1,
...rest,
messages: sessionMessages,
})
}
/* 4d ▸ Otherwise propagate the error (mirrors `generateObject`). */
;(error as any).sessionMessages = sessionMessages // attach context
throw error
}
}
Examples
Here are some examples of how to use AI SDK Plus in practice.
Return a structured object with a tool call
const location = 'Freiburg in Germany'
const { object } = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
schema: z.object({
degreesInCelsius: z.number(),
weather: z.enum(['sunny', 'cloudy', 'rainy']),
}),
maxSteps: 10,
tools: {
getWeather: {
description: 'Get the weather for a given location',
parameters: z.object({
location: z.string(),
}),
execute: async ({ location }) => {
// This is a mock implementation of the getWeather tool.
// In a real world scenario, you would call an external API to get the weather.
// For the sake of this example, we'll just return a fixed value.
return {
degreesInCelsius: 29,
weather: 'sunny',
}
},
},
},
prompt: `What is the weather in ${location}?`,
})
console.log(`It is ${object.degreesInCelsius} degrees and ${object.weather} in ${location}.`)
// It is 29 degrees and sunny in Freiburg in Germany.
const location = 'Freiburg in Germany'
const { object } = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
schema: z.object({
degreesInCelsius: z.number(),
weather: z.enum(['sunny', 'cloudy', 'rainy']),
}),
maxSteps: 10,
tools: {
getWeather: {
description: 'Get the weather for a given location',
parameters: z.object({
location: z.string(),
}),
execute: async ({ location }) => {
// This is a mock implementation of the getWeather tool.
// In a real world scenario, you would call an external API to get the weather.
// For the sake of this example, we'll just return a fixed value.
return {
degreesInCelsius: 29,
weather: 'sunny',
}
},
},
},
prompt: `What is the weather in ${location}?`,
})
console.log(`It is ${object.degreesInCelsius} degrees and ${object.weather} in ${location}.`)
// It is 29 degrees and sunny in Freiburg in Germany.
Using askFollowUp to send a follow up
const emails = await getEmails()
const { object: { doWeWantToSendAFollowUp }, askFollowUp } = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
schema: z.object({
doWeWantToSendAFollowUp: z.boolean(),
}),
prompt: `Do we want to send a follow up email to clarify something?
Emails: ${emails.join(', ')}
`,
})
if (doWeWantToSendAFollowUp) {
console.log('Generating follow up email')
// We do not need to send the emails again as it has the messages we send before still in its context.
const { object: { followUpEmail } } = await askFollowUp({
prompt: 'Generate a follow up email to clarify something',
schema: z.object({
followUpEmail: z.string(),
}),
})
console.log(followUpEmail)
await sendEmail(followUpEmail)
} else {
console.log('No follow up needed')
}
const emails = await getEmails()
const { object: { doWeWantToSendAFollowUp }, askFollowUp } = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
schema: z.object({
doWeWantToSendAFollowUp: z.boolean(),
}),
prompt: `Do we want to send a follow up email to clarify something?
Emails: ${emails.join(', ')}
`,
})
if (doWeWantToSendAFollowUp) {
console.log('Generating follow up email')
// We do not need to send the emails again as it has the messages we send before still in its context.
const { object: { followUpEmail } } = await askFollowUp({
prompt: 'Generate a follow up email to clarify something',
schema: z.object({
followUpEmail: z.string(),
}),
})
console.log(followUpEmail)
await sendEmail(followUpEmail)
} else {
console.log('No follow up needed')
}
Looping askFollowUp till we have enough results
const minResults = 10
const query = 'Pizza'
const restaurantSchema = z.object({
name: z.string(),
address: z.string(),
})
const restaurantsStorage = []
let askFollowUpStorage = null
const { object: restaurants, askFollowUp } = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
prompt: `Find me restaurants that serve ${query}`,
schema: z.object({
restaurants: z.array(restaurantSchema),
}),
maxSteps: 10,
})
restaurantsStorage.push(...restaurants.restaurants)
askFollowUpStorage = askFollowUp
while (restaurantsStorage.length < minResults) {
// Doesn't need maxSteps as it uses the maxSteps from the first call, same for model and tools
const { object: restaurants } = await askFollowUpStorage({
prompt: 'Pls find more restaurants',
schema: z.object({
restaurants: z.array(restaurantSchema),
}),
})
restaurantsStorage.push(...restaurants.restaurants)
askFollowUpStorage = askFollowUp
}
console.log(`Found ${restaurantsStorage.length} restaurants`)
console.log(restaurantsStorage)
const minResults = 10
const query = 'Pizza'
const restaurantSchema = z.object({
name: z.string(),
address: z.string(),
})
const restaurantsStorage = []
let askFollowUpStorage = null
const { object: restaurants, askFollowUp } = await generateObjectPlus({
model: openai('gpt-4.1-mini'),
prompt: `Find me restaurants that serve ${query}`,
schema: z.object({
restaurants: z.array(restaurantSchema),
}),
maxSteps: 10,
})
restaurantsStorage.push(...restaurants.restaurants)
askFollowUpStorage = askFollowUp
while (restaurantsStorage.length < minResults) {
// Doesn't need maxSteps as it uses the maxSteps from the first call, same for model and tools
const { object: restaurants } = await askFollowUpStorage({
prompt: 'Pls find more restaurants',
schema: z.object({
restaurants: z.array(restaurantSchema),
}),
})
restaurantsStorage.push(...restaurants.restaurants)
askFollowUpStorage = askFollowUp
}
console.log(`Found ${restaurantsStorage.length} restaurants`)
console.log(restaurantsStorage)
Goals & Community
My primary goal is helping others build reliable AI solutions. Secondarily, I hope the AI SDK team considers incorporating some concepts natively. The code is largely LLM-generated and may contain bugs - tough I am actively using it in prod and it is working well - I'll update it as I find issues. Pull requests are welcome to help identify and fix any problems. Please note that I don't take responsibility if something doesn't work correctly in your implementation.