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.

TSX
// 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.

TSX
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.

TSX
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).

TSX
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

TSX
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

TSX
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

TSX
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.