diff --git a/lib/openai/base_model.rb b/lib/openai/base_model.rb index c12857ab..2d37a563 100644 --- a/lib/openai/base_model.rb +++ b/lib/openai/base_model.rb @@ -9,22 +9,34 @@ module Converter # # @param value [Object] # - # @return [Object] - def coerce(value) = value - - # @api private + # @param state [Hash{Symbol=>Object}] . # - # @param value [Object] + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched # # @return [Object] - def dump(value) = value + def coerce(value, state:) = (raise NotImplementedError) # @api private # # @param value [Object] # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) = (raise NotImplementedError) + # @return [Object] + def dump(value) + case value + in Array + value.map { OpenAI::Unknown.dump(_1) } + in Hash + value.transform_values { OpenAI::Unknown.dump(_1) } + in OpenAI::BaseModel + value.class.dump(value) + else + value + end + end # rubocop:enable Lint/UnusedMethodArgument @@ -44,14 +56,14 @@ class << self # @return [Proc] def type_info(spec) case spec - in Hash - type_info(spec.slice(:const, :enum, :union).first&.last) in Proc spec - in OpenAI::Converter | Module | Symbol - -> { spec } + in Hash + type_info(spec.slice(:const, :enum, :union).first&.last) in true | false -> { OpenAI::BooleanModel } + in OpenAI::Converter | Class | Symbol + -> { spec } in NilClass | Integer | Float -> { spec.class } end @@ -66,108 +78,127 @@ def type_info(spec) # converted value # 3. otherwise, the given `value` unaltered # + # The coercion process is subject to improvement between minor release versions. + # See https://docs.pydantic.dev/latest/concepts/unions/#smart-mode + # # @param target [OpenAI::Converter, Class] + # # @param value [Object] # + # @param state [Hash{Symbol=>Object}] The `strictness` is one of `true`, `false`, or `:strong`. This informs the + # coercion strategy when we have to decide between multiple possible conversion + # targets: + # + # - `true`: the conversion must be exact, with minimum coercion. + # - `false`: the conversion can be approximate, with some coercion. + # - `:strong`: the conversion must be exact, with no coercion, and raise an error + # if not possible. + # + # The `exactness` is `Hash` with keys being one of `yes`, `no`, or `maybe`. For + # any given conversion attempt, the exactness will be updated based on how closely + # the value recursively matches the target type: + # + # - `yes`: the value can be converted to the target type with minimum coercion. + # - `maybe`: the value can be converted to the target type with some reasonable + # coercion. + # - `no`: the value cannot be converted to the target type. + # + # See implementation below for more details. + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Object] - def coerce(target, value) + def coerce(target, value, state: {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0}) + strictness, exactness = state.fetch_values(:strictness, :exactness) + case target in OpenAI::Converter - target.coerce(value) - in Symbol - case value - in Symbol | String if (val = value.to_sym) == target - val - else - value + return target.coerce(value, state: state) + in Class + if value.is_a?(target) + exactness[:yes] += 1 + return value end - in Module + case target in -> { _1 <= NilClass } - nil + exactness[value.nil? ? :yes : :maybe] += 1 + return nil in -> { _1 <= Integer } - value.is_a?(Numeric) ? Integer(value) : value + if value.is_a?(Integer) + exactness[:yes] += 1 + return value + elsif strictness == :strong + message = "no implicit conversion of #{value.class} into #{target.inspect}" + raise TypeError.new(message) + else + Kernel.then do + return Integer(value).tap { exactness[:maybe] += 1 } + rescue ArgumentError, TypeError + end + end in -> { _1 <= Float } - value.is_a?(Numeric) ? Float(value) : value - in -> { _1 <= Symbol } - value.is_a?(String) ? value.to_sym : value + if value.is_a?(Numeric) + exactness[:yes] += 1 + return Float(value) + elsif strictness == :strong + message = "no implicit conversion of #{value.class} into #{target.inspect}" + raise TypeError.new(message) + else + Kernel.then do + return Float(value).tap { exactness[:maybe] += 1 } + rescue ArgumentError, TypeError + end + end in -> { _1 <= String } - value.is_a?(Symbol) ? value.to_s : value + case value + in String | Symbol | Numeric + exactness[value.is_a?(Numeric) ? :maybe : :yes] += 1 + return value.to_s + else + if strictness == :strong + message = "no implicit conversion of #{value.class} into #{target.inspect}" + raise TypeError.new(message) + end + end in -> { _1 <= Date || _1 <= Time } - value.is_a?(String) ? target.parse(value) : value - in -> { _1 <= IO } - value.is_a?(String) ? StringIO.new(value) : value + Kernel.then do + return target.parse(value).tap { exactness[:yes] += 1 } + rescue ArgumentError, TypeError => e + raise e if strictness == :strong + end + in -> { _1 <= IO } if value.is_a?(String) + exactness[:yes] += 1 + return StringIO.new(value.b) else - value end - end - end - - # @api private - # - # @param target [OpenAI::Converter, Class] - # @param value [Object] - # - # @return [Object] - def dump(target, value) - case target - in OpenAI::Converter - target.dump(value) + in Symbol + if (value.is_a?(Symbol) || value.is_a?(String)) && value.to_sym == target + exactness[:yes] += 1 + return target + elsif strictness == :strong + message = "cannot convert non-matching #{value.class} into #{target.inspect}" + raise ArgumentError.new(message) + end else - value end + + exactness[:no] += 1 + value end # @api private # - # The underlying algorithm for computing maximal compatibility is subject to - # future improvements. - # - # Similar to `#.coerce`, used to determine the best union variant to decode into. - # - # 1. determine if strict-ish coercion is possible - # 2. return either result of successful coercion or if loose coercion is possible - # 3. return a score for recursively tallied count for fields that can be coerced - # # @param target [OpenAI::Converter, Class] # @param value [Object] # # @return [Object] - def try_strict_coerce(target, value) - case target - in OpenAI::Converter - target.try_strict_coerce(value) - in Symbol - case value - in Symbol | String if (val = value.to_sym) == target - [true, val, 1] - else - [false, false, 0] - end - in Module - case [target, value] - in [-> { _1 <= NilClass }, _] - [true, nil, value.nil? ? 1 : 0] - in [-> { _1 <= Integer }, Numeric] - [true, Integer(value), 1] - in [-> { _1 <= Float }, Numeric] - [true, Float(value), 1] - in [-> { _1 <= Symbol }, String] - [true, value.to_sym, 1] - in [-> { _1 <= String }, Symbol] - [true, value.to_s, 1] - in [-> { _1 <= Date || _1 <= Time }, String] - Kernel.then do - [true, target.parse(value), 1] - rescue ArgumentError - [false, false, 0] - end - in [_, ^target] - [true, value, 1] - else - [false, false, 0] - end - end + def dump(target, value) + target.is_a?(OpenAI::Converter) ? target.dump(value) : OpenAI::Unknown.dump(value) end end end @@ -193,13 +224,23 @@ def self.===(other) = true def self.==(other) = other.is_a?(Class) && other <= OpenAI::Unknown class << self - # @!parse - # # @api private - # # - # # @param value [Object] - # # - # # @return [Object] - # def coerce(value) = super + # @api private + # + # @param value [Object] + # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # + # @return [Object] + def coerce(value, state:) + state.fetch(:exactness)[:yes] += 1 + value + end # @!parse # # @api private @@ -208,16 +249,6 @@ class << self # # # # @return [Object] # def dump(value) = super - - # @api private - # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - # prevent unknown variant from being chosen during the first coercion pass - [false, true, 0] - end end # rubocop:enable Lint/UnusedMethodArgument @@ -242,13 +273,23 @@ def self.===(other) = other == true || other == false def self.==(other) = other.is_a?(Class) && other <= OpenAI::BooleanModel class << self - # @!parse - # # @api private - # # - # # @param value [Boolean, Object] - # # - # # @return [Boolean, Object] - # def coerce(value) = super + # @api private + # + # @param value [Boolean, Object] + # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # + # @return [Boolean, Object] + def coerce(value, state:) + state.fetch(:exactness)[value == true || value == false ? :yes : :no] += 1 + value + end # @!parse # # @api private @@ -257,20 +298,6 @@ class << self # # # # @return [Boolean, Object] # def dump(value) = super - - # @api private - # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in true | false - [true, value, 1] - else - [false, false, 0] - end - end end end @@ -333,19 +360,34 @@ def ===(other) = values.include?(other) # # @return [Boolean] def ==(other) - other.is_a?(Module) && other.singleton_class.ancestors.include?(OpenAI::Enum) && other.values.to_set == values.to_set + other.is_a?(Module) && other.singleton_class <= OpenAI::Enum && other.values.to_set == values.to_set end # @api private # + # Unlike with primitives, `Enum` additionally validates that the value is a member + # of the enum. + # # @param value [String, Symbol, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Symbol, Object] - def coerce(value) - case value - in Symbol | String if values.include?(val = value.to_sym) + def coerce(value, state:) + exactness = state.fetch(:exactness) + val = value.is_a?(String) ? value.to_sym : value + + if values.include?(val) + exactness[:yes] += 1 val else + exactness[values.first&.class == val.class ? :maybe : :no] += 1 value end end @@ -357,27 +399,6 @@ def coerce(value) # # # # @return [Symbol, Object] # def dump(value) = super - - # @api private - # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - return [true, value, 1] if values.include?(value) - - case value - in Symbol | String if values.include?(val = value.to_sym) - [true, val, 1] - else - case [value, values.first] - in [true | false, true | false] | [Integer, Integer] | [Symbol | String, Symbol] - [false, true, 0] - else - [false, false, 0] - end - end - end end # @api private @@ -426,9 +447,7 @@ module Union # All of the specified variants for this union. # # @return [Array] - def variants - derefed_variants.map(&:last) - end + def variants = derefed_variants.map(&:last) # @api private # @@ -458,7 +477,7 @@ def variants case key in Symbol [key, OpenAI::Converter.type_info(spec)] - in Proc | OpenAI::Converter | Module | Hash + in Proc | OpenAI::Converter | Class | Hash [nil, OpenAI::Converter.type_info(key)] end @@ -475,16 +494,14 @@ def variants in [_, OpenAI::BaseModel] value.class in [Symbol, Hash] - key = - if value.key?(@discriminator) - value.fetch(@discriminator) - elsif value.key?((discriminator = @discriminator.to_s)) - value.fetch(discriminator) - end + key = value.fetch(@discriminator) do + value.fetch(@discriminator.to_s, OpenAI::Util::OMIT) + end + + return nil if key == OpenAI::Util::OMIT key = key.to_sym if key.is_a?(String) - _, resolved = known_variants.find { |k,| k == key } - resolved.nil? ? OpenAI::Unknown : resolved.call + known_variants.find { |k,| k == key }&.last&.call else nil end @@ -506,87 +523,81 @@ def ===(other) # # @return [Boolean] def ==(other) - other.is_a?(Module) && other.singleton_class.ancestors.include?(OpenAI::Union) && other.derefed_variants == derefed_variants + other.is_a?(Module) && other.singleton_class <= OpenAI::Union && other.derefed_variants == derefed_variants end # @api private # # @param value [Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Object] - def coerce(value) - if (variant = resolve_variant(value)) - return OpenAI::Converter.coerce(variant, value) + def coerce(value, state:) + if (target = resolve_variant(value)) + return OpenAI::Converter.coerce(target, value, state: state) end - matches = [] + strictness = state.fetch(:strictness) + exactness = state.fetch(:exactness) + state[:strictness] = strictness == :strong ? true : strictness + alternatives = [] known_variants.each do |_, variant_fn| - variant = variant_fn.call - - case OpenAI::Converter.try_strict_coerce(variant, value) - in [true, coerced, _] + target = variant_fn.call + exact = state[:exactness] = {yes: 0, no: 0, maybe: 0} + state[:branched] += 1 + + coerced = OpenAI::Converter.coerce(target, value, state: state) + yes, no, maybe = exact.values + if (no + maybe).zero? || (!strictness && yes.positive?) + exact.each { exactness[_1] += _2 } + state[:exactness] = exactness return coerced - in [false, true, score] - matches << [score, variant] - in [false, false, _] - nil + elsif maybe.positive? + alternatives << [[-yes, -maybe, no], exact, coerced] end end - _, variant = matches.sort! { _2.first <=> _1.first }.find { |score,| !score.zero? } - variant.nil? ? value : OpenAI::Converter.coerce(variant, value) - end - - # @api private - # - # @param value [Object] - # - # @return [Object] - def dump(value) - if (variant = resolve_variant(value)) - return OpenAI::Converter.dump(variant, value) - end - - known_variants.each do |_, variant_fn| - variant = variant_fn.call - if variant === value - return OpenAI::Converter.dump(variant, value) + case alternatives.sort_by(&:first) + in [] + exactness[:no] += 1 + if strictness == :strong + message = "no possible conversion of #{value.class} into a variant of #{target.inspect}" + raise ArgumentError.new(message) end + value + in [[_, exact, coerced], *] + exact.each { exactness[_1] += _2 } + coerced end - value + .tap { state[:exactness] = exactness } + ensure + state[:strictness] = strictness end # @api private # # @param value [Object] # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - # TODO(ruby) this will result in super linear decoding behaviour for nested unions - # follow up with a decoding context that captures current strictness levels - if (variant = resolve_variant(value)) - return Converter.try_strict_coerce(variant, value) + # @return [Object] + def dump(value) + if (target = resolve_variant(value)) + return OpenAI::Converter.dump(target, value) end - coercible = false - max_score = 0 - - known_variants.each do |_, variant_fn| - variant = variant_fn.call - - case OpenAI::Converter.try_strict_coerce(variant, value) - in [true, coerced, score] - return [true, coerced, score] - in [false, true, score] - coercible = true - max_score = [max_score, score].max - in [false, false, _] - nil - end + known_variants.each do + target = _2.call + return OpenAI::Converter.dump(target, value) if target === value end - [false, coercible, max_score] + super end # rubocop:enable Style/CaseEquality @@ -617,36 +628,46 @@ def self.[](type_info, spec = {}) = new(type_info, spec) # @param other [Object] # # @return [Boolean] - def ===(other) - type = item_type - case other - in Array - # rubocop:disable Style/CaseEquality - other.all? { type === _1 } - # rubocop:enable Style/CaseEquality - else - false - end - end + def ===(other) = other.is_a?(Array) && other.all?(item_type) # @param other [Object] # # @return [Boolean] - def ==(other) = other.is_a?(OpenAI::ArrayOf) && other.item_type == item_type + def ==(other) = other.is_a?(OpenAI::ArrayOf) && other.nilable? == nilable? && other.item_type == item_type # @api private # # @param value [Enumerable, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Array, Object] - def coerce(value) - type = item_type - case value - in Enumerable unless value.is_a?(Hash) - value.map { OpenAI::Converter.coerce(type, _1) } - else - value + def coerce(value, state:) + exactness = state.fetch(:exactness) + + unless value.is_a?(Array) + exactness[:no] += 1 + return value end + + target = item_type + exactness[:yes] += 1 + value + .map do |item| + case [nilable?, item] + in [true, nil] + exactness[:yes] += 1 + nil + else + OpenAI::Converter.coerce(target, item, state: state) + end + end end # @api private @@ -655,57 +676,19 @@ def coerce(value) # # @return [Array, Object] def dump(value) - type = item_type - case value - in Enumerable unless value.is_a?(Hash) - value.map { OpenAI::Converter.dump(type, _1) }.to_a - else - value - end + target = item_type + value.is_a?(Array) ? value.map { OpenAI::Converter.dump(target, _1) } : super end # @api private # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in Array - type = item_type - great_success = true - tally = 0 - - mapped = - value.map do |item| - case OpenAI::Converter.try_strict_coerce(type, item) - in [true, coerced, score] - tally += score - coerced - in [false, true, score] - great_success = false - tally += score - item - in [false, false, _] - great_success &&= item.nil? - item - end - end - - if great_success - [true, mapped, tally] - else - [false, true, tally] - end - else - [false, false, 0] - end - end + # @return [OpenAI::Converter, Class] + protected def item_type = @item_type_fn.call # @api private # - # @return [OpenAI::Converter, Class] - protected def item_type = @item_type_fn.call + # @return [Boolean] + protected def nilable? = @nilable # @api private # @@ -722,6 +705,7 @@ def try_strict_coerce(value) # @option spec [Boolean] :"nil?" def initialize(type_info, spec = {}) @item_type_fn = OpenAI::Converter.type_info(type_info || spec) + @nilable = spec[:nil?] end end @@ -769,24 +753,46 @@ def ===(other) # @param other [Object] # # @return [Boolean] - def ==(other) = other.is_a?(OpenAI::HashOf) && other.item_type == item_type + def ==(other) = other.is_a?(OpenAI::HashOf) && other.nilable? == nilable? && other.item_type == item_type # @api private # # @param value [Hash{Object=>Object}, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Hash{Symbol=>Object}, Object] - def coerce(value) - type = item_type - case value - in Hash - value.to_h do |key, val| - coerced = OpenAI::Converter.coerce(type, val) - [key.is_a?(String) ? key.to_sym : key, coerced] - end - else - value + def coerce(value, state:) + exactness = state.fetch(:exactness) + + unless value.is_a?(Hash) + exactness[:no] += 1 + return value end + + target = item_type + exactness[:yes] += 1 + value + .to_h do |key, val| + k = key.is_a?(String) ? key.to_sym : key + v = + case [nilable?, val] + in [true, nil] + exactness[:yes] += 1 + nil + else + OpenAI::Converter.coerce(target, val, state: state) + end + + exactness[:no] += 1 unless k.is_a?(Symbol) + [k, v] + end end # @api private @@ -795,59 +801,19 @@ def coerce(value) # # @return [Hash{Symbol=>Object}, Object] def dump(value) - type = item_type - case value - in Hash - value.transform_values do |val| - OpenAI::Converter.dump(type, val) - end - else - value - end + target = item_type + value.is_a?(Hash) ? value.transform_values { OpenAI::Converter.dump(target, _1) } : super end # @api private # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in Hash - type = item_type - great_success = true - tally = 0 - - mapped = - value.transform_values do |val| - case OpenAI::Converter.try_strict_coerce(type, val) - in [true, coerced, score] - tally += score - coerced - in [false, true, score] - great_success = false - tally += score - val - in [false, false, _] - great_success &&= val.nil? - val - end - end - - if great_success - [true, mapped, tally] - else - [false, true, tally] - end - else - [false, false, 0] - end - end + # @return [OpenAI::Converter, Class] + protected def item_type = @item_type_fn.call # @api private # - # @return [OpenAI::Converter, Class] - protected def item_type = @item_type_fn.call + # @return [Boolean] + protected def nilable? = @nilable # @api private # @@ -864,6 +830,7 @@ def try_strict_coerce(value) # @option spec [Boolean] :"nil?" def initialize(type_info, spec = {}) @item_type_fn = OpenAI::Converter.type_info(type_info || spec) + @nilable = spec[:nil?] end end @@ -890,13 +857,6 @@ def known_fields @known_fields ||= (self < OpenAI::BaseModel ? superclass.known_fields.dup : {}) end - # @api private - # - # @return [Hash{Symbol=>Symbol}] - def reverse_map - @reverse_map ||= (self < OpenAI::BaseModel ? superclass.reverse_map.dup : {}) - end - # @api private # # @return [Hash{Symbol=>Hash{Symbol=>Object}}] @@ -906,11 +866,6 @@ def fields end end - # @api private - # - # @return [Hash{Symbol=>Proc}] - def defaults = (@defaults ||= {}) - # @api private # # @param name_sym [Symbol] @@ -931,38 +886,40 @@ def defaults = (@defaults ||= {}) private def add_field(name_sym, required:, type_info:, spec:) type_fn, info = case type_info - in Proc | Module | OpenAI::Converter + in Proc | OpenAI::Converter | Class [OpenAI::Converter.type_info({**spec, union: type_info}), spec] in Hash [OpenAI::Converter.type_info(type_info), type_info] end - fallback = info[:const] - defaults[name_sym] = fallback if required && !info[:nil?] && info.key?(:const) - - key = info[:api_name]&.tap { reverse_map[_1] = name_sym } || name_sym setter = "#{name_sym}=" + api_name = info.fetch(:api_name, name_sym) + nilable = info[:nil?] + const = required && !nilable ? info.fetch(:const, OpenAI::Util::OMIT) : OpenAI::Util::OMIT - if known_fields.key?(name_sym) - [name_sym, setter].each { undef_method(_1) } - end + [name_sym, setter].each { undef_method(_1) } if known_fields.key?(name_sym) - known_fields[name_sym] = {mode: @mode, key: key, required: required, type_fn: type_fn} + known_fields[name_sym] = + { + mode: @mode, + api_name: api_name, + required: required, + nilable: nilable, + const: const, + type_fn: type_fn + } - define_method(setter) do |val| - @data[key] = val - end + define_method(setter) { @data.store(name_sym, _1) } define_method(name_sym) do - field_type = type_fn.call - value = @data.fetch(key) { self.class.defaults[key] } - OpenAI::Converter.coerce(field_type, value) + target = type_fn.call + value = @data.fetch(name_sym) { const == OpenAI::Util::OMIT ? nil : const } + state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + (nilable || !required) && value.nil? ? nil : OpenAI::Converter.coerce(target, value, state: state) rescue StandardError - name = self.class.name.split("::").last - raise OpenAI::ConversionError.new( - "Failed to parse #{name}.#{name_sym} as #{field_type.inspect}. " \ - "To get the unparsed API response, use #{name}[:#{key}]." - ) + cls = self.class.name.split("::").last + message = "Failed to parse #{cls}.#{__method__} from #{value.class} to #{target.inspect}. To get the unparsed API response, use #{cls}[:#{__method__}]." + raise OpenAI::ConversionError.new(message) end end @@ -1028,120 +985,124 @@ def optional(name_sym, type_info, spec = {}) ensure @mode = nil end + + # @param other [Object] + # + # @return [Boolean] + def ==(other) = other.is_a?(Class) && other <= OpenAI::BaseModel && other.fields == fields end # @param other [Object] # # @return [Boolean] - def ==(other) - case other - in OpenAI::BaseModel - self.class.fields == other.class.fields && @data == other.to_h - else - false - end - end + def ==(other) = self.class == other.class && @data == other.to_h class << self # @api private # # @param value [OpenAI::BaseModel, Hash{Object=>Object}, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [OpenAI::BaseModel, Object] - def coerce(value) - case OpenAI::Util.coerce_hash(value) - in Hash => coerced - new(coerced) - else - value + def coerce(value, state:) + exactness = state.fetch(:exactness) + + if value.is_a?(self.class) + exactness[:yes] += 1 + return value end - end - # @api private - # - # @param value [OpenAI::BaseModel, Object] - # - # @return [Hash{Object=>Object}, Object] - def dump(value) - unless (coerced = OpenAI::Util.coerce_hash(value)).is_a?(Hash) + unless (val = OpenAI::Util.coerce_hash(value)).is_a?(Hash) + exactness[:no] += 1 return value end + exactness[:yes] += 1 - values = coerced.filter_map do |key, val| - name = key.to_sym - case (field = known_fields[name]) - in nil - [name, val] - else - mode, type_fn, api_name = field.fetch_values(:mode, :type_fn, :key) - case mode - in :coerce - next + keys = val.keys.to_set + instance = new + data = instance.to_h + + fields.each do |name, field| + mode, required, target = field.fetch_values(:mode, :required, :type) + api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const) + + unless val.key?(api_name) + if const != OpenAI::Util::OMIT + exactness[:yes] += 1 + elsif required && mode != :dump + exactness[nilable ? :maybe : :no] += 1 else - target = type_fn.call - [api_name, OpenAI::Converter.dump(target, val)] + exactness[:yes] += 1 end + next end - end.to_h - defaults.each do |key, val| - next if values.key?(key) + item = val.fetch(api_name) + keys.delete(api_name) - values[key] = val + converted = + if item.nil? && (nilable || !required) + exactness[nilable ? :yes : :maybe] += 1 + nil + else + coerced = OpenAI::Converter.coerce(target, item, state: state) + case target + in OpenAI::Converter | Symbol + coerced + else + item + end + end + data.store(name, converted) end - values + keys.each { data.store(_1, val.fetch(_1)) } + instance end # @api private # - # @param value [Object] + # @param value [OpenAI::BaseModel, Object] # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in Hash | OpenAI::BaseModel - value = value.to_h - else - return [false, false, 0] + # @return [Hash{Object=>Object}, Object] + def dump(value) + unless (coerced = OpenAI::Util.coerce_hash(value)).is_a?(Hash) + return super end - keys = value.keys.to_set - great_success = true - tally = 0 acc = {} - known_fields.each_value do |field| - mode, required, type_fn, api_name = field.fetch_values(:mode, :required, :type_fn, :key) - keys.delete(api_name) - - case [required && mode != :dump, value.key?(api_name)] - in [_, true] - target = type_fn.call - item = value.fetch(api_name) - case OpenAI::Converter.try_strict_coerce(target, item) - in [true, coerced, score] - tally += score - acc[api_name] = coerced - in [false, true, score] - great_success = false - tally += score - acc[api_name] = item - in [false, false, _] - great_success &&= item.nil? + coerced.each do |key, val| + name = key.is_a?(String) ? key.to_sym : key + case (field = known_fields[name]) + in nil + acc.store(name, super(val)) + else + mode, api_name, type_fn = field.fetch_values(:mode, :api_name, :type_fn) + case mode + in :coerce + next + else + target = type_fn.call + acc.store(api_name, OpenAI::Converter.dump(target, val)) end - in [true, false] - great_success = false - in [false, false] - nil end end - keys.each do |key| - acc[key] = value.fetch(key) + known_fields.each_value do |field| + mode, api_name, const = field.fetch_values(:mode, :api_name, :const) + next if mode == :coerce || acc.key?(api_name) || const == OpenAI::Util::OMIT + acc.store(api_name, const) end - great_success ? [true, new(acc), tally] : [false, true, tally] + acc end end @@ -1181,14 +1142,15 @@ def to_h = @data # # @return [Hash{Symbol=>Object}] def deconstruct_keys(keys) - (keys || self.class.known_fields.keys).filter_map do |k| - unless self.class.known_fields.key?(k) - next - end + (keys || self.class.known_fields.keys) + .filter_map do |k| + unless self.class.known_fields.key?(k) + next + end - [k, method(k).call] - end - .to_h + [k, public_send(k)] + end + .to_h end # Create a new instance of a model. @@ -1197,21 +1159,7 @@ def deconstruct_keys(keys) def initialize(data = {}) case OpenAI::Util.coerce_hash(data) in Hash => coerced - @data = coerced.to_h do |key, value| - name = key.to_sym - mapped = self.class.reverse_map.fetch(name, name) - type = self.class.fields[mapped]&.fetch(:type) - stored = - case [type, value] - in [Module, Hash] if type <= OpenAI::BaseModel - type.new(value) - in [OpenAI::ArrayOf, Array] | [OpenAI::HashOf, Hash] - type.coerce(value) - else - value - end - [name, stored] - end + @data = coerced else raise ArgumentError.new("Expected a #{Hash} or #{OpenAI::BaseModel}, got #{data.inspect}") end @@ -1222,9 +1170,12 @@ def to_s = @data.to_s # @return [String] def inspect - "#<#{self.class.name}:0x#{object_id.to_s(16)} #{deconstruct_keys(nil).map do |k, v| - "#{k}=#{v.inspect}" - end.join(' ')}>" + rows = self.class.known_fields.keys.map do + "#{_1}=#{@data.key?(_1) ? public_send(_1) : ''}" + rescue OpenAI::ConversionError + "#{_1}=#{@data.fetch(_1)}" + end + "#<#{self.class.name}:0x#{object_id.to_s(16)} #{rows.join(' ')}>" end end end diff --git a/rbi/lib/openai/base_model.rbi b/rbi/lib/openai/base_model.rbi index c99d19ab..0f1d214f 100644 --- a/rbi/lib/openai/base_model.rbi +++ b/rbi/lib/openai/base_model.rbi @@ -5,9 +5,18 @@ module OpenAI module Converter Input = T.type_alias { T.any(OpenAI::Converter, T::Class[T.anything]) } + State = + T.type_alias do + { + strictness: T.any(T::Boolean, Symbol), + exactness: {yes: Integer, no: Integer, maybe: Integer}, + branched: Integer + } + end + # @api private - sig { overridable.params(value: T.anything).returns(T.anything) } - def coerce(value) + sig { overridable.params(value: T.anything, state: OpenAI::Converter::State).returns(T.anything) } + def coerce(value, state:) end # @api private @@ -15,15 +24,6 @@ module OpenAI def dump(value) end - # @api private - sig do - overridable - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end - class << self # @api private sig do @@ -51,28 +51,43 @@ module OpenAI # 2. if it's possible and safe to convert the given `value` to `target`, then the # converted value # 3. otherwise, the given `value` unaltered - sig { params(target: OpenAI::Converter::Input, value: T.anything).returns(T.anything) } - def self.coerce(target, value) + # + # The coercion process is subject to improvement between minor release versions. + # See https://docs.pydantic.dev/latest/concepts/unions/#smart-mode + sig do + params(target: OpenAI::Converter::Input, value: T.anything, state: OpenAI::Converter::State) + .returns(T.anything) + end + def self.coerce( + target, + value, + # The `strictness` is one of `true`, `false`, or `:strong`. This informs the + # coercion strategy when we have to decide between multiple possible conversion + # targets: + # + # - `true`: the conversion must be exact, with minimum coercion. + # - `false`: the conversion can be approximate, with some coercion. + # - `:strong`: the conversion must be exact, with no coercion, and raise an error + # if not possible. + # + # The `exactness` is `Hash` with keys being one of `yes`, `no`, or `maybe`. For + # any given conversion attempt, the exactness will be updated based on how closely + # the value recursively matches the target type: + # + # - `yes`: the value can be converted to the target type with minimum coercion. + # - `maybe`: the value can be converted to the target type with some reasonable + # coercion. + # - `no`: the value cannot be converted to the target type. + # + # See implementation below for more details. + state: {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + ) end # @api private sig { params(target: OpenAI::Converter::Input, value: T.anything).returns(T.anything) } def self.dump(target, value) end - - # @api private - # - # The underlying algorithm for computing maximal compatibility is subject to - # future improvements. - # - # Similar to `#.coerce`, used to determine the best union variant to decode into. - # - # 1. determine if strict-ish coercion is possible - # 2. return either result of successful coercion or if loose coercion is possible - # 3. return a score for recursively tallied count for fields that can be coerced - sig { params(target: OpenAI::Converter::Input, value: T.anything).returns(T.anything) } - def self.try_strict_coerce(target, value) - end end end @@ -95,23 +110,14 @@ module OpenAI class << self # @api private - sig(:final) { override.params(value: T.anything).returns(T.anything) } - def coerce(value) + sig(:final) { override.params(value: T.anything, state: OpenAI::Converter::State).returns(T.anything) } + def coerce(value, state:) end # @api private sig(:final) { override.params(value: T.anything).returns(T.anything) } def dump(value) end - - # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end end @@ -135,9 +141,11 @@ module OpenAI class << self # @api private sig(:final) do - override.params(value: T.any(T::Boolean, T.anything)).returns(T.any(T::Boolean, T.anything)) + override + .params(value: T.any(T::Boolean, T.anything), state: OpenAI::Converter::State) + .returns(T.any(T::Boolean, T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -146,15 +154,6 @@ module OpenAI end def dump(value) end - - # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end end @@ -194,23 +193,21 @@ module OpenAI end # @api private - sig { override.params(value: T.any(String, Symbol, T.anything)).returns(T.any(Symbol, T.anything)) } - def coerce(value) + # + # Unlike with primitives, `Enum` additionally validates that the value is a member + # of the enum. + sig do + override + .params(value: T.any(String, Symbol, T.anything), state: OpenAI::Converter::State) + .returns(T.any(Symbol, T.anything)) + end + def coerce(value, state:) end # @api private sig { override.params(value: T.any(Symbol, T.anything)).returns(T.any(Symbol, T.anything)) } def dump(value) end - - # @api private - sig do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end # @api private @@ -264,23 +261,14 @@ module OpenAI end # @api private - sig { override.params(value: T.anything).returns(T.anything) } - def coerce(value) + sig { override.params(value: T.anything, state: OpenAI::Converter::State).returns(T.anything) } + def coerce(value, state:) end # @api private sig { override.params(value: T.anything).returns(T.anything) } def dump(value) end - - # @api private - sig do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end # @api private @@ -317,10 +305,10 @@ module OpenAI # @api private sig(:final) do override - .params(value: T.any(T::Enumerable[T.anything], T.anything)) + .params(value: T.any(T::Enumerable[T.anything], T.anything), state: OpenAI::Converter::State) .returns(T.any(T::Array[T.anything], T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -333,17 +321,13 @@ module OpenAI end # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) + sig(:final) { returns(T.anything) } + protected def item_type end # @api private - sig(:final) { returns(T.anything) } - protected def item_type + sig(:final) { returns(T::Boolean) } + protected def nilable? end # @api private @@ -396,10 +380,10 @@ module OpenAI # @api private sig(:final) do override - .params(value: T.any(T::Hash[T.anything, T.anything], T.anything)) + .params(value: T.any(T::Hash[T.anything, T.anything], T.anything), state: OpenAI::Converter::State) .returns(T.any(OpenAI::Util::AnyHash, T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -412,17 +396,13 @@ module OpenAI end # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) + sig(:final) { returns(T.anything) } + protected def item_type end # @api private - sig(:final) { returns(T.anything) } - protected def item_type + sig(:final) { returns(T::Boolean) } + protected def nilable? end # @api private @@ -446,7 +426,7 @@ module OpenAI abstract! - KnownFieldShape = T.type_alias { {mode: T.nilable(Symbol), required: T::Boolean} } + KnownFieldShape = T.type_alias { {mode: T.nilable(Symbol), required: T::Boolean, nilable: T::Boolean} } class << self # @api private @@ -465,11 +445,6 @@ module OpenAI def known_fields end - # @api private - sig { returns(T::Hash[Symbol, Symbol]) } - def reverse_map - end - # @api private sig do returns(T::Hash[Symbol, T.all(OpenAI::BaseModel::KnownFieldShape, {type: OpenAI::Converter::Input})]) @@ -477,11 +452,6 @@ module OpenAI def fields end - # @api private - sig { returns(T::Hash[Symbol, T.proc.returns(T::Class[T.anything])]) } - def defaults - end - # @api private sig do params( @@ -551,6 +521,10 @@ module OpenAI sig { params(blk: T.proc.void).void } private def response_only(&blk) end + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + end end sig { params(other: T.anything).returns(T::Boolean) } @@ -561,10 +535,13 @@ module OpenAI # @api private sig do override - .params(value: T.any(OpenAI::BaseModel, T::Hash[T.anything, T.anything], T.anything)) + .params( + value: T.any(OpenAI::BaseModel, T::Hash[T.anything, T.anything], T.anything), + state: OpenAI::Converter::State + ) .returns(T.any(T.attached_class, T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -575,15 +552,6 @@ module OpenAI end def dump(value) end - - # @api private - sig do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end # Returns the raw value associated with the given key, if found. Otherwise, nil is diff --git a/sig/openai/base_model.rbs b/sig/openai/base_model.rbs index ff01cbf3..7f741683 100644 --- a/sig/openai/base_model.rbs +++ b/sig/openai/base_model.rbs @@ -2,13 +2,16 @@ module OpenAI module Converter type input = OpenAI::Converter | Class - def coerce: (top value) -> top + type state = + { + strictness: bool | :strong, + exactness: { yes: Integer, no: Integer, maybe: Integer }, + branched: Integer + } - def dump: (top value) -> top + def coerce: (top value, state: OpenAI::Converter::state) -> top - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) + def dump: (top value) -> top def self.type_info: ( { @@ -20,14 +23,13 @@ module OpenAI | OpenAI::Converter::input spec ) -> (^-> top) - def self.coerce: (OpenAI::Converter::input target, top value) -> top - - def self.dump: (OpenAI::Converter::input target, top value) -> top - - def self.try_strict_coerce: ( + def self.coerce: ( OpenAI::Converter::input target, - top value + top value, + ?state: OpenAI::Converter::state ) -> top + + def self.dump: (OpenAI::Converter::input target, top value) -> top end class Unknown @@ -37,13 +39,9 @@ module OpenAI def self.==: (top other) -> bool - def self.coerce: (top value) -> top + def self.coerce: (top value, state: OpenAI::Converter::state) -> top def self.dump: (top value) -> top - - def self.try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end class BooleanModel @@ -53,13 +51,12 @@ module OpenAI def self.==: (top other) -> bool - def self.coerce: (bool | top value) -> (bool | top) + def self.coerce: ( + bool | top value, + state: OpenAI::Converter::state + ) -> (bool | top) def self.dump: (bool | top value) -> (bool | top) - - def self.try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end module Enum @@ -73,13 +70,12 @@ module OpenAI def ==: (top other) -> bool - def coerce: (String | Symbol | top value) -> (Symbol | top) + def coerce: ( + String | Symbol | top value, + state: OpenAI::Converter::state + ) -> (Symbol | top) def dump: (Symbol | top value) -> (Symbol | top) - - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end module Union @@ -109,13 +105,9 @@ module OpenAI def ==: (top other) -> bool - def coerce: (top value) -> top + def coerce: (top value, state: OpenAI::Converter::state) -> top def dump: (top value) -> top - - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end class ArrayOf @@ -132,16 +124,17 @@ module OpenAI def ==: (top other) -> bool - def coerce: (Enumerable[top] | top value) -> (::Array[top] | top) + def coerce: ( + Enumerable[top] | top value, + state: OpenAI::Converter::state + ) -> (::Array[top] | top) def dump: (Enumerable[top] | top value) -> (::Array[top] | top) - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) - def item_type: -> top + def nilable?: -> bool + def initialize: ( ::Hash[Symbol, top] | ^-> OpenAI::Converter::input @@ -164,16 +157,17 @@ module OpenAI def ==: (top other) -> bool - def coerce: (::Hash[top, top] | top value) -> (::Hash[Symbol, top] | top) + def coerce: ( + ::Hash[top, top] | top value, + state: OpenAI::Converter::state + ) -> (::Hash[Symbol, top] | top) def dump: (::Hash[top, top] | top value) -> (::Hash[Symbol, top] | top) - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) - def item_type: -> top + def nilable?: -> bool + def initialize: ( ::Hash[Symbol, top] | ^-> OpenAI::Converter::input @@ -185,18 +179,15 @@ module OpenAI class BaseModel extend OpenAI::Converter - type known_field = { mode: (:coerce | :dump)?, required: bool } + type known_field = + { mode: (:coerce | :dump)?, required: bool, nilable: bool } def self.known_fields: -> ::Hash[Symbol, (OpenAI::BaseModel::known_field & { type_fn: (^-> OpenAI::Converter::input) })] - def self.reverse_map: -> ::Hash[Symbol, Symbol] - def self.fields: -> ::Hash[Symbol, (OpenAI::BaseModel::known_field & { type: OpenAI::Converter::input })] - def self.defaults: -> ::Hash[Symbol, (^-> Class)] - private def self.add_field: ( Symbol name_sym, required: bool, @@ -231,18 +222,17 @@ module OpenAI private def self.response_only: { -> void } -> void + def self.==: (top other) -> bool + def ==: (top other) -> bool def self.coerce: ( - OpenAI::BaseModel | ::Hash[top, top] | top value + OpenAI::BaseModel | ::Hash[top, top] | top value, + state: OpenAI::Converter::state ) -> (instance | top) def self.dump: (instance | top value) -> (::Hash[top, top] | top) - def self.try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) - def []: (Symbol key) -> top? def to_h: -> ::Hash[Symbol, top] diff --git a/test/openai/base_model_test.rb b/test/openai/base_model_test.rb index bb5fb2a6..f1c37432 100644 --- a/test/openai/base_model_test.rb +++ b/test/openai/base_model_test.rb @@ -2,380 +2,576 @@ require_relative "test_helper" -class OpenAI::Test::BaseModelTest < Minitest::Test - module E1 - extend OpenAI::Enum +class OpenAI::Test::PrimitiveModelTest < Minitest::Test + A = OpenAI::ArrayOf[-> { Integer }] + H = OpenAI::HashOf[-> { Integer }, nil?: true] - A = :a - B = :b + module E + extend OpenAI::Enum end - A1 = OpenAI::ArrayOf[-> { Integer }] - A2 = OpenAI::ArrayOf[enum: -> { E1 }] + module U + extend OpenAI::Union + end - def test_basic - assert(E1.is_a?(OpenAI::Converter)) - assert(A1.is_a?(OpenAI::Converter)) + class B < OpenAI::BaseModel + optional :a, Integer + optional :b, B end - def test_basic_coerce - assert_pattern do - OpenAI::Converter.coerce(A1, [1.0, 2.0, 3.0]) => [1, 2, 3] - end + def test_typing + converters = [ + OpenAI::Unknown, + OpenAI::BooleanModel, + A, + H, + E, + U, + B + ] - assert_pattern do - OpenAI::Converter.coerce(A2, %w[a b c]) => [:a, :b, "c"] + converters.each do |conv| + assert_pattern do + conv => OpenAI::Converter + end end end - def test_basic_dump - assert_pattern do - OpenAI::Converter.dump(A1, [1.0, 2.0, 3.0]) => [1, 2, 3] - end + def test_coerce + cases = { + [OpenAI::Unknown, :a] => [{yes: 1}, :a], + [NilClass, :a] => [{maybe: 1}, nil], + [NilClass, nil] => [{yes: 1}, nil], + [OpenAI::BooleanModel, true] => [{yes: 1}, true], + [OpenAI::BooleanModel, "true"] => [{no: 1}, "true"], + [Integer, 1] => [{yes: 1}, 1], + [Integer, 1.0] => [{maybe: 1}, 1], + [Integer, "1"] => [{maybe: 1}, 1], + [Integer, "one"] => [{no: 1}, "one"], + [Float, 1] => [{yes: 1}, 1.0], + [Float, "1"] => [{maybe: 1}, 1.0], + [Float, :one] => [{no: 1}, :one], + [String, :str] => [{yes: 1}, "str"], + [String, "str"] => [{yes: 1}, "str"], + [String, 1] => [{maybe: 1}, "1"], + [:a, "a"] => [{yes: 1}, :a], + [Date, "1990-09-19"] => [{yes: 1}, Date.new(1990, 9, 19)], + [Date, Date.new(1990, 9, 19)] => [{yes: 1}, Date.new(1990, 9, 19)], + [Date, "one"] => [{no: 1}, "one"], + [Time, "1990-09-19"] => [{yes: 1}, Time.new(1990, 9, 19)], + [Time, Time.new(1990, 9, 19)] => [{yes: 1}, Time.new(1990, 9, 19)], + [Time, "one"] => [{no: 1}, "one"] + } - assert_pattern do - OpenAI::Converter.dump(A2, %w[a b c]) => %w[a b c] + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + assert_pattern do + OpenAI::Converter.coerce(target, input, state: state) => ^expect + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness + end end end - def test_primitive_try_strict_coerce - d_now = Date.today - t_now = Time.now - + def test_dump cases = { - [NilClass, :a] => [true, nil, 0], - [NilClass, nil] => [true, nil, 1], - [Integer, 1.0] => [true, 1, 1], - [Float, 1] => [true, 1.0, 1], - [Date, d_now] => [true, d_now, 1], - [Time, t_now] => [true, t_now, 1] + [OpenAI::Unknown, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [A, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [H, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [E, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [U, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [B, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [String, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [:b, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [nil, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [OpenAI::BooleanModel, true] => true, + [OpenAI::BooleanModel, "true"] => "true", + [Integer, "1"] => "1", + [Float, 1] => 1, + [String, "one"] => "one", + [String, :one] => :one, + [:a, :b] => :b, + [:a, "a"] => "a" } - cases.each do |test, expect| - type, input = test + cases.each do + target, input = _1 + expect = _2 assert_pattern do - OpenAI::Converter.try_strict_coerce(type, input) => ^expect + OpenAI::Converter.dump(target, input) => ^expect end end end - def test_basic_enum_try_strict_coerce + def test_coerce_errors cases = { - :a => [true, :a, 1], - "a" => [true, :a, 1], - :c => [false, true, 0], - 1 => [false, false, 0] + [Integer, "one"] => TypeError, + [Float, "one"] => TypeError, + [String, Time] => TypeError, + [:a, "one"] => ArgumentError, + [Date, "one"] => ArgumentError, + [Time, "one"] => ArgumentError } - cases.each do |input, expect| - assert_pattern do - OpenAI::Converter.try_strict_coerce(E1, input) => ^expect + cases.each do + target, input = _1 + state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + assert_raises(_2) do + OpenAI::Converter.coerce(target, input, state: state) end end end +end + +class OpenAI::Test::EnumModelTest < Minitest::Test + module E1 + extend OpenAI::Enum + + TRUE = true + end + + module E2 + extend OpenAI::Enum + + ONE = 1 + TWO = 2 + end - def test_basic_array_try_strict_coerce + module E3 + extend OpenAI::Enum + + ONE = 1.0 + TWO = 2.0 + end + + module E4 + extend OpenAI::Enum + + ONE = :one + TWO = :two + end + + def test_coerce cases = { - [] => [true, [], 0], - nil => [false, false, 0], - [1, 2, 3] => [true, [1, 2, 3], 3], - [1.0, 2.0, 3.0] => [true, [1, 2, 3], 3], - [1, nil, 3] => [true, [1, nil, 3], 2], - [1, nil, nil] => [true, [1, nil, nil], 1], - [1, "two", 3] => [false, true, 2] + [E1, true] => [{yes: 1}, true], + [E1, false] => [{no: 1}, false], + [E1, :true] => [{no: 1}, :true], + + [E2, 1] => [{yes: 1}, 1], + [E2, 1.0] => [{yes: 1}, 1], + [E2, 1.2] => [{no: 1}, 1.2], + [E2, "1"] => [{no: 1}, "1"], + + [E3, 1.0] => [{yes: 1}, 1.0], + [E3, 1] => [{yes: 1}, 1.0], + [E3, "one"] => [{no: 1}, "one"], + + [E4, :one] => [{yes: 1}, :one], + [E4, "one"] => [{yes: 1}, :one], + [E4, "1"] => [{maybe: 1}, "1"], + [E4, :"1"] => [{maybe: 1}, :"1"], + [E4, 1] => [{no: 1}, 1] } - cases.each do |input, expect| + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} assert_pattern do - OpenAI::Converter.try_strict_coerce(A1, input) => ^expect + OpenAI::Converter.coerce(target, input, state: state) => ^expect + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness end end end - def test_nested_array_try_strict_coerce + def test_dump cases = { - %w[a b] => [true, [:a, :b], 2], - %w[a b c] => [false, true, 2] + [E1, true] => true, + [E1, "true"] => "true", + + [E2, 1.0] => 1.0, + [E2, 3] => 3, + [E2, "1.0"] => "1.0", + + [E3, 1.0] => 1.0, + [E3, 3] => 3, + [E3, "1.0"] => "1.0", + + [E4, :one] => :one, + [E4, "one"] => "one", + [E4, "1.0"] => "1.0" } - cases.each do |input, expect| + cases.each do + target, input = _1 + expect = _2 assert_pattern do - OpenAI::Converter.try_strict_coerce(A2, input) => ^expect + OpenAI::Converter.dump(target, input) => ^expect end end end +end - class M1 < OpenAI::BaseModel - required :a, Time - optional :b, E1, api_name: :renamed - required :c, A1 +class OpenAI::Test::CollectionModelTest < Minitest::Test + A1 = OpenAI::ArrayOf[-> { Integer }] + H1 = OpenAI::HashOf[Integer] - request_only do - required :w, Integer - optional :x, String - end + A2 = OpenAI::ArrayOf[H1] + H2 = OpenAI::HashOf[-> { A1 }] - response_only do - required :y, Integer - optional :z, String + A3 = OpenAI::ArrayOf[Integer, nil?: true] + H3 = OpenAI::HashOf[Integer, nil?: true] + + def test_coerce + cases = { + [A1, []] => [{yes: 1}, []], + [A1, {}] => [{no: 1}, {}], + [A1, [1, 2.0]] => [{yes: 2, maybe: 1}, [1, 2]], + [A1, ["1", 2.0]] => [{yes: 1, maybe: 2}, [1, 2]], + [H1, {}] => [{yes: 1}, {}], + [H1, []] => [{no: 1}, []], + [H1, {a: 1, b: 2}] => [{yes: 3}, {a: 1, b: 2}], + [H1, {"a" => 1, "b" => 2}] => [{yes: 3}, {a: 1, b: 2}], + [H1, {[] => 1}] => [{yes: 2, no: 1}, {[] => 1}], + [H1, {a: 1.5}] => [{yes: 1, maybe: 1}, {a: 1}], + + [A2, [{}, {"a" => 1}]] => [{yes: 4}, [{}, {a: 1}]], + [A2, [{"a" => "1"}]] => [{yes: 2, maybe: 1}, [{a: 1}]], + [H2, {a: [1, 2]}] => [{yes: 4}, {a: [1, 2]}], + [H2, {"a" => ["1", 2]}] => [{yes: 3, maybe: 1}, {a: [1, 2]}], + [H2, {"a" => ["one", 2]}] => [{yes: 3, no: 1}, {a: ["one", 2]}], + + [A3, [nil, 1]] => [{yes: 3}, [nil, 1]], + [A3, [nil, "1"]] => [{yes: 2, maybe: 1}, [nil, 1]], + [H3, {a: nil, b: "1"}] => [{yes: 2, maybe: 1}, {a: nil, b: 1}], + [H3, {a: nil}] => [{yes: 2}, {a: nil}] + } + + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + assert_pattern do + OpenAI::Converter.coerce(target, input, state: state) => ^expect + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness + end end end +end - class M2 < M1 - required :c, M1 +class OpenAI::Test::BaseModelTest < Minitest::Test + class M1 < OpenAI::BaseModel + required :a, Integer end - def test_model_accessors - now = Time.now.round(0) - model = M2.new(a: now.to_s, b: "b", renamed: "a", c: [1.0, 2.0, 3.0], w: 1, y: 1) + class M2 < M1 + required :a, Time + required :b, Integer, nil?: true + optional :c, String + end - cases = [ - [model.a, now], - [model.b, :a], - [model.c, [1, 2, 3]], - [model.w, 1], - [model.y, 1] - ] + class M3 < OpenAI::BaseModel + optional :c, const: :c + required :d, const: :d + end - cases.each do |input, expect| - assert_pattern do - input => ^expect - end + class M4 < M1 + request_only do + required :a, Integer + optional :b, String end - end - def test_model_conversion_accessor - model = M2.new(c: {}) - assert_pattern do - model.c => M1 + response_only do + required :c, Integer + optional :d, String end end - def test_model_equality - now = Time.now - model1 = M2.new(a: now, b: "b", renamed: "a", c: M1.new, w: 1, y: 1) - model2 = M2.new(a: now, b: "b", renamed: "a", c: M1.new, w: 1, y: 1) + class M5 < OpenAI::BaseModel + request_only do + required :c, const: :c + end - assert_pattern do - model2 => ^model1 + response_only do + required :d, const: :d end end - def test_basic_model_coerce + class M6 < M1 + required :a, OpenAI::ArrayOf[M6] + end + + def test_coerce cases = { - {} => M2.new, - {a: nil, b: :a, c: [1.0, 2.0, 3.0], w: 1} => M2.new(a: nil, b: :a, c: [1.0, 2.0, 3.0], w: 1) + [M1, {}] => [{yes: 1, no: 1}, {}], + [M1, :m1] => [{no: 1}, :m1], + + [M2, {}] => [{yes: 2, no: 1, maybe: 1}, {}], + [M2, {a: "1990-09-19", b: nil}] => [{yes: 4}, {a: "1990-09-19", b: nil}], + [M2, {a: "1990-09-19", b: "1"}] => [{yes: 3, maybe: 1}, {a: "1990-09-19", b: "1"}], + [M2, {a: "1990-09-19"}] => [{yes: 3, maybe: 1}, {a: "1990-09-19"}], + [M2, {a: "1990-09-19", c: nil}] => [{yes: 2, maybe: 2}, {a: "1990-09-19", c: nil}], + + [M3, {c: "c", d: "d"}] => [{yes: 3}, {c: :c, d: :d}], + [M3, {c: "d", d: "c"}] => [{yes: 1, no: 2}, {c: "d", d: "c"}], + + [M4, {c: 2}] => [{yes: 5}, {c: 2}], + [M4, {a: "1", c: 2}] => [{yes: 4, maybe: 1}, {a: "1", c: 2}], + [M4, {b: nil, c: 2}] => [{yes: 4, maybe: 1}, {b: nil, c: 2}], + + [M5, {}] => [{yes: 3}, {}], + [M5, {c: "c"}] => [{yes: 3}, {c: :c}], + [M5, {d: "d"}] => [{yes: 3}, {d: :d}], + [M5, {d: nil}] => [{yes: 2, no: 1}, {d: nil}], + + [M6, {a: [{a: []}]}] => [{yes: 4}, -> { _1 in {a: [M6]} }] } - cases.each do |input, expect| + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} assert_pattern do - OpenAI::Converter.coerce(M2, input) => ^expect + coerced = OpenAI::Converter.coerce(target, input, state: state) + assert_equal(coerced, coerced) + if coerced.is_a?(OpenAI::BaseModel) + coerced.to_h => ^expect + else + coerced => ^expect + end + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness end end end - def test_basic_model_dump + def test_dump cases = { - nil => nil, - {} => {}, - {w: 1, x: "x", y: 1, z: "z"} => {w: 1, x: "x"}, - [1, 2, 3] => [1, 2, 3] + [M3, M3.new] => {d: :d}, + [M3, {}] => {d: :d}, + [M3, {d: 1}] => {d: 1}, + + [M4, M4.new(a: 1, b: "b", c: 2, d: "d")] => {a: 1, b: "b"}, + [M4, {a: 1, b: "b", c: 2, d: "d"}] => {a: 1, b: "b"}, + + [M5, M5.new] => {c: :c}, + [M5, {}] => {c: :c}, + [M5, {c: 1}] => {c: 1} } - cases.each do |input, expect| + cases.each do + target, input = _1 + expect = _2 assert_pattern do - OpenAI::Converter.dump(M2, input) => ^expect + OpenAI::Converter.dump(target, input) => ^expect end end end - def test_basic_model_try_strict_coerce - raw = {a: Time.now, c: [2], y: 1} - addn = {x: "x", n: "n"} - expect_exact = M1.new(raw) - expect_addn = M1.new(**raw, **addn) - + def test_accessors cases = { - {} => [false, true, 0], - raw => [true, expect_exact, 3], - {**raw, **addn} => [true, expect_addn, 4] + M2.new({a: "1990-09-19", b: "1"}) => {a: Time.new(1990, 9, 19), b: TypeError}, + M2.new(a: "one", b: "one") => {a: ArgumentError, b: TypeError}, + M2.new(a: nil, b: 2.0) => {a: TypeError, b: TypeError}, + + M3.new => {d: :d}, + M3.new(d: 1) => {d: ArgumentError}, + + M5.new => {c: :c, d: :d} } - cases.each do |input, expect| - assert_pattern do - OpenAI::Converter.try_strict_coerce(M1, input) => ^expect + cases.each do + target = _1 + _2.each do |accessor, expect| + case expect + in Class if expect <= StandardError + tap do + target.public_send(accessor) + flunk + rescue OpenAI::ConversionError => e + assert_kind_of(expect, e.cause) + end + else + assert_pattern { target.public_send(accessor) => ^expect } + end end end end +end - def test_nested_model_dump - now = Time.now - models = [M1, M2] - inputs = [ - M1.new(a: now, b: "a", c: [1.0, 2.0, 3.0], y: 1), - {a: now, b: "a", c: [1.0, 2.0, 3.0], y: 1}, - {"a" => now, b: "", "b" => "a", "c" => [], :c => [1.0, 2.0, 3.0], "y" => 1} - ] +class OpenAI::Test::UnionTest < Minitest::Test + module U0 + extend OpenAI::Union + end - models.product(inputs).each do |model, input| - assert_pattern do - OpenAI::Converter.dump(model, input) => {a: now, renamed: "a", c: [1, 2, 3]} - end - end + module U1 + extend OpenAI::Union + variant const: :a + variant const: 2 end - class M4 < M2 - required :c, M1 - required :d, OpenAI::ArrayOf[M4] - required :e, M2, api_name: :f + class M1 < OpenAI::BaseModel + required :t, const: :a, api_name: :type + optional :c, String end - def test_model_to_h - model = M4.new(a: "wow", c: {}, d: [{}, 2, {c: {}}], f: {}) - assert_pattern do - model.to_h => {a: "wow", c: M1, d: [M4, 2, M4 => child], f: M2} - assert_equal({c: M1.new}, child.to_h) - end + class M2 < OpenAI::BaseModel + required :type, const: :b + optional :c, String end - A3 = OpenAI::ArrayOf[A1] + module U2 + extend OpenAI::Union + discriminator :type - class M3 < M1 - optional :b, E1, api_name: :renamed_again + variant :a, M1 + variant :b, M2 end - module U1 + module U3 extend OpenAI::Union - discriminator :type + variant :a, M1 - variant :b, M3 + variant String end - module U2 + module U4 extend OpenAI::Union + discriminator :type - variant A1 - variant A3 + variant String + variant :a, M1 end - def test_basic_union - assert(U1.is_a?(OpenAI::Converter)) + class M3 < OpenAI::BaseModel + optional :recur, -> { U5 } + required :a, Integer + end - assert_pattern do - M1.new => U1 - M3.new => U1 - end + class M4 < OpenAI::BaseModel + optional :recur, -> { U5 } + required :a, OpenAI::ArrayOf[-> { U5 }] end - def test_basic_discriminated_union_coerce - common = {a: Time.now, c: [], w: 1} - cases = { - nil => nil, - {type: "a", **common} => M1.new(type: "a", **common), - {type: :b, **common} => M3.new(type: :b, **common), - {type: :c, xyz: 1} => {type: :c, xyz: 1} - } + class M5 < OpenAI::BaseModel + optional :recur, -> { U5 } + required :b, OpenAI::ArrayOf[-> { U5 }] + end - cases.each do |input, expect| - assert_pattern do - OpenAI::Converter.coerce(U1, input) => ^expect - end - end + module U5 + extend OpenAI::Union + + variant -> { M3 } + variant -> { M4 } end - def test_basic_discriminated_union_dump - now = Time.now - cases = { - nil => nil, - M1.new(a: now, b: :a, c: [1.0, 2.0, 3.0], y: 1) => {a: now, renamed: :a, c: [1, 2, 3]}, - M3.new(b: "a", y: 1) => {renamed_again: "a"}, - {type: :a, b: "a", y: 1} => {type: :a, renamed: "a"}, - {type: "b", b: "a", y: 1} => {type: "b", renamed_again: "a"}, - {type: :c, xyz: 1} => {type: :c, xyz: 1} - } + module U6 + extend OpenAI::Union - cases.each do |input, expect| - assert_pattern do - OpenAI::Converter.dump(U1, input) => ^expect - end + variant -> { M3 } + variant -> { M5 } + end + + def test_accessors + model = M3.new(recur: []) + tap do + model.recur + flunk + rescue OpenAI::ConversionError => e + assert_kind_of(ArgumentError, e.cause) end end - def test_basic_undifferentiated_union_try_strict_coerce + def test_coerce cases = { - [] => [true, [], 0], - [[]] => [true, [[]], 0], - # [nil] => [false, true, 0], - [1, 2, 3] => [true, [1, 2, 3], 3], - [[1, 2, 3], [4, 5, 6]] => [true, [[1, 2, 3], [4, 5, 6]], 6] + [U0, :""] => [{no: 1}, 0, :""], + + [U1, "a"] => [{yes: 1}, 1, :a], + [U1, "2"] => [{maybe: 1}, 2, 2], + [U1, :b] => [{no: 1}, 2, :b], + + [U2, {type: :a}] => [{yes: 3}, 0, {t: :a}], + [U2, {type: "b"}] => [{yes: 3}, 0, {type: :b}], + + [U3, "one"] => [{yes: 1}, 2, "one"], + [U4, "one"] => [{yes: 1}, 1, "one"], + + [U5, {a: []}] => [{yes: 3}, 2, {a: []}], + [U6, {b: []}] => [{yes: 3}, 2, {b: []}], + + [U5, {a: [{a: []}]}] => [{yes: 6}, 4, {a: [M4.new(a: [])]}], + [U5, {a: [{a: [{a: []}]}]}] => [{yes: 9}, 6, {a: [M4.new(a: [M4.new(a: [])])]}] } - cases.each do |input, expect| + cases.each do |lhs, rhs| + target, input = lhs + exactness, branched, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} assert_pattern do - OpenAI::Converter.try_strict_coerce(U2, input) => ^expect + coerced = OpenAI::Converter.coerce(target, input, state: state) + assert_equal(coerced, coerced) + if coerced.is_a?(OpenAI::BaseModel) + coerced.to_h => ^expect + else + coerced => ^expect + end + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness + state => {branched: ^branched} end end end +end - class C1 < OpenAI::BaseModel - required :a, const: :a - required :b, const: :b, nil?: true - optional :c, const: :c - end +class OpenAI::Test::BaseModelQoLTest < Minitest::Test + module E1 + extend OpenAI::Enum - def test_basic_const - assert_pattern do - C1.dump(C1.new) => {a: :a} - C1.new => {a: :a} - C1.new(a: "a") => {a: :a} - C1.new(b: 2) => {b: 2} - C1.new.a => :a - C1.new.b => nil - C1.new.c => nil - end + A = 1 end module E2 extend OpenAI::Enum - A = :a - B = :b + A = 1 end - module U3 - extend OpenAI::Union + module E3 + extend OpenAI::Enum - discriminator :type - variant :a, M1 - variant :b, M3 + A = 2 + B = 3 end - def test_basic_eql - assert_equal(OpenAI::Unknown, OpenAI::Unknown) - refute_equal(OpenAI::Unknown, OpenAI::BooleanModel) - assert_equal(OpenAI::BooleanModel, OpenAI::BooleanModel) - - assert_equal(E1, E2) - assert_equal(E1, E2) - - refute_equal(U1, U2) - assert_equal(U1, U3) + class M1 < OpenAI::BaseModel + required :a, Integer end - module U4 - extend OpenAI::Union + class M2 < OpenAI::BaseModel + required :a, Integer, nil?: true + end - variant :a, const: :a - variant :b, const: :b + class M3 < M2 + required :a, Integer end - def test_basic_const_union - assert_pattern do - U4.coerce(nil) => nil - U4.coerce("") => "" - U4.coerce(:a) => :a - U4.coerce("a") => :a + def test_equality + cases = { + [OpenAI::Unknown, OpenAI::Unknown] => true, + [OpenAI::BooleanModel, OpenAI::BooleanModel] => true, + [OpenAI::Unknown, OpenAI::BooleanModel] => false, + [E1, E2] => true, + [E1, E3] => false, + [M1, M2] => false, + [M1, M3] => true + } + + cases.each do + if _2 + assert_equal(*_1) + else + refute_equal(*_1) + end end end end