diff --git a/README.md b/README.md index 21bf0691b..04c714ba5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/lib/openai/helpers/structured_output/array_of.rb b/lib/openai/helpers/structured_output/array_of.rb index fb49cce10..13a890c42 100644 --- a/lib/openai/helpers/structured_output/array_of.rb +++ b/lib/openai/helpers/structured_output/array_of.rb @@ -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}] diff --git a/lib/openai/helpers/structured_output/base_model.rb b/lib/openai/helpers/structured_output/base_model.rb index b1e8b1036..6c8977e5d 100644 --- a/lib/openai/helpers/structured_output/base_model.rb +++ b/lib/openai/helpers/structured_output/base_model.rb @@ -58,6 +58,44 @@ def to_json_schema_inner(state:) end class << self + # @api private + def required(name_sym, type_info, spec = {}) + 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() + 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) diff --git a/lib/openai/helpers/structured_output/union_of.rb b/lib/openai/helpers/structured_output/union_of.rb index 26cb255bd..631b1094d 100644 --- a/lib/openai/helpers/structured_output/union_of.rb +++ b/lib/openai/helpers/structured_output/union_of.rb @@ -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}] diff --git a/lib/openai/internal/type/converter.rb b/lib/openai/internal/type/converter.rb index 0b31dfcf2..4fbe589ca 100644 --- a/lib/openai/internal/type/converter.rb +++ b/lib/openai/internal/type/converter.rb @@ -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 } diff --git a/test/openai/helpers/structured_output_test.rb b/test/openai/helpers/structured_output_test.rb index e91114e18..dd4c53231 100644 --- a/test/openai/helpers/structured_output_test.rb +++ b/test/openai/helpers/structured_output_test.rb @@ -36,6 +36,72 @@ class M3 < OpenAI::Helpers::StructuredOutput::BaseModel required :type, const: :m3, doc: "Model M3" end + class NestedParticipant < OpenAI::BaseModel + required :name, String + end + + class NestedEvent < OpenAI::BaseModel + required :participant, NestedParticipant + required :participants, OpenAI::ArrayOf[NestedParticipant] + required :choice, OpenAI::UnionOf[String, NestedParticipant] + required :status, OpenAI::EnumOf[:confirmed, :tentative] + end + + class NullableNestedEvent < OpenAI::BaseModel + required :participants, OpenAI::ArrayOf[NestedParticipant, nil?: true] + end + + class ScalarContractEvent < OpenAI::BaseModel + required :active, OpenAI::Boolean + required :status, OpenAI::EnumOf[:confirmed, :tentative] + required :flags, OpenAI::ArrayOf[OpenAI::Boolean] + end + + class NullableScalarContractEvent < OpenAI::BaseModel + required :active, OpenAI::Boolean, nil?: true + required :participant, NestedParticipant, nil?: true + end + + class NullableReaderContractEvent < OpenAI::BaseModel + required :name, String, nil?: true + required :kind, Symbol, nil?: true + required :count, Integer, nil?: true + required :amount, Float, nil?: true + required :active, OpenAI::Boolean, nil?: true + required :participant, NestedParticipant, nil?: true + required :participants, OpenAI::ArrayOf[NestedParticipant], nil?: true + required :status, OpenAI::EnumOf[:confirmed], nil?: true + required :choice, OpenAI::UnionOf[String, NestedParticipant], nil?: true + end + + class EmptyUnionMember < OpenAI::BaseModel + end + + class UnionContractEvent < OpenAI::BaseModel + required :choice, OpenAI::UnionOf[String, EmptyUnionMember] + end + + class SymbolContractEvent < OpenAI::BaseModel + required :kind, Symbol + end + + class RecursiveSymbolContractEvent < OpenAI::BaseModel + required :kinds, OpenAI::ArrayOf[Symbol] + required :nullable_kinds, OpenAI::ArrayOf[Symbol, nil?: true] + required :nested_kinds, OpenAI::ArrayOf[OpenAI::ArrayOf[Symbol]] + required :choice, OpenAI::UnionOf[Integer, Symbol] + required :choices, OpenAI::ArrayOf[OpenAI::UnionOf[Integer, Symbol]] + end + + class NestedSymbolContractEvent < OpenAI::BaseModel + required :participants, OpenAI::ArrayOf[SymbolContractEvent] + required :choice, OpenAI::UnionOf[Integer, SymbolContractEvent] + end + + class InheritedNestedEvent < NestedEvent + required :name, String + end + U1 = OpenAI::Helpers::StructuredOutput::UnionOf[Integer, A1] U2 = OpenAI::Helpers::StructuredOutput::UnionOf[M2, M3] U3 = OpenAI::Helpers::StructuredOutput::UnionOf[A1, A1] @@ -70,6 +136,514 @@ def test_base_model end end + def test_nested_readers_materialize_constructor_values_without_changing_raw_storage + participant = {name: "Ada"} + participants = [{name: "Grace"}] + choice = {name: "Katherine"} + event = NestedEvent.new( + participant: participant, + participants: participants, + choice: choice, + status: "confirmed" + ) + + assert_instance_of(NestedParticipant, event.participant) + assert_equal("Ada", event.participant.name) + assert_instance_of(NestedParticipant, event.participants.fetch(0)) + assert_instance_of(NestedParticipant, event.choice) + assert_equal(:confirmed, event.status) + assert_same(participant, event[:participant]) + assert_same(participants, event.to_h.fetch(:participants)) + assert_same(choice, event[:choice]) + end + + def test_nested_readers_materialize_assigned_values_without_changing_raw_storage + event = NestedEvent.new + participant = {name: "Ada"} + participants = [{name: "Grace"}] + choice = {name: "Katherine"} + + event.participant = participant + event.participants = participants + event.choice = choice + event.status = "tentative" + + assert_instance_of(NestedParticipant, event.participant) + assert_instance_of(NestedParticipant, event.participants.fetch(0)) + assert_instance_of(NestedParticipant, event.choice) + assert_equal(:tentative, event.status) + assert_same(participant, event.to_h.fetch(:participant)) + assert_same(participants, event[:participants]) + assert_same(choice, event.to_h.fetch(:choice)) + end + + def test_nested_readers_preserve_materialized_response_values + state = OpenAI::Internal::Type::Converter.new_coerce_state + event = OpenAI::Internal::Type::Converter.coerce( + NestedEvent, + { + participant: {name: "Ada"}, + participants: [{name: "Grace"}], + choice: {name: "Katherine"}, + status: "confirmed" + }, + state: state + ) + + assert_nil(state.fetch(:error)) + assert_instance_of(NestedParticipant, event.participant) + assert_instance_of(NestedParticipant, event.participants.fetch(0)) + assert_instance_of(NestedParticipant, event.choice) + assert_equal(:confirmed, event.status) + assert_same(event[:participant], event.participant) + assert_same(event[:participants], event.participants) + assert_same(event[:choice], event.choice) + end + + def test_nested_readers_preserve_nilable_array_elements + participants = [{name: "Ada"}, nil] + event = NullableNestedEvent.new(participants: participants) + + assert_instance_of(NestedParticipant, event.participants.fetch(0)) + assert_nil(event.participants.fetch(1)) + assert_same(participants, event[:participants]) + assert_same(participants, event.to_h.fetch(:participants)) + + replacement = [nil, {name: "Grace"}] + event.participants = replacement + + assert_nil(event.participants.fetch(0)) + assert_instance_of(NestedParticipant, event.participants.fetch(1)) + assert_same(replacement, event[:participants]) + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce( + NullableNestedEvent, + {participants: participants}, + state: state + ) + + assert_nil(state.fetch(:error)) + assert_instance_of(NestedParticipant, parsed.participants.fetch(0)) + assert_nil(parsed.participants.fetch(1)) + assert_same(parsed[:participants], parsed.participants) + + parsed.participants << nil + + assert_equal(3, parsed[:participants].length) + assert_same(parsed[:participants], parsed.participants) + end + + def test_nested_readers_are_preserved_by_subclass_inheritance + participant = {name: "Ada"} + participants = [{name: "Grace"}] + event = InheritedNestedEvent.new( + name: "conference", + participant: participant, + participants: participants, + choice: "speaker", + status: "confirmed" + ) + + assert_equal("conference", event.name) + assert_instance_of(NestedParticipant, event.participant) + assert_instance_of(NestedParticipant, event.participants.fetch(0)) + assert_equal("speaker", event.choice) + assert_equal(:confirmed, event.status) + assert_same(participant, event[:participant]) + assert_same(participants, event.to_h.fetch(:participants)) + end + + def test_nested_readers_observe_mutations_to_caller_owned_values + participant = {name: "Ada"} + participants = [{name: "Grace"}] + event = NestedEvent.new(participant: participant, participants: participants) + + participant[:name] = "Katherine" + participants << {name: "Dorothy"} + + assert_equal("Katherine", event.participant.name) + assert_equal(%w[Grace Dorothy], event.participants.map(&:name)) + assert_same(participant, event[:participant]) + assert_same(participants, event.to_h.fetch(:participants)) + end + + def test_nested_readers_preserve_existing_conversion_errors + participant = Object.new + event = NestedEvent.new(participant: participant) + + assert_raises(OpenAI::Errors::ConversionError) { event.participant } + assert_same(participant, event[:participant]) + assert_same(participant, event.to_h.fetch(:participant)) + end + + def test_typed_readers_reject_nonviable_scalar_constructor_values + event = ScalarContractEvent.new(active: "yes", status: "unknown") + + assert_raises(OpenAI::Errors::ConversionError) { event.active } + assert_raises(OpenAI::Errors::ConversionError) { event.status } + assert_equal("yes", event[:active]) + assert_equal("unknown", event[:status]) + end + + def test_typed_readers_reject_nil_for_required_non_nilable_fields + event = ScalarContractEvent.new(active: nil) + + assert_raises(OpenAI::Errors::ConversionError) { event.active } + assert_nil(event[:active]) + end + + def test_typed_readers_reject_missing_required_fields + scalar_event = ScalarContractEvent.new + nested_event = NestedEvent.new + + assert_raises(OpenAI::Errors::ConversionError) { scalar_event.active } + assert_raises(OpenAI::Errors::ConversionError) { scalar_event.status } + assert_raises(OpenAI::Errors::ConversionError) { scalar_event.flags } + assert_raises(OpenAI::Errors::ConversionError) { nested_event.participant } + assert_raises(OpenAI::Errors::ConversionError) { nested_event.participants } + assert_raises(OpenAI::Errors::ConversionError) { nested_event.status } + end + + def test_typed_readers_allow_nil_when_declared_nilable + event = NullableScalarContractEvent.new(active: nil) + + assert_nil(event.active) + assert_nil(event.participant) + assert_nil(event[:active]) + end + + def test_nullable_readers_accept_explicit_nil_for_every_supported_shape + readers = { + name: lambda(&:name), + kind: lambda(&:kind), + count: lambda(&:count), + amount: lambda(&:amount), + active: lambda(&:active), + participant: lambda(&:participant), + participants: lambda(&:participants), + status: lambda(&:status), + choice: lambda(&:choice) + } + payload = readers.keys.to_h { [_1, nil] } + constructed = NullableReaderContractEvent.new(payload) + readers.each_value { assert_nil(_1.call(constructed)) } + + assigned = NullableReaderContractEvent.new + assigned.name = nil + assigned.kind = nil + assigned.count = nil + assigned.amount = nil + assigned.active = nil + assigned.participant = nil + assigned.participants = nil + assigned.status = nil + assigned.choice = nil + readers.each { |name, reader| assert_nil(reader.call(assigned), name.to_s) } + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce(NullableReaderContractEvent, payload, state: state) + + assert_nil(state.fetch(:error)) + readers.each_value { assert_nil(_1.call(parsed)) } + assert_equal(payload, constructed.to_h) + assert_equal(payload, assigned.to_h) + assert_equal(payload, parsed.to_h) + end + + def test_union_readers_ignore_errors_from_rejected_alternatives + choice = {} + event = UnionContractEvent.new(choice: choice) + + assert_instance_of(EmptyUnionMember, event.choice) + assert_same(choice, event[:choice]) + + replacement = {} + event.choice = replacement + + assert_instance_of(EmptyUnionMember, event.choice) + assert_same(replacement, event.to_h.fetch(:choice)) + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce( + UnionContractEvent, + {choice: {}}, + state: state + ) + + assert_nil(state.fetch(:error)) + assert_instance_of(EmptyUnionMember, parsed.choice) + assert_same(parsed[:choice], parsed.choice) + end + + def test_symbol_readers_materialize_json_strings_without_replacing_raw_values + kind = "ready" + event = SymbolContractEvent.new(kind: kind) + + assert_equal(:ready, event.kind) + assert_same(kind, event[:kind]) + + replacement = "updated" + event.kind = replacement + + assert_equal(:updated, event.kind) + assert_same(replacement, event.to_h.fetch(:kind)) + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce( + SymbolContractEvent, + {kind: "parsed"}, + state: state + ) + + assert_equal(:parsed, parsed.kind) + end + + def test_symbol_readers_materialize_nested_array_and_union_values + kinds = ["ready"] + nullable_kinds = ["ready", nil] + nested_kinds = [["nested"]] + choice = "selected" + choices = ["first", 2] + event = RecursiveSymbolContractEvent.new( + kinds: kinds, + nullable_kinds: nullable_kinds, + nested_kinds: nested_kinds, + choice: choice, + choices: choices + ) + + assert_equal([:ready], event.kinds) + assert_equal([:ready, nil], event.nullable_kinds) + assert_equal([[:nested]], event.nested_kinds) + assert_equal(:selected, event.choice) + assert_equal([:first, 2], event.choices) + assert_same(kinds, event[:kinds]) + assert_same(nullable_kinds, event[:nullable_kinds]) + assert_same(nested_kinds, event[:nested_kinds]) + assert_same(choice, event[:choice]) + assert_same(choices, event[:choices]) + end + + def test_symbol_readers_materialize_assigned_and_parsed_container_values + kinds = ["assigned"] + choice = "assigned" + event = RecursiveSymbolContractEvent.new + event.kinds = kinds + event.choice = choice + + assert_equal([:assigned], event.kinds) + assert_equal(:assigned, event.choice) + assert_same(kinds, event.to_h.fetch(:kinds)) + assert_same(choice, event.to_h.fetch(:choice)) + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce( + RecursiveSymbolContractEvent, + { + kinds: ["parsed"], + nullable_kinds: ["parsed", nil], + nested_kinds: [["parsed"]], + choice: "parsed", + choices: ["parsed", 3] + }, + state: state + ) + + assert_nil(state.fetch(:error)) + assert_equal([:parsed], parsed.kinds) + assert_equal([:parsed, nil], parsed.nullable_kinds) + assert_equal([[:parsed]], parsed.nested_kinds) + assert_equal(:parsed, parsed.choice) + assert_equal([:parsed, 3], parsed.choices) + assert_same(parsed[:kinds], parsed.kinds) + assert_same(parsed[:nullable_kinds], parsed.nullable_kinds) + assert_same(parsed[:choices], parsed.choices) + end + + def test_symbol_readers_materialize_models_nested_in_arrays_and_unions + participants = [{kind: "ready"}] + choice = {kind: "selected"} + event = NestedSymbolContractEvent.new(participants: participants, choice: choice) + + assert_equal(:ready, event.participants.first.kind) + assert_equal(:selected, event.choice.kind) + assert_same(participants, event[:participants]) + assert_same(choice, event[:choice]) + + assigned_participants = [{kind: "assigned"}] + assigned_choice = {kind: "assigned"} + event.participants = assigned_participants + event.choice = assigned_choice + + assert_equal(:assigned, event.participants.first.kind) + assert_equal(:assigned, event.choice.kind) + assert_same(assigned_participants, event.to_h.fetch(:participants)) + assert_same(assigned_choice, event.to_h.fetch(:choice)) + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce( + NestedSymbolContractEvent, + {participants: [{kind: "parsed"}], choice: {kind: "parsed"}}, + state: state + ) + + assert_nil(state.fetch(:error)) + assert_equal(:parsed, parsed.participants.first.kind) + assert_equal(:parsed, parsed.choice.kind) + assert_same(parsed[:participants], parsed.participants) + assert_same(parsed[:choice], parsed.choice) + end + + def test_supported_scalars_materialize_consistently_through_nested_model_shapes + cases = { + String => [:ready, "ready"], + Symbol => ["ready", :ready], + Integer => ["42", 42], + Float => ["4.5", 4.5], + OpenAI::Boolean => [true, true], + OpenAI::EnumOf[:ready] => ["ready", :ready] + } + assert_readers = lambda do |event, expected| + assert_equal(expected, event.member.value) + assert_equal(expected, event.members.first.value) + assert_equal(expected, event.choice.value) + end + + cases.each do |type, (input, expected)| + member = Class.new(OpenAI::BaseModel) { required :value, type } + container = Class.new(OpenAI::BaseModel) do + required :member, member + required :members, OpenAI::ArrayOf[member] + required :choice, OpenAI::UnionOf[String, member] + end + payload = {member: {value: input}, members: [{value: input}], choice: {value: input}} + event = container.new(payload) + + assert_readers.call(event, expected) + payload.each { |name, raw| assert_same(raw, event[name]) } + + event.members = payload.fetch(:members) + event.choice = payload.fetch(:choice) + + assert_readers.call(event, expected) + payload.each { |name, raw| assert_same(raw, event.to_h.fetch(name)) } + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce(container, payload, state: state) + + assert_nil(state.fetch(:error)) + assert_readers.call(parsed, expected) + assert_same(parsed[:members], parsed.members) + assert_same(parsed[:choice], parsed.choice) + end + end + + def test_responses_api_parses_nested_structured_model_readers_end_to_end + content = {participants: [{kind: "ready"}], choice: {kind: "selected"}} + body = { + id: "resp_contract", + created_at: 1, + model: "gpt-4o", + object: "response", + output: [ + { + id: "msg_contract", + type: "message", + role: "assistant", + status: "completed", + content: [{type: "output_text", text: JSON.generate(content), annotations: []}] + } + ], + parallel_tool_calls: false, + tool_choice: "auto", + tools: [] + } + response = OpenAI::HTTPClient::Response.new( + status: 200, + headers: {"content-type" => "application/json"}, + body: JSON.generate(body) + ) + transport = OpenAI::HTTPClient.new + client = OpenAI::Client.new(api_key: "test-key", http_client: transport) + + transport.stub(:execute, response) do + result = client.responses.create( + input: "Extract participants", model: "gpt-4o", text: NestedSymbolContractEvent + ) + parsed = result.output.fetch(0).content.fetch(0).parsed + + assert_instance_of(NestedSymbolContractEvent, parsed) + assert_equal(:ready, parsed.participants.fetch(0).kind) + assert_equal(:selected, parsed.choice.kind) + assert_same(parsed[:participants], parsed.participants) + end + end + + def test_chat_completions_parse_nested_structured_model_readers_end_to_end + content = {participants: [{kind: "ready"}], choice: {kind: "selected"}} + body = { + id: "chatcmpl_contract", + created: 1, + model: "gpt-4o", + object: "chat.completion", + choices: [ + { + index: 0, + finish_reason: "stop", + message: {role: "assistant", content: JSON.generate(content), refusal: nil} + } + ] + } + response = OpenAI::HTTPClient::Response.new( + status: 200, + headers: {"content-type" => "application/json"}, + body: JSON.generate(body) + ) + transport = OpenAI::HTTPClient.new + client = OpenAI::Client.new(api_key: "test-key", http_client: transport) + + transport.stub(:execute, response) do + result = client.chat.completions.create( + messages: [{role: :user, content: "Extract participants"}], + model: "gpt-4o", + response_format: NestedSymbolContractEvent + ) + parsed = result.choices.fetch(0).message.parsed + + assert_instance_of(NestedSymbolContractEvent, parsed) + assert_equal(:ready, parsed.participants.fetch(0).kind) + assert_equal(:selected, parsed.choice.kind) + assert_same(parsed[:participants], parsed.participants) + end + end + + def test_typed_readers_reject_nonviable_scalar_assignment_values + event = ScalarContractEvent.new + event.active = "yes" + event.status = "unknown" + + assert_raises(OpenAI::Errors::ConversionError) { event.active } + assert_raises(OpenAI::Errors::ConversionError) { event.status } + assert_equal("yes", event.to_h.fetch(:active)) + assert_equal("unknown", event.to_h.fetch(:status)) + end + + def test_typed_readers_reject_invalid_mutations_to_caller_owned_containers + flags = [true] + participants = [{name: "Ada"}] + scalar_event = ScalarContractEvent.new(flags: flags) + nested_event = NestedEvent.new(participants: participants) + + flags << "yes" + participants << Object.new + + assert_raises(OpenAI::Errors::ConversionError) { scalar_event.flags } + assert_raises(OpenAI::Errors::ConversionError) { nested_event.participants } + assert_same(flags, scalar_event[:flags]) + assert_same(participants, nested_event[:participants]) + end + def test_to_schema cases = { NilClass => {type: "null"}, diff --git a/test/openai/internal/type/base_model_test.rb b/test/openai/internal/type/base_model_test.rb index f8aae4d23..40f959a77 100644 --- a/test/openai/internal/type/base_model_test.rb +++ b/test/openai/internal/type/base_model_test.rb @@ -54,6 +54,9 @@ def test_coerce [String, :str] => [{yes: 1}, "str"], [String, "str"] => [{yes: 1}, "str"], [String, 1] => [{maybe: 1}, "1"], + [Symbol, :str] => [{yes: 1}, :str], + [Symbol, "str"] => [{yes: 1}, :str], + [Symbol, 1] => [{no: 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)], @@ -111,6 +114,7 @@ def test_coerce_errors [Integer, "one"] => ArgumentError, [Float, "one"] => ArgumentError, [String, Time] => TypeError, + [Symbol, 1] => TypeError, [Date, "one"] => ArgumentError, [Time, "one"] => ArgumentError } diff --git a/test/openai/tapioca/base_model_compiler_test.rb b/test/openai/tapioca/base_model_compiler_test.rb index 601dfd2dd..c681f03e7 100644 --- a/test/openai/tapioca/base_model_compiler_test.rb +++ b/test/openai/tapioca/base_model_compiler_test.rb @@ -15,17 +15,49 @@ module OpenAIBaseModelCompilerFixtures class Participant < OpenAI::BaseModel required :name, String required :nickname, String, nil?: true + required :role, Symbol end class Event < OpenAI::BaseModel required :active, OpenAI::Boolean required :description, String, nil?: true + required :kind, Symbol + required :kinds, OpenAI::ArrayOf[Symbol] + required :symbol_choice, OpenAI::UnionOf[Integer, Symbol] required :participant, Participant required :participants, OpenAI::ArrayOf[Participant] required :aliases, OpenAI::ArrayOf[String, nil?: true] required :status, OpenAI::EnumOf[:confirmed, :tentative] required :detail, OpenAI::UnionOf[String, Participant] end + + def self.assert_reader_contract(event, source:, detail_type: Participant) + checks = { + active: [true, false].include?(event.active), + description: event.description.nil? || event.description.is_a?(String), + kind: event.kind.is_a?(Symbol), + kinds: event.kinds.all?(Symbol), + symbol_choice: event.symbol_choice.is_a?(Symbol), + participant: event.participant.is_a?(Participant) && event.participant.role.is_a?(Symbol), + participants: event.participants.all? { _1.is_a?(Participant) && _1.role.is_a?(Symbol) }, + aliases: event.aliases.all? { _1.nil? || _1.is_a?(String) }, + status: event.status.is_a?(Symbol), + detail: event.detail.is_a?(detail_type) && + (detail_type == String || event.detail.role.is_a?(Symbol)) + } + + checks.each do |field, matches| + abort([source, field, "reader disagrees with its generated RBI"].join(": ")) unless matches + end + end + + def self.assert_raw_values(event, values, source:) + values.each do |field, raw| + next if event[field].equal?(raw) && event.to_h.fetch(field).equal?(raw) + + abort([source, field, "raw caller-owned value was replaced"].join(": ")) + end + end end require "tapioca/helpers/test/dsl_compiler" @@ -39,6 +71,90 @@ class Event < OpenAI::BaseModel rbi = context.rbi_for("OpenAIBaseModelCompilerFixtures::Event") participant_rbi = context.rbi_for("OpenAIBaseModelCompilerFixtures::Participant") + participant = {name: "Ada", nickname: nil, role: "speaker"} + participants = [{name: "Grace", nickname: nil, role: "organizer"}] + aliases = [:lead, nil] + status = "confirmed" + detail = {name: "Margaret", nickname: nil, role: "moderator"} + payload = { + active: true, + description: "scheduled", + kind: "conference", + kinds: ["conference", "presentation"], + symbol_choice: "speaker", + participant: participant, + participants: participants, + aliases: aliases, + status: status, + detail: detail + } + constructed = OpenAIBaseModelCompilerFixtures::Event.new(payload) + OpenAIBaseModelCompilerFixtures.assert_reader_contract(constructed, source: "constructor") + OpenAIBaseModelCompilerFixtures.assert_raw_values( + constructed, + payload.slice(:kind, :kinds, :symbol_choice, :participant, :participants, :aliases, :status, :detail), + source: "constructor" + ) + nullable = OpenAIBaseModelCompilerFixtures::Event.new(description: nil) + abort("nullable constructor reader rejected nil") unless nullable.description.nil? + + replacement = {name: "Katherine", nickname: nil, role: "speaker"} + replacement_participants = [{name: "Joan", nickname: nil, role: "organizer"}] + replacement_aliases = [:reviewer, nil] + replacement_status = "tentative" + replacement_detail = {name: "Dorothy", nickname: nil, role: "moderator"} + assigned = OpenAIBaseModelCompilerFixtures::Event.new + assigned.active = false + assigned.description = "updated" + assigned.kind = "meeting" + assigned.kinds = ["meeting"] + assigned.symbol_choice = "organizer" + assigned.participant = replacement + assigned.participants = replacement_participants + assigned.aliases = replacement_aliases + assigned.status = replacement_status + assigned.detail = replacement_detail + OpenAIBaseModelCompilerFixtures.assert_reader_contract(assigned, source: "assignment") + OpenAIBaseModelCompilerFixtures.assert_raw_values( + assigned, + { + participant: replacement, + participants: replacement_participants, + aliases: replacement_aliases, + status: replacement_status, + detail: replacement_detail + }, + source: "assignment" + ) + assigned.description = nil + abort("nullable assignment reader rejected nil") unless assigned.description.nil? + assigned.description = "updated" + + assigned.detail = "speaker" + OpenAIBaseModelCompilerFixtures.assert_reader_contract( + assigned, + source: "scalar union assignment", + detail_type: String + ) + + state = OpenAI::Internal::Type::Converter.new_coerce_state + parsed = OpenAI::Internal::Type::Converter.coerce( + OpenAIBaseModelCompilerFixtures::Event, + payload.merge(description: nil), + state: state + ) + abort("parsed response reported a conversion error") unless state.fetch(:error).nil? + OpenAIBaseModelCompilerFixtures.assert_reader_contract(parsed, source: "parsed response") + { + participant: parsed.participant, + participants: parsed.participants, + detail: parsed.detail + }.each do |field, materialized| + abort(["parsed response", field, "materialized value was replaced"].join(": ")) unless ( + parsed[field].equal?(materialized) + ) + end + require "tmpdir" Dir.mktmpdir("openai-base-model-compiler-test") do |dir| rbi_path = File.join(dir, "event.rbi") @@ -54,7 +170,11 @@ class Event < OpenAI::BaseModel T.let(event.active, T::Boolean) T.let(event.description, T.nilable(String)) + T.let(event.kind, Symbol) + T.let(event.kinds, T::Array[Symbol]) + T.let(event.symbol_choice, T.any(Integer, Symbol)) T.let(event.participant, OpenAIBaseModelCompilerFixtures::Participant) + T.let(event.participant.role, Symbol) T.let( event.participants, T::Array[OpenAIBaseModelCompilerFixtures::Participant] @@ -65,6 +185,13 @@ class Event < OpenAI::BaseModel event.detail, T.any(OpenAIBaseModelCompilerFixtures::Participant, String) ) + + constructed = OpenAIBaseModelCompilerFixtures::Event.new + T.let(constructed.participant, OpenAIBaseModelCompilerFixtures::Participant) + T.let( + constructed.participants, + T::Array[OpenAIBaseModelCompilerFixtures::Participant] + ) USAGE runner = Object.new.extend(Tapioca::SorbetHelper) @@ -118,6 +245,9 @@ def test_generates_typed_readers_for_structured_output_models assert_includes(stdout, "class OpenAIBaseModelCompilerFixtures::Event") assert_includes(stdout, "sig { returns(T::Boolean) }\n def active; end") assert_includes(stdout, "sig { returns(T.nilable(String)) }\n def description; end") + assert_includes(stdout, "sig { returns(Symbol) }\n def kind; end") + assert_includes(stdout, "sig { returns(T::Array[Symbol]) }\n def kinds; end") + assert_includes(stdout, "sig { returns(T.any(Integer, Symbol)) }\n def symbol_choice; end") assert_includes( stdout, "sig { returns(OpenAIBaseModelCompilerFixtures::Participant) }\n def participant; end"