Skip to content

Structured output & json mode #122

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 59 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
39a594d
feat(core): add structured output with JSON schema validation
kieranklaassen Apr 18, 2025
290764a
test: add tests and VCR cassette for structured output
kieranklaassen Apr 18, 2025
1a766d7
docs: add documentation for structured output feature
kieranklaassen Apr 18, 2025
16ce84a
chore: update changelog for v1.3.0
kieranklaassen Apr 18, 2025
9816968
docs: update internal contribution guide
kieranklaassen Apr 18, 2025
2d30f10
feat(core): add system schema guidance for JSON output in chat
kieranklaassen Apr 18, 2025
a651e2d
refactor(core): update Gemini capabilities to support JSON mode and r…
kieranklaassen Apr 18, 2025
0513cea
fix(providers): update render_payload methods to accept chat parameter
kieranklaassen Apr 18, 2025
5a749d2
refactor(gemini): use supports_structured_output instead of json_mode
kieranklaassen Apr 18, 2025
a1e01d4
refactor(providers): update parse_completion_response method to accep…
kieranklaassen Apr 18, 2025
376156e
refactor(chat): enhance with_output_schema method to include strict m…
kieranklaassen Apr 18, 2025
96a9d9c
refactor(providers): update supports_structured_output method signatu…
kieranklaassen Apr 18, 2025
87ddf79
Delete CHANGELOG.md
kieranklaassen Apr 18, 2025
642b3c9
docs(README): add examples for accessing structured data in user profile
kieranklaassen Apr 18, 2025
39c3902
docs(structured-output): enhance documentation for strict and non-str…
kieranklaassen Apr 18, 2025
fb39411
docs(structured-output): expand implementation details and limitation…
kieranklaassen Apr 18, 2025
126bebf
refactor(acts_as): simplify extract_content method implementation
kieranklaassen Apr 18, 2025
21dea58
test(acts_as): update tests for JSON and Hash content handling
kieranklaassen Apr 18, 2025
44a77d5
fix(acts_as): update content assignment in message update
kieranklaassen Apr 18, 2025
e7ee70d
feat(structured-output): implement structured output parsing and enha…
kieranklaassen Apr 18, 2025
68d39eb
refactor(gemini): introduce shared utility methods and enhance struct…
kieranklaassen Apr 18, 2025
320c611
refactor(deepseek): remove unused render_payload method from chat pro…
kieranklaassen Apr 18, 2025
98ff547
fix(models): update structured output support and adjust timestamps
kieranklaassen Apr 18, 2025
65c2215
refactor: rename output_schema methods to response_format for clarity
kieranklaassen Apr 19, 2025
15dc0e4
refactor(chat): enhance response_format handling and add JSON guidance
kieranklaassen Apr 19, 2025
2d19063
refactor(chat): improve model compatibility checks and enhance JSON g…
kieranklaassen Apr 19, 2025
78ba898
refactor(chat): clarify response_format documentation and error handling
kieranklaassen Apr 19, 2025
a20c1b7
feat(json): add support for JSON mode and enhance response format han…
kieranklaassen Apr 19, 2025
370ef1d
feat(capabilities): add method to check model support for JSON mode
kieranklaassen Apr 19, 2025
b8cc7ec
feat(capabilities): add method to check model support for JSON mode a…
kieranklaassen Apr 19, 2025
932bc11
feat(models): add support for JSON mode across multiple providers
kieranklaassen Apr 19, 2025
cc13503
refactor(chat): enhance with_response_format method and update docume…
kieranklaassen Apr 19, 2025
6993978
fix(version): downgrade version to 1.2.0
kieranklaassen Apr 19, 2025
3de661f
feat(models): add support for JSON mode check in Bedrock model specs
kieranklaassen Apr 19, 2025
09d78a6
refactor(chat): update response format handling in OpenAI provider
kieranklaassen Apr 19, 2025
eb2f95b
refactor(readme): streamline badge layout and improve formatting
kieranklaassen Apr 19, 2025
0f1e4d8
docs(structured-output): update documentation for schema-based output…
kieranklaassen Apr 19, 2025
17b179d
refactor(chat): update methods to use response_format instead of chat…
kieranklaassen Apr 21, 2025
acfc00c
chore(.gitignore): add CLAUDE.md to ignore list
kieranklaassen Apr 21, 2025
f93bed3
Delete CLAUDE.md
kieranklaassen Apr 21, 2025
90b57f7
refactor(structured-output): update compatibility checks and paramete…
kieranklaassen Apr 21, 2025
5dfe022
docs(README): improve badge layout and update structured output descr…
kieranklaassen Apr 21, 2025
4a66560
docs(structured-output): streamline Rails integration section and rem…
kieranklaassen Apr 21, 2025
2eb7790
docs(rails): update structured output section and add link to structu…
kieranklaassen Apr 21, 2025
f778328
docs(structured-output): enhance guide with new features, error handl…
kieranklaassen Apr 21, 2025
02af5b2
docs(index): update structured output description to remove 'validati…
kieranklaassen Apr 21, 2025
1897092
refactor(chat): improve response format handling and compatibility ch…
kieranklaassen Apr 21, 2025
a9ee1c5
style
kieranklaassen Apr 21, 2025
0ac9a3d
refactor(chat): add response_format parameter to complete method for …
kieranklaassen Apr 21, 2025
2c72684
Merge main into json-schemas and resolve conflicts
kieranklaassen Apr 21, 2025
0dc953c
refactor(chat): remove redundant comments in parse_completion_respons…
kieranklaassen Apr 21, 2025
837e951
refactor(parser): simplify parse_structured_output method by removing…
kieranklaassen Apr 21, 2025
ad061b5
refactor(chat): integrate structured output parser and enhance parse_…
kieranklaassen Apr 21, 2025
43f9c95
docs(chat): clarify comment on response format requirements for JSON …
kieranklaassen Apr 21, 2025
fc64702
refactor(chat): update response format key check to use :json_schema …
kieranklaassen Apr 21, 2025
629c29c
refactor(chat): enhance guidance handling for response formats and im…
kieranklaassen Apr 21, 2025
7153695
refactor(chat): streamline message handling by adding new message cal…
kieranklaassen Apr 21, 2025
fa06863
docs(rules): add comprehensive documentation for ActiveRecord integra…
kieranklaassen Apr 21, 2025
3a9c515
chore(rules): remove outdated documentation for ActiveRecord integrat…
kieranklaassen Apr 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ Gemfile.lock
# .rubocop-https?--*

repomix-output.*
CLAUDE.md
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ RubyLLM fixes all that. One beautiful API for everything. One consistent format.
- 🖼️ **Image generation** with DALL-E and other providers
- 📊 **Embeddings** for vector search and semantic analysis
- 🔧 **Tools** that let AI use your Ruby code
- 📝 **Structured Output** with JSON schemas
- 🚂 **Rails integration** to persist chats and messages with ActiveRecord
- 🌊 **Streaming** responses with proper Ruby patterns

Expand Down
2 changes: 2 additions & 0 deletions docs/_data/navigation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
url: /guides/image-generation
- title: Embeddings
url: /guides/embeddings
- title: Structured Output
url: /guides/structured-output
- title: Error Handling
url: /guides/error-handling
- title: Models
Expand Down
3 changes: 3 additions & 0 deletions docs/guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Learn how to generate images using DALL-E and other providers.
### [Embeddings]({% link guides/embeddings.md %})
Explore how to create vector embeddings for semantic search and other applications.

### [Structured Output]({% link guides/structured-output.md %})
Learn how to use JSON schemas to get validated structured data from LLMs.

### [Error Handling]({% link guides/error-handling.md %})
Master the techniques for robust error handling in AI applications.

Expand Down
89 changes: 89 additions & 0 deletions docs/guides/rails.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ After reading this guide, you will know:
* How to set up ActiveRecord models for persisting chats and messages.
* How to use `acts_as_chat` and `acts_as_message`.
* How chat interactions automatically persist data.
* How to work with structured output in your Rails models.
* A basic approach for integrating streaming responses with Hotwire/Turbo Streams.

## Setup
Expand Down Expand Up @@ -174,6 +175,93 @@ system_message = chat_record.messages.find_by(role: :system)
puts system_message.content # => "You are a concise Ruby expert."
```

## Working with Structured Output
{: .d-inline-block }

New (v1.3.0)
{: .label .label-green }

RubyLLM supports structured output with JSON schema validation. This works seamlessly with Rails integration, allowing you to get and persist structured data from AI models. See the [Structured Output guide]({% link guides/structured-output.md %}) for more details on schemas and compatibility.

### Database Considerations

For best results with structured output, use a database that supports JSON data natively:

```ruby
# For PostgreSQL, use jsonb for the content column
class CreateMessages < ActiveRecord::Migration[7.1]
def change
create_table :messages do |t|
t.references :chat, null: false, foreign_key: true
t.string :role
t.jsonb :content # Use jsonb instead of text for PostgreSQL
# ...other fields...
end
end
end
```

For databases without native JSON support, you can use text columns with serialization:

```ruby
# app/models/message.rb
class Message < ApplicationRecord
acts_as_message
serialize :content, JSON # Add this for text columns
end
```

### Using Structured Output

The `with_response_format` method is available on your `Chat` model thanks to `acts_as_chat`:

```ruby
# Make sure to use a model that supports structured output
chat_record = Chat.create!(model_id: 'gpt-4.1-nano')

# Define your JSON schema
schema = {
type: "object",
properties: {
name: { type: "string" },
version: { type: "string" },
features: {
type: "array",
items: { type: "string" }
}
},
required: ["name", "version"]
}

begin
# Get structured data instead of plain text
response = chat_record.with_response_format(schema).ask("Tell me about Ruby")

# The response content is a Hash (or serialized JSON in text columns)
response.content # => {"name"=>"Ruby", "version"=>"3.2.0", "features"=>["Blocks", "Procs"]}

# You can access the persisted message as usual
message = chat_record.messages.where(role: 'assistant').last
message.content['name'] # => "Ruby"

# In your views, you can easily display structured data:
# <%= message.content['name'] %> <%= message.content['version'] %>
# <ul>
# <% message.content['features'].each do |feature| %>
# <li><%= feature %></li>
# <% end %>
# </ul>
rescue RubyLLM::UnsupportedStructuredOutputError => e
# Handle case where the model doesn't support structured output
puts "This model doesn't support structured output: #{e.message}"
rescue RubyLLM::InvalidStructuredOutput => e
# Handle case where the model returns invalid JSON
puts "The model returned invalid JSON: #{e.message}"
end
```

With this approach, you can build robust data-driven applications that leverage the structured output capabilities of AI models while properly handling errors.

## Streaming Responses with Hotwire/Turbo

You can combine `acts_as_chat` with streaming and Turbo Streams for real-time UI updates. The persistence logic works seamlessly alongside the streaming block.
Expand Down Expand Up @@ -264,4 +352,5 @@ Your `Chat`, `Message`, and `ToolCall` models are standard ActiveRecord models.
* [Using Tools]({% link guides/tools.md %})
* [Streaming Responses]({% link guides/streaming.md %})
* [Working with Models]({% link guides/models.md %})
* [Structured Output]({% link guides/structured-output.md %})
* [Error Handling]({% link guides/error-handling.md %})
201 changes: 201 additions & 0 deletions docs/guides/structured-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
---
layout: default
title: Structured Output
parent: Guides
nav_order: 7
---

# Structured Output
{: .no_toc .d-inline-block }

New (v1.3.0)
{: .label .label-green }

Get structured, well-formatted data from language models by providing a JSON schema. Use the `with_response_format` method to ensure the AI returns data that matches your schema instead of free-form text.
{: .fs-6 .fw-300 }

## Table of contents
{: .no_toc .text-delta }

1. TOC
{:toc}

---

After reading this guide, you will know:

* How to use JSON schemas to get structured data from language models
* How to request simple JSON responses without a specific schema
* How to work with models that may not officially support structured output
* How to handle errors related to structured output
* Best practices for creating effective JSON schemas

## Getting Structured Data with Schemas

The most powerful way to get structured data is by providing a JSON schema that defines the exact format you need:

```ruby
# Define your JSON schema
schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
interests: { type: "array", items: { type: "string" } }
},
required: ["name", "age", "interests"]
}

# Request data that follows this schema
response = RubyLLM.chat(model: "gpt-4o")
.with_response_format(schema)
.ask("Create a profile for a Ruby developer")

# Access the structured data as a Hash
puts response.content["name"] # => "Ruby Smith"
puts response.content["age"] # => 32
puts response.content["interests"] # => ["Metaprogramming", "Rails", "Testing"]
```

RubyLLM intelligently adapts based on each model's capabilities:

- For models with native schema support (like GPT-4o): Uses the provider's API-level schema validation
- For other models: Automatically adds schema instructions to the system message

## Simple JSON Mode

When you just need well-formed JSON without a specific structure:

```ruby
response = RubyLLM.chat(model: "gpt-4.1-nano")
.with_response_format(:json)
.ask("Create a profile for a Ruby developer")

# The response will be valid JSON but with a format chosen by the model
puts response.content.keys # => ["name", "bio", "skills", "experience", "github"]
```

This simpler approach uses OpenAI's `response_format: {type: "json_object"}` parameter, guaranteeing valid JSON output without enforcing a specific schema structure.

## Working with Unsupported Models

To use structured output with models that don't officially support it, set `assume_supported: true`:

```ruby
response = RubyLLM.chat(model: "gemini-2.0-flash")
.with_response_format(schema, assume_supported: true)
.ask("Create a profile for a Ruby developer")
```

This bypasses compatibility checks and inserts the schema as system instructions. Most modern models can follow these instructions to produce properly formatted JSON, even without native schema support.

## Error Handling

RubyLLM provides specialized error classes for structured output that help you handle different types of issues:

### UnsupportedStructuredOutputError

Raised when a model doesn't support the structured output format and `assume_supported` is false:

```ruby
begin
# Try to use structured output with a model that doesn't support it
response = RubyLLM.chat(model: "gemini-2.0-flash")
.with_response_format(schema)
.ask("Create a profile for a Ruby developer")
rescue RubyLLM::UnsupportedStructuredOutputError => e
puts "This model doesn't support structured output: #{e.message}"
# Fall back to non-structured output or a different model
end
```

### InvalidStructuredOutput

Raised if the model returns a response that can't be parsed as valid JSON:

```ruby
begin
response = RubyLLM.chat(model: "gpt-4o")
.with_response_format(schema)
.ask("Create a profile for a Ruby developer")
rescue RubyLLM::InvalidStructuredOutput => e
puts "The model returned invalid JSON: #{e.message}"
# Handle the error, perhaps by retrying or using a simpler schema
end
```

Note: RubyLLM checks that responses are valid JSON but doesn't verify schema conformance (required fields, data types, etc.). For full schema validation, use a library like `json-schema`.

## With ActiveRecord and Rails

For Rails integration details with structured output, please see the [Rails guide](rails.md#working-with-structured-output).

## Best Practices for JSON Schemas

When creating schemas for structured output, follow these guidelines:

1. **Keep it simple**: Start with the minimum structure needed. More complex schemas can confuse the model.
2. **Be specific with types**: Use appropriate JSON Schema types (`string`, `number`, `boolean`, `array`, `object`) for your data.
3. **Include descriptions**: Add a `description` field to each property to help guide the model.
4. **Mark required fields**: Use the `required` array to indicate which properties must be included.
5. **Provide examples**: When possible, include `examples` for complex properties.
6. **Test thoroughly**: Different models have varying levels of schema compliance.

## Example: Complex Schema

Here's an example of a more complex schema for inventory data:

```ruby
schema = {
type: "object",
properties: {
products: {
type: "array",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Name of the product"
},
price: {
type: "number",
description: "Price in dollars"
},
in_stock: {
type: "boolean",
description: "Whether the item is currently available"
},
categories: {
type: "array",
items: { type: "string" },
description: "List of categories this product belongs to"
}
},
required: ["name", "price", "in_stock"]
}
},
total_products: {
type: "integer",
description: "Total number of products in inventory"
}
},
required: ["products", "total_products"]
}

inventory = RubyLLM.chat(model: "gpt-4o")
.with_response_format(schema)
.ask("Create an inventory for a Ruby gem store")
```

## Limitations

When working with structured output, be aware of these limitations:

* Schema validation is only available at the API level for certain models (primarily OpenAI models)
* RubyLLM validates that responses are valid JSON but doesn't verify schema conformance
* For full schema validation, use a library like `json-schema` to verify output
* Models may occasionally deviate from the schema despite instructions
* Complex, deeply nested schemas may reduce compliance

RubyLLM handles the complexity of supporting different model capabilities, so you can focus on your application logic rather than provider-specific implementation details.
18 changes: 18 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ RubyLLM fixes all that. One beautiful API for everything. One consistent format.
- 🖼️ **Image generation** with DALL-E and other providers
- 📊 **Embeddings** for vector search and semantic analysis
- 🔧 **Tools** that let AI use your Ruby code
- 📝 **Structured Output** with JSON schema
- 🚂 **Rails integration** to persist chats and messages with ActiveRecord
- 🌊 **Streaming** responses with proper Ruby patterns

Expand Down Expand Up @@ -105,6 +106,23 @@ class Weather < RubyLLM::Tool
end

chat.with_tool(Weather).ask "What's the weather in Berlin? (52.5200, 13.4050)"

# Get structured output with JSON schema validation
schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
interests: {
type: "array",
items: { type: "string" }
}
},
required: ["name", "age", "interests"]
}

# Returns a validated Hash instead of plain text
user_data = chat.with_response_format(schema).ask("Create a profile for a Ruby developer")
```

## Quick start
Expand Down
11 changes: 11 additions & 0 deletions lib/ruby_llm/active_record/acts_as.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ def with_temperature(temperature)
self
end

# Specifies the response format for the chat (JSON mode or JSON schema)
# @param response_format [Hash, String, Symbol] The response format, either:
# - :json for simple JSON mode
# - JSON schema as a Hash or JSON string for schema-based output
# @param assume_supported [Boolean] Whether to assume the model supports the requested format (default: false)
# @return [self] Chainable chat instance
def with_response_format(response_format, assume_supported: false)
to_llm.with_response_format(response_format, assume_supported: assume_supported)
self
end

def on_new_message(&)
to_llm.on_new_message(&)
self
Expand Down
Loading