Skip to content
Closed
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,9 @@ The SDK includes a Tapioca DSL compiler for application-defined subclasses of
`OpenAI::BaseModel`. When Tapioca loads your application, running
`bundle exec tapioca dsl` generates typed readers for fields declared with
`required`, including nested models, arrays, enums, unions, and fields declared
with `nil?: true`.
with `nil?: true`. Structured-output model readers materialize these values
even when a model is constructed or assigned with hashes. Accessing a field
with `[]` or `to_h` still returns the original caller-provided value.

Response `parsed` fields can contain different application-defined models, so
their generated SDK type remains broad. Cast a parsed value to the structured
Expand All @@ -943,6 +945,7 @@ output model supplied with the request before accessing its generated readers:
```ruby
event = T.cast(content.parsed, CalendarEvent)
puts(event.name)
puts(event.participants.fetch(0).first_name)
```

The compiler is only loaded by Tapioca; using the SDK normally still does not
Expand Down
8 changes: 8 additions & 0 deletions lib/openai/helpers/structured_output/array_of.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ module StructuredOutput
class ArrayOf < OpenAI::Internal::Type::ArrayOf
include OpenAI::Helpers::StructuredOutput::JsonSchemaConverter

# @api public
#
# @param other [Object]
# @return [Boolean]
def ===(other)
super || (nilable? && other.is_a?(Array) && other.compact.all?(item_type))
end

# @api private
#
# @param state [Hash{Symbol=>Object}]
Expand Down
38 changes: 38 additions & 0 deletions lib/openai/helpers/structured_output/base_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ def to_json_schema_inner(state:)
end

class << self
# @api private
def required(name_sym, type_info, spec = {})
Comment thread
jbeckwith-oai marked this conversation as resolved.
super

field = known_fields.fetch(name_sym)
type = field.fetch(:type_fn)
nilable = field.fetch(:nilable)
# Preserve the original reader's validation without replacing raw field storage.
readers = @structured_output_readers ||= Module.new.tap { prepend(_1) }
readers.define_method(name_sym) do
return nil if nilable && self[name_sym].nil?

value = super()
Comment thread
jbeckwith-oai marked this conversation as resolved.
target = type.call

case value
when target
return value
end

state = OpenAI::Internal::Type::Converter.new_coerce_state(translate_names: false)
converted = OpenAI::Internal::Type::Converter.coerce(target, value, state: state)

case converted
when target
return converted if state.fetch(:error).nil? && state.fetch(:exactness).fetch(:no).zero?
end

raise OpenAI::Errors::ConversionError.new(
on: self.class,
method: name_sym,
target: target,
value: value,
cause: state.fetch(:error)
)
end
end

def optional(...)
message = "`optional` is not supported for structured output APIs, use `#required` with `nil?: true` instead"
raise RuntimeError.new(message)
Expand Down
17 changes: 17 additions & 0 deletions lib/openai/helpers/structured_output/union_of.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ class UnionOf
include OpenAI::Internal::Type::Union
include OpenAI::Helpers::StructuredOutput::JsonSchemaConverter

# @api private
#
# @param value [Object]
# @param state [Hash{Symbol=>Object}]
# @return [Object]
def coerce(value, state:)
nonviable = state.fetch(:exactness).fetch(:no)
converted = super

case converted
when self
state[:error] = nil if state.fetch(:exactness).fetch(:no) == nonviable
end

converted
end

# @api private
#
# @param state [Hash{Symbol=>Object}]
Expand Down
7 changes: 7 additions & 0 deletions lib/openai/internal/type/converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ def coerce(target, value, state: OpenAI::Internal::Type::Converter.new_coerce_st
else
state[:error] = TypeError.new("#{value.class} can't be coerced into #{String}")
end
in -> { _1 <= Symbol }
if value.is_a?(String)
exactness[:yes] += 1
return value.to_sym
else
state[:error] = TypeError.new("#{value.class} can't be coerced into #{Symbol}")
end
in -> { _1 <= Date || _1 <= Time }
Kernel.then do
return target.parse(value).tap { exactness[:yes] += 1 }
Expand Down
Loading