Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,65 @@ schema.to_json_schema
# }
```

### Dependencies

Use `requires:` inline or `dependent` block to express that the presence of one property requires others. Maps to [`dependentRequired`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentRequired) (Draft 2019-09) and [`dependentSchemas`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentSchemas) (Draft 2019-09). Check your provider's documentation for compatibility.

```ruby
class PaymentSchema < RubyLLM::Schema
string :name
number :credit_card, required: false, requires: %i[billing_address cvv]
string :billing_address, required: false
string :cvv, required: false
end
```

Use a `dependent` block when you also need validations — this upgrades the output to `dependentSchemas`:

```ruby
dependent :credit_card do
requires :billing_address
validates :billing_address, type: :string, min_length: 1
end
```

### Conditionals

Use `given` to add [JSON Schema `if`/`then`/`else`](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) (Draft 7) rules. Condition values are automatically coerced: strings → `const`, arrays → `enum`, regexps → `pattern`, hashes → raw schema.

```ruby
class OrderSchema < RubyLLM::Schema
string :status, enum: ["pending", "shipped", "cancelled"]
string :tracking_number, required: false
string :cancellation_reason, required: false

given status: "shipped" do
requires :tracking_number
end

given status: "cancelled" do
requires :cancellation_reason
validates :cancellation_reason, type: :string, min_length: 1
end
end
```

`validates` supports: `type:`, `not_value:`, `min_length:`, `max_length:`, `pattern:` (string or regexp), `enum:`, `const:`, `minimum:`, `maximum:`.

Use `otherwise` for an `else` branch:

```ruby
given domestic: true do
requires :state

otherwise do
requires :country
end
end
```

Conditions propagate through nested schemas via `of:`.

## JSON Output

```ruby
Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_llm/schema/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require_relative "dsl/schema_builders"
require_relative "dsl/primitive_types"
require_relative "dsl/complex_types"
require_relative "dsl/conditionals"
require_relative "dsl/utilities"

module RubyLLM
Expand All @@ -11,6 +12,7 @@ module DSL
include SchemaBuilders
include PrimitiveTypes
include ComplexTypes
include Conditionals
include Utilities
end
end
Expand Down
16 changes: 8 additions & 8 deletions lib/ruby_llm/schema/dsl/complex_types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ module RubyLLM
class Schema
module DSL
module ComplexTypes
def object(name, description: nil, required: true, **options, &block)
add_property(name, object_schema(description: description, **options, &block), required: required)
def object(name, description: nil, required: true, requires: nil, **options, &block)
add_property(name, object_schema(description: description, **options, &block), required: required, requires: requires)
end

def array(name, description: nil, required: true, **options, &block)
add_property(name, array_schema(description: description, **options, &block), required: required)
def array(name, description: nil, required: true, requires: nil, **options, &block)
add_property(name, array_schema(description: description, **options, &block), required: required, requires: requires)
end

def any_of(name, description: nil, required: true, **options, &block)
add_property(name, any_of_schema(description: description, **options, &block), required: required)
def any_of(name, description: nil, required: true, requires: nil, **options, &block)
add_property(name, any_of_schema(description: description, **options, &block), required: required, requires: requires)
end

def one_of(name, description: nil, required: true, **options, &block)
add_property(name, one_of_schema(description: description, **options, &block), required: required)
def one_of(name, description: nil, required: true, requires: nil, **options, &block)
add_property(name, one_of_schema(description: description, **options, &block), required: required, requires: requires)
end

def optional(name, description: nil, &block)
Expand Down
169 changes: 169 additions & 0 deletions lib/ruby_llm/schema/dsl/conditionals.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# frozen_string_literal: true

module RubyLLM
class Schema
module DSL
module Conditionals
def conditions
@conditions ||= []
end

def dependencies
@dependencies ||= {}
end

def dependent(property, &block)
builder = ConditionalBuilder.new
builder.instance_eval(&block)

dependencies[property.to_s] = builder
end

def given(**properties, &block)
raise ArgumentError, "given requires at least one property condition" if properties.empty?

if_schema = {
properties: properties.transform_keys(&:to_s).transform_values { |v| coerce_condition(v) },
required: properties.keys.map(&:to_s)
}

then_builder = ConditionalBuilder.new
else_builder = ConditionalBuilder.new

context = ConditionalContext.new(then_builder, else_builder)
context.instance_eval(&block)

condition = {if: if_schema, then: then_builder.to_schema}
condition[:else] = else_builder.to_schema unless else_builder.empty?

conditions << condition
end

private

def merge_conditions(schema, schema_class)
if schema_class.respond_to?(:conditions) && schema_class.conditions.any?
if schema_class.conditions.length == 1
schema.merge!(schema_class.conditions.first)
else
schema[:allOf] = schema_class.conditions
end
end

if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any?
dependent_required = {}
dependent_schemas = {}

schema_class.dependencies.each do |property, builder|
if builder.validations_empty?
dependent_required[property] = builder.required_fields
else
dependent_schemas[property] = builder.to_schema
end
end

schema[:dependentRequired] = dependent_required if dependent_required.any?
schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any?
end

schema
end

def coerce_condition(value)
case value
when Array then {enum: value}
when Regexp then {pattern: value.source}
when Hash then value
else {const: value}
end
end
end

class ConditionalContext
def initialize(then_builder, else_builder)
@then_builder = then_builder
@else_builder = else_builder
end

def requires(*fields)
@then_builder.requires(*fields)
end

def validates(field, **options)
@then_builder.validates(field, **options)
end

def otherwise(&block)
@else_builder.instance_eval(&block)
end
end

class ConditionalBuilder
def requires(*fields)
required.concat(fields.map(&:to_s))
end

VALIDATES_KEY_MAP = {
type: :type,
const: :const,
enum: :enum,
not_value: :not,
min_length: :minLength,
max_length: :maxLength,
pattern: :pattern,
minimum: :minimum,
maximum: :maximum
}.freeze

def validates(field, **options)
constraints = {}

options.each do |key, value|
schema_key = VALIDATES_KEY_MAP[key]
raise ArgumentError, "unknown validates option: #{key.inspect}" unless schema_key

case key
when :type then constraints[:type] = value.to_s
when :not_value then constraints[:not] = {const: value}
when :pattern then constraints[:pattern] = value.is_a?(Regexp) ? value.source : value
else constraints[schema_key] = value
end
end

validations[field.to_s] = constraints
end

def to_schema
schema = {}

schema[:required] = required if required.any?
schema[:properties] = validations if validations.any?

schema
end

def empty?
required.empty? && validations.empty?
end

def required_fields
required.dup
end

def validations_empty?
validations.empty?
end

private

def required
@required ||= []
end

def validations
@validations ||= {}
end
end
end
end
end
20 changes: 10 additions & 10 deletions lib/ruby_llm/schema/dsl/primitive_types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@ module RubyLLM
class Schema
module DSL
module PrimitiveTypes
def string(name, description: nil, required: true, **options)
add_property(name, string_schema(description: description, **options), required: required)
def string(name, description: nil, required: true, requires: nil, **options)
add_property(name, string_schema(description: description, **options), required: required, requires: requires)
end

def number(name, description: nil, required: true, **options)
add_property(name, number_schema(description: description, **options), required: required)
def number(name, description: nil, required: true, requires: nil, **options)
add_property(name, number_schema(description: description, **options), required: required, requires: requires)
end

def integer(name, description: nil, required: true, **options)
add_property(name, integer_schema(description: description, **options), required: required)
def integer(name, description: nil, required: true, requires: nil, **options)
add_property(name, integer_schema(description: description, **options), required: required, requires: requires)
end

def boolean(name, description: nil, required: true, **options)
add_property(name, boolean_schema(description: description, **options), required: required)
def boolean(name, description: nil, required: true, requires: nil, **options)
add_property(name, boolean_schema(description: description, **options), required: required, requires: requires)
end

def null(name, description: nil, required: true, **options)
add_property(name, null_schema(description: description, **options), required: required)
def null(name, description: nil, required: true, requires: nil, **options)
add_property(name, null_schema(description: description, **options), required: required, requires: requires)
end
end
end
Expand Down
7 changes: 6 additions & 1 deletion lib/ruby_llm/schema/dsl/schema_builders.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ def object_schema(description: nil, of: nil, reference: nil, &block)
schema_class_to_inline_schema(result).merge(description ? {description: description} : {})
# Block didn't return reference or schema, so we build an inline object schema
else
{
schema = {
type: "object",
properties: sub_schema.properties,
required: sub_schema.required_properties,
additionalProperties: sub_schema.additional_properties,
description: description
}.compact

merge_conditions(schema, sub_schema)
end
end
end
Expand Down Expand Up @@ -180,7 +182,10 @@ def schema_class_to_inline_schema(schema_class_or_instance)
else
schema_class_or_instance.instance_variable_get(:@description) || schema_class.description
end

schema[:description] = description if description

merge_conditions(schema, schema_class)
end
end
end
Expand Down
14 changes: 12 additions & 2 deletions lib/ruby_llm/schema/dsl/utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ def define(name, &)
sub_schema = Class.new(Schema)
sub_schema.class_eval(&)

definitions[name] = {
schema = {
type: "object",
properties: sub_schema.properties,
required: sub_schema.required_properties,
additionalProperties: sub_schema.additional_properties
}

merge_conditions(schema, sub_schema)

definitions[name] = schema
end

def reference(schema_name)
Expand All @@ -27,7 +31,7 @@ def reference(schema_name)

private

def add_property(name, definition, required:)
def add_property(name, definition, required:, requires: nil)
property_name = name.to_sym

properties[property_name] = definition
Expand All @@ -37,6 +41,12 @@ def add_property(name, definition, required:)
required_properties.delete(property_name)
end

if requires
builder = ConditionalBuilder.new
builder.requires(*Array(requires))
dependencies[name.to_s] = builder
end

nil
end

Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_llm/schema/json_output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ def to_json_schema
# Only include $defs if there are definitions
schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty?

self.class.send(:merge_conditions, schema_hash, self.class)

{
name: @name,
description: @description || self.class.description,
Expand Down
Loading