Skip to content
Closed
Show file tree
Hide file tree
Changes from 37 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
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# CLAUDE.md
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: should we include or not?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like yet another thing to manage IMO, but if you don't think it'll need to be modified often then sure, why not I guess?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no


This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build & Test Commands
- Build: `bundle exec rake build`
- Install dependencies: `bundle install`
- Run all tests: `bundle exec rspec`
- Run specific test: `bundle exec rspec spec/ruby_llm/chat_spec.rb`
- Run specific test by description: `bundle exec rspec -e "description"`
- Re-record VCR cassettes: `bundle exec rake vcr:record[all]` or `bundle exec rake vcr:record[openai,anthropic]`
- Check style: `bundle exec rubocop`
- Auto-fix style: `bundle exec rubocop -A`

## Code Style Guidelines
- Follow [Standard Ruby](https://github.com/testdouble/standard) style
- Use frozen_string_literal comment at the top of each file
- Follow model naming conventions from CONTRIBUTING.md when adding providers
- Use RSpec for tests with descriptive test names that form clean VCR cassettes
- Handle errors with specific error classes from RubyLLM::Error
- Use method keyword arguments with Ruby 3+ syntax
- Document public APIs with YARD comments
- Maintain backward compatibility for minor version changes
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,9 @@ A delightful Ruby way to work with AI. No configuration madness, no complex call
<img src="https://upload.wikimedia.org/wikipedia/commons/e/ec/DeepSeek_logo.svg" alt="DeepSeek" height="40" width="120">
</div>

<a href="https://badge.fury.io/rb/ruby_llm"><img src="https://badge.fury.io/rb/ruby_llm.svg" alt="Gem Version" /></a>
<a href="https://github.com/testdouble/standard"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Ruby Style Guide" /></a>
<a href="https://rubygems.org/gems/ruby_llm"><img alt="Gem Downloads" src="https://img.shields.io/gem/dt/ruby_llm"></a>
<a href="https://codecov.io/gh/crmne/ruby_llm"><img src="https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg" alt="codecov" /></a>
<a href="https://badge.fury.io/rb/ruby_llm"><img src="https://badge.fury.io/rb/ruby_llm.svg" alt="Gem Version" /></a> <a href="https://github.com/testdouble/standard"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Ruby Style Guide" /></a> <a href="https://rubygems.org/gems/ruby_llm"><img alt="Gem Downloads" src="https://img.shields.io/gem/dt/ruby_llm"></a> <a href="https://codecov.io/gh/crmne/ruby_llm"><img src="https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg" alt="codecov" /></a>

🤺 Battle tested at [💬 Chat with Work](https://chatwithwork.com)
🤺 Battle tested at [💬 Chat with Work](https://chatwithwork.com)

## The problem with AI libraries

Expand All @@ -36,6 +33,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 validation
- 🚂 **Rails integration** to persist chats and messages with ActiveRecord
- 🌊 **Streaming** responses with proper Ruby patterns

Expand Down Expand Up @@ -83,6 +81,28 @@ 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")

# Access the structured data using hash keys
puts "Name: #{user_data.content['name']}" # => "Jane Smith"
puts "Age: #{user_data.content['age']}" # => 32
puts "Interests: #{user_data.content['interests'].join(', ')}" # => "Ruby, Rails, API design"
```

## Installation
Expand Down Expand Up @@ -214,6 +234,7 @@ Check out the guides at https://rubyllm.com for deeper dives into conversations
We welcome contributions to RubyLLM!

See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed instructions on how to:

- Run the test suite
- Add new features
- Update documentation
Expand Down
2 changes: 2 additions & 0 deletions docs/_data/navigation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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
84 changes: 84 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,89 @@ system_message = chat_record.messages.find_by(role: :system)
puts system_message.content # => "You are a concise Ruby expert."
```

## Working with Structured Output

RubyLLM 1.3.0+ supports structured output with JSON schema validation. This works seamlessly with Rails integration, allowing you to get and persist structured data from AI models.

### 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
160 changes: 160 additions & 0 deletions docs/guides/structured-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
layout: default
title: Structured Output
parent: Guides
nav_order: 7
---

# Structured Output

RubyLLM allows you to request structured data from language models by providing a JSON schema. When you use the `with_response_format` method, RubyLLM will ensure the model returns data matching your schema instead of free-form text.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try to follow the new format for the docs, e.g.:

# Configuring RubyLLM
{: .no_toc }

This guide covers all the configuration options available in RubyLLM, from setting API keys and default models to customizing connection behavior and using scoped contexts.
{: .fs-6 .fw-300 }

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

1. TOC
{:toc}

---

After reading this guide, you will know:

*   How to set up global configuration using `RubyLLM.configure`.
*   How to configure API keys for different providers.
*   How to set default models for chat, embeddings, and image generation.
*   How to customize connection timeouts and retries.
*   How to connect to custom endpoints (like Azure OpenAI).
*   How to use temporary, scoped configurations with `RubyLLM.context`.


## Schema-Based Output (Recommended)

We recommend providing a schema for structured data:

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

response = RubyLLM.chat(model: "gpt-4o")
.with_response_format(schema)
.ask("Create a profile for a Ruby developer")
```

RubyLLM intelligently handles your schema based on the model's capabilities:

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

## Simple JSON Mode (Alternative)

For cases where you just need well-formed JSON:

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

This uses OpenAI's `response_format: {type: "json_object"}` parameter, works with most OpenAI models, and guarantees valid JSON without enforcing a specific structure.

## Strict and Non-Strict Modes
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced about the naming here. For example for skipping the model registry check I used assume_model_exists which clarifies the intention of the flag. The first time I read strict I had no idea what the API was gonna be strict about? The output format? How?

I would go for assume_supported: true

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it


By default, RubyLLM operates in "strict mode" which only allows models that officially support the requested output format. If you try to use a schema with a model that doesn't support schema validation, RubyLLM will raise an `UnsupportedStructuredOutputError`.

For broader compatibility, you can disable strict mode:

```ruby
# Use schema with a model that doesn't currently support schema validation on RubyLLM
response = RubyLLM.chat(model: "gemini-2.0-flash")
.with_response_format(schema, strict: false)
.ask("Create a profile for a Ruby developer")
```

In non-strict mode:

- RubyLLM doesn't validate if the model supports the requested format
- The schema is automatically added to the system message
- JSON parsing is handled automatically
- Works with most models that can produce JSON output, including Claude and Gemini

This allows you to use schema-based output with a wider range of models, though without API-level schema validation.

## Error Handling

RubyLLM provides two main error types for structured output:

1. **UnsupportedStructuredOutputError**: Raised when using schema-based output with a model that doesn't support it in strict mode:
2. **InvalidStructuredOutput**: Raised if the model returns invalid JSON:

Note: RubyLLM checks that responses are valid JSON but doesn't verify conformance to the schema structure. For full schema validation, use a library like `json-schema`.

## With ActiveRecord and Rails
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is repeated. instead just link the correct section


The structured output feature works seamlessly with RubyLLM's Rails integration. Message content can be either a String or a Hash.

If you're storing message content in your database, ensure your messages table can store JSON. PostgreSQL's `jsonb` column type is ideal:

```ruby
# In a migration
create_table :messages do |t|
t.references :chat
t.string :role
t.jsonb :content # Use jsonb for efficient JSON storage
# other fields...
end
```

If you have an existing application with a text-based content column, add serialization:

```ruby
# In your Message model
class Message < ApplicationRecord
serialize :content, JSON
acts_as_message
end
```

## Tips for Effective Schemas

1. **Be specific**: Provide clear property descriptions to guide the model.
2. **Start simple**: Begin with basic schemas and add complexity gradually.
3. **Include required fields**: Specify which properties are required.
4. **Use appropriate types**: Match JSON Schema types to your expected data.
5. **Validate locally**: Consider using a gem like `json-schema` for additional validation.
6. **Test model compatibility**: Different models have different levels of schema support.

## Example: Complex Schema

```ruby
schema = {
type: "object",
properties: {
products: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
price: { type: "number" },
in_stock: { type: "boolean" },
categories: {
type: "array",
items: { type: "string" }
}
},
required: ["name", "price", "in_stock"]
}
},
total_products: { type: "integer" },
store_info: {
type: "object",
properties: {
name: { type: "string" },
location: { type: "string" }
}
}
},
required: ["products", "total_products"]
}

inventory = chat.with_response_format(schema) # Let RubyLLM handle the schema formatting
.ask("Create an inventory for a Ruby gem store")
```

### Limitations

- Schema validation is only available at the API level for certain OpenAI models
- No enforcement of required fields or data types without external validation
- For full schema validation, use a library like `json-schema` to verify the output

RubyLLM handles all the complexity of supporting different model capabilities, so you can focus on your application logic.
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 validation
- 🚂 **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
Loading