Skip to main content
LeapSDK provides powerful constrained generation capabilities that enable you to generate structured JSON output with compile-time validation. This feature ensures the AI model produces responses that conform to your predefined types, and works across both iOS and Android platforms.

Overview

Constrained generation allows you to:
  • Define structured output formats using native types on each platform
  • Get compile-time validation of your type definitions
  • Generate JSON responses that are guaranteed to match your structures
  • Decode responses directly into type-safe objects

How It Works

The constrained generation system works through a three-step process:
  1. Compile-time: The @Generatable annotation (Kotlin) or macro (Swift) analyzes your type and generates a JSON schema based on property types and @Guide descriptions
  2. Runtime: The generation options are configured with the generated schema to constrain the model’s output
  3. Generation: The LLM produces valid JSON that conforms to your structure, which you can deserialize directly into your typed object
The JSON schema generation happens at compile time, not runtime, ensuring optimal performance.

Setup

The constrained generation annotations are included in the LeapSDK dependency. No additional setup is required beyond your existing LeapSDK integration.
import ai.liquid.leap.structuredoutput.Generatable
import ai.liquid.leap.structuredoutput.Guide

Defining Structured Types

Use the @Generatable and @Guide annotations (Kotlin) or macros (Swift) to define types for structured output.

Basic Example

Only Kotlin data classes can be annotated with @Generatable, and all the fields of the data class should be declared in the parameter of the constructor. The @Guide annotation adds descriptions to individual fields to help the AI understand what each field should contain.
@Generatable(description = "A joke with metadata")
data class Joke(
    @Guide(description = "The joke text")
    val text: String,

    @Guide(description = "The category of humor (pun, dad-joke, programming, etc.)")
    val category: String,

    @Guide(description = "Humor rating from 1-10")
    val rating: Int,

    @Guide(description = "Whether the joke is suitable for children")
    val kidFriendly: Boolean,
)

Setting the Response Format

Use setResponseFormatType() in GenerationOptions to set up the constraint:
val options = GenerationOptions.build {
    // Set the response format to follow `Joke`
    setResponseFormatType(Joke::class)
    // Example of other parameters
    minP = 0.0f
    temperature = 0.7f
}

conversation.generateResponse("Create a programming joke in JSON format", options)
If you want to add the JSON Schema into the prompt to help the generation, you can get the raw JSON Schema with JSONSchemaGenerator:
val jsonSchema = JSONSchemaGenerator.getJSONSchema(Joke::class)
conversation.generateResponse(
    "Create a programming joke following this JSON Schema: $jsonSchema",
    options
)
If the JSON Schema cannot be created from the provided data class, a LeapGeneratableSchematizationException will be thrown.

Deserializing Output

Use GeneratableFactory.createFromJSONObject() to deserialize the JSON string generated by the model into the generatable data class:
import ai.liquid.leap.structuredoutput.GeneratableFactory

conversation.generateResponse(
    "Create a programming joke.",
    options
).onEach {
    if (it is MessageResponse.Complete) {
        val message = it.fullMessage
        val jsonContent = (message.content.first() as ChatMessageContent.Text).text

        // Deserialize the content as a `Joke` object.
        val joke: Joke = GeneratableFactory.createFromJSONObject(
            JSONObject(jsonContent),
        )

        println("Text: ${joke.text}")
        println("Category: ${joke.category}")
        println("Rating: ${joke.rating}/10")
        println("Kid-friendly: ${joke.kidFriendly}")
    }
}.collect()
If the JSON string generated by the model is not valid for creating instances of the generatable data class, a LeapGeneratableDeserializationException will be thrown.

Supported Data Types

Not all data types are supported in constrained generation. Here is the list of supported JSON Schema types:
JSON Schema TypeKotlin TypesSwift Types
StringStringString
IntegerInt, LongInt
NumberFloat, DoubleDouble
BooleanBooleanBool
EnumEnum class (plain name strings as values)—
ObjectData classes annotated with @GeneratableStructs with @Generatable macro
ArrayList, MutableList of supported types; arrays of integer, float, and boolean[T] (Array) of supported types
Optional—T? (Optional)
Only if the data types of the fields of object types and the items of array types are themselves supported can these composition types be used.

Advanced Examples

Complex Nested Structures

@Generatable(description = "Facts about a city")
data class CityFact(
    @Guide(description = "Name of the city")
    val name: String,

    @Guide(description = "State/province of the city")
    val state: String,

    @Guide(description = "Country name")
    val country: String,

    @Guide(description = "Places of interest in the city")
    val placeOfInterests: List<String>,
)

// Usage
val options = GenerationOptions.build {
    setResponseFormatType(CityFact::class)
    temperature = 0.7f
}

conversation.generateResponse("Show the city facts about Tokyo", options)
    .onEach {
        if (it is MessageResponse.Complete) {
            val message = it.fullMessage
            val jsonContent = (message.content.first() as ChatMessageContent.Text).text
            val cityFact: CityFact = GeneratableFactory.createFromJSONObject(
                JSONObject(jsonContent),
            )
        }
    }.collect()

Mathematical Problem Solving

@Generatable(description = "Mathematical calculation result with detailed steps")
data class MathResult(
    @Guide(description = "The mathematical expression that was solved")
    val expression: String,

    @Guide(description = "The final numeric result")
    val result: Double,

    @Guide(description = "Step-by-step solution process")
    val steps: List<String>,

    @Guide(description = "The mathematical operation type (addition, multiplication, etc.)")
    val operationType: String,

    @Guide(description = "Whether the solution is exact or approximate")
    val isExact: Boolean,
)

// Usage
val options = GenerationOptions.build {
    setResponseFormatType(MathResult::class)
    temperature = 0.3f  // Lower temperature for mathematical accuracy
}

conversation.generateResponse(
    "Solve: 15 x 4 + 8 / 2. Show your work step by step.",
    options
)

Data Analysis Results

@Generatable(description = "Statistical summary of data")
data class StatisticalSummary(
    @Guide(description = "Total number of data points")
    val totalPoints: Int,

    @Guide(description = "Mean value")
    val mean: Double,

    @Guide(description = "Standard deviation")
    val standardDeviation: Double,

    @Guide(description = "Minimum value observed")
    val minimum: Double,

    @Guide(description = "Maximum value observed")
    val maximum: Double,
)

@Generatable(description = "Analysis results for a dataset")
data class DataAnalysis(
    @Guide(description = "Name or description of the dataset")
    val datasetName: String,

    @Guide(description = "Key insights discovered")
    val insights: List<String>,

    @Guide(description = "Statistical summary")
    val statistics: StatisticalSummary,

    @Guide(description = "Recommended next steps")
    val recommendations: List<String>,
)

Best Practices

1. Use Descriptive Guide Annotations

Good @Guide descriptions help the AI understand what each field should contain:
// Good - specific and descriptive
@Guide(description = "The programming language name (e.g., Kotlin, Python, JavaScript)")
val language: String

// Less helpful - too generic
@Guide(description = "A string")
val language: String

2. Keep Structures Focused

Smaller, well-defined types work better than large complex ones:
// Good - focused single responsibility
@Generatable(description = "A user's basic profile information")
data class UserProfile(
    @Guide(description = "Full name") val name: String,
    @Guide(description = "Email address") val email: String,
    @Guide(description = "Age in years") val age: Int,
)

// Less ideal - too many responsibilities
@Generatable(description = "Everything about a user")
data class ComplexUser(
    // ... 20+ properties mixing profile, preferences, history, etc.
)

3. Handle Optional Fields Appropriately

Use optional types when fields might not always be present:
@Generatable(description = "A book review")
data class BookReview(
    @Guide(description = "The book title")
    val title: String,

    @Guide(description = "Review text")
    val reviewText: String,

    @Guide(description = "Rating from 1-5 stars, if provided")
    val rating: Int?,  // Nullable - reviewer might not provide a rating

    @Guide(description = "Reviewer's name, if available")
    val reviewerName: String?,  // Nullable - might be anonymous
)

4. Validate Generated Output

Always handle potential parsing errors gracefully:
try {
    val result: Joke = GeneratableFactory.createFromJSONObject(
        JSONObject(jsonContent),
    )
    processJoke(result)
} catch (e: LeapGeneratableDeserializationException) {
    println("Failed to deserialize structured response: ${e.message}")
    // Fallback to treating as plain text
    processPlainText(jsonContent)
}

Error Handling

Schema Errors

If the JSON Schema cannot be created from the provided data class, a LeapGeneratableSchematizationException will be thrown at the point where setResponseFormatType() is called.

Deserialization Errors

If the JSON string generated by the model is not valid for creating instances of the generatable data class, a LeapGeneratableDeserializationException will be thrown by GeneratableFactory.createFromJSONObject().
try {
    val result: Joke = GeneratableFactory.createFromJSONObject(
        JSONObject(jsonContent),
    )
    processJoke(result)
} catch (e: LeapGeneratableDeserializationException) {
    println("Deserialization failed: ${e.message}")
    processPlainText(jsonContent)
} catch (e: LeapGeneratableSchematizationException) {
    println("Schema generation failed: ${e.message}")
}

Troubleshooting

Generated JSON doesn’t match expected format

  • Check your @Guide descriptions are clear and specific
  • Try adjusting the temperature in GenerationOptions (lower values like 0.3-0.5 can improve structured output)
  • Include the JSON Schema in the prompt using JSONSchemaGenerator.getJSONSchema() to give the model additional guidance
  • Ensure your prompt clearly requests JSON format output

LeapGeneratableSchematizationException thrown

  • Verify that only supported data types are used in your data class fields
  • Ensure the class is a data class, not a regular class
  • All fields must be declared in the primary constructor
If you encounter persistent issues with constrained generation, try testing with a simpler structure first to verify the basic functionality is working.