diff --git a/README.md b/README.md index 46bbca82..b2592346 100644 --- a/README.md +++ b/README.md @@ -934,26 +934,6 @@ params = OpenAI::Chat::CompletionCreateParams.new( openai.chat.completions.create(**params) ``` -### Structured output models - -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`. - -Response `parsed` fields can contain different application-defined models, so -their generated SDK type remains broad. Cast a parsed value to the structured -output model supplied with the request before accessing its generated readers: - -```ruby -event = T.cast(content.parsed, CalendarEvent) -puts(event.name) -``` - -The compiler is only loaded by Tapioca; using the SDK normally still does not -require `sorbet-runtime`. - ### Enums Since this library does not depend on `sorbet-runtime`, it cannot provide [`T::Enum`](https://sorbet.org/docs/tenum) instances. Instead, we provide "tagged symbols" instead, which is always a primitive at runtime: diff --git a/lib/tapioca/dsl/compilers/openai_base_model.rb b/lib/tapioca/dsl/compilers/openai_base_model.rb deleted file mode 100644 index d00e05c4..00000000 --- a/lib/tapioca/dsl/compilers/openai_base_model.rb +++ /dev/null @@ -1,39 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require "openai" - -return unless defined?(OpenAI::BaseModel) - -module Tapioca - module Dsl - module Compilers - # Generates RBI definitions for application-defined structured output models. - class OpenAIBaseModel < Compiler - extend T::Sig - - ConstantType = type_member { {fixed: T.class_of(::OpenAI::BaseModel)} } - - sig { override.void } - def decorate - root.create_path(constant) do |model| - constant.fields.each do |name, field| - type = ::OpenAI::Internal::Util::SorbetRuntimeSupport.to_sorbet_type(field.fetch(:type)).to_s - type = as_nilable_type(type) if field.fetch(:nilable) || !field.fetch(:required) - model.create_method(name.to_s, return_type: type) - end - end - end - - class << self - extend T::Sig - - sig { override.returns(T::Enumerable[Module]) } - def gather_constants - all_classes.select { _1 < ::OpenAI::BaseModel } - end - end - end - end - end -end diff --git a/test/openai/helpers/structured_output_api_names_test.rb b/test/openai/helpers/structured_output_api_names_test.rb index fd4030dc..9ae07d21 100644 --- a/test/openai/helpers/structured_output_api_names_test.rb +++ b/test/openai/helpers/structured_output_api_names_test.rb @@ -16,6 +16,11 @@ class AliasedEnvelope < OpenAI::BaseModel required :backup_profile, AliasedProfile, api_name: :backupProfile end + class AliasedProfileCollection < OpenAI::BaseModel + required :primary_profile, AliasedProfile, api_name: :primaryProfile + required :profiles, OpenAI::ArrayOf[AliasedProfile] + end + class AliasedNameCollision < OpenAI::BaseModel required :display_name, String, api_name: :displayName required :displayName, String, api_name: :legacyDisplayName @@ -254,4 +259,52 @@ def test_responses_round_trips_api_named_structured_output assert_equal("Ada", parsed.display_name) assert_nil(parsed.middle_name) end + + def test_public_structured_output_endpoints_materialize_nested_models + profile = {displayName: "Ada", middleName: nil} + content = {primaryProfile: profile, profiles: [profile]}.to_json + + stub_request(:post, "http://localhost/chat/completions").to_return_json( + status: 200, + body: { + id: "chatcmpl_nested", + choices: [{finish_reason: "stop", index: 0, message: {content: content, role: "assistant"}}], + created: 1_700_000_000, + model: "gpt-4o-mini", + object: "chat.completion" + } + ) + stub_request(:post, "http://localhost/responses").to_return_json( + status: 200, + body: { + id: "resp_nested", + output: [ + { + id: "msg_nested", + content: [{annotations: [], text: content, type: "output_text"}], + role: "assistant", + status: "completed", + type: "message" + } + ] + } + ) + + chat = @client.chat.completions.create( + messages: [{content: "Generate profiles", role: :user}], + model: "gpt-4o-mini", + response_format: AliasedProfileCollection + ) + response = @client.responses.create( + model: "gpt-4o-mini", input: "Generate profiles", text: AliasedProfileCollection + ) + + [chat.choices.first.message.parsed, response.output.first.content.first.parsed].each do |parsed| + assert_instance_of(AliasedProfileCollection, parsed) + assert_instance_of(AliasedProfile, parsed.primary_profile) + assert_instance_of(AliasedProfile, parsed.profiles.fetch(0)) + assert_equal("Ada", parsed.primary_profile.display_name) + assert_equal("Ada", parsed.profiles.fetch(0).display_name) + end + end end diff --git a/test/openai/helpers/structured_output_test.rb b/test/openai/helpers/structured_output_test.rb index e91114e1..3baee370 100644 --- a/test/openai/helpers/structured_output_test.rb +++ b/test/openai/helpers/structured_output_test.rb @@ -36,6 +36,15 @@ 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] + end + U1 = OpenAI::Helpers::StructuredOutput::UnionOf[Integer, A1] U2 = OpenAI::Helpers::StructuredOutput::UnionOf[M2, M3] U3 = OpenAI::Helpers::StructuredOutput::UnionOf[A1, A1] @@ -70,6 +79,42 @@ def test_base_model end end + def test_direct_structured_output_models_preserve_nested_raw_values + participant = {name: "Ada"} + participants = [{name: "Grace"}] + event = NestedEvent.new(participant: participant, participants: participants) + + assert_same(participant, event.participant) + assert_same(participants, event.participants) + + replacement = {name: "Katherine"} + event.participant = replacement + + assert_same(replacement, event.participant) + assert_same(replacement, event.to_h.fetch(:participant)) + + replacement_participants = [{name: "Dorothy"}] + event.participants = replacement_participants + + assert_same(replacement_participants, event.participants) + assert_same(replacement_participants, event.to_h.fetch(:participants)) + end + + def test_response_coercion_materializes_nested_structured_output_models + state = OpenAI::Internal::Type::Converter.new_coerce_state + event = OpenAI::Internal::Type::Converter.coerce( + NestedEvent, + {participant: {name: "Ada"}, participants: [{name: "Grace"}]}, + state: state + ) + + assert_instance_of(NestedParticipant, event.participant) + assert_instance_of(NestedParticipant, event.participants.fetch(0)) + assert_equal("Ada", event.participant.name) + assert_equal("Grace", event.participants.fetch(0).name) + assert_nil(state.fetch(:error)) + end + def test_to_schema cases = { NilClass => {type: "null"}, diff --git a/test/openai/internal/type/array_of_sorbet_type_test.rb b/test/openai/internal/type/array_of_sorbet_type_test.rb new file mode 100644 index 00000000..c3587d11 --- /dev/null +++ b/test/openai/internal/type/array_of_sorbet_type_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "open3" +require "rbconfig" + +require_relative "../../test_helper" + +class OpenAI::Test::ArrayOfSorbetTypeTest < Minitest::Test + def test_nullable_elements_are_preserved_in_sorbet_types + stdout, stderr, status = Open3.capture3( + {"RUBYOPT" => nil}, + RbConfig.ruby, + "-I", + File.expand_path("../../../../lib", __dir__), + "-rsorbet-runtime", + "-ropenai", + "-e", + "puts OpenAI::ArrayOf[Integer, nil?: true].to_sorbet_type" + ) + + assert_predicate(status, :success?, stderr) + assert_equal("T::Array[T.nilable(Integer)]\n", stdout) + end +end diff --git a/test/openai/tapioca/base_model_compiler_test.rb b/test/openai/tapioca/base_model_compiler_test.rb deleted file mode 100644 index 601dfd2d..00000000 --- a/test/openai/tapioca/base_model_compiler_test.rb +++ /dev/null @@ -1,142 +0,0 @@ -# frozen_string_literal: true - -require "open3" -require "rbconfig" - -require_relative "../test_helper" - -class OpenAI::Test::BaseModelCompilerTest < Minitest::Test - ROOT = File.expand_path("../../..", __dir__) - - COMPILER_SCRIPT = <<~RUBY - require "openai" - - module OpenAIBaseModelCompilerFixtures - class Participant < OpenAI::BaseModel - required :name, String - required :nickname, String, nil?: true - end - - class Event < OpenAI::BaseModel - required :active, OpenAI::Boolean - required :description, String, nil?: true - 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 - end - - require "tapioca/helpers/test/dsl_compiler" - abort("compiler is not discoverable") unless Gem.find_files( - "tapioca/dsl/compilers/openai_base_model.rb" - ).any? - require "tapioca/dsl/compilers/openai_base_model" - - compiler = Tapioca::Dsl::Compilers::OpenAIBaseModel - context = Tapioca::Helpers::Test::DslCompiler::CompilerContext.new(compiler) - rbi = context.rbi_for("OpenAIBaseModelCompilerFixtures::Event") - participant_rbi = context.rbi_for("OpenAIBaseModelCompilerFixtures::Participant") - - require "tmpdir" - Dir.mktmpdir("openai-base-model-compiler-test") do |dir| - rbi_path = File.join(dir, "event.rbi") - participant_rbi_path = File.join(dir, "participant.rbi") - usage_path = File.join(dir, "usage.rb") - File.write(rbi_path, rbi) - File.write(participant_rbi_path, participant_rbi) - File.write(usage_path, <<~USAGE) - # typed: true - - parsed = T.let(T.unsafe(nil), T.anything) - event = T.cast(parsed, OpenAIBaseModelCompilerFixtures::Event) - - T.let(event.active, T::Boolean) - T.let(event.description, T.nilable(String)) - T.let(event.participant, OpenAIBaseModelCompilerFixtures::Participant) - T.let( - event.participants, - T::Array[OpenAIBaseModelCompilerFixtures::Participant] - ) - T.let(event.aliases, T::Array[T.nilable(String)]) - T.let(event.status, Symbol) - T.let( - event.detail, - T.any(OpenAIBaseModelCompilerFixtures::Participant, String) - ) - USAGE - - runner = Object.new.extend(Tapioca::SorbetHelper) - result = runner.sorbet( - "--no-config", - "--dir", - "rbi", - rbi_path, - participant_rbi_path, - usage_path - ) - abort(result.err) unless result.status - end - - puts(rbi) - RUBY - - def test_loads_through_the_tapioca_cli - stdout, stderr, status = - Open3.capture3( - {"RUBYOPT" => nil}, - RbConfig.ruby, - Gem.bin_path("bundler", "bundle"), - "exec", - "tapioca", - "dsl", - "--list-compilers", - "--only", - "OpenAIBaseModel", - chdir: ROOT - ) - - assert_predicate(status, :success?, stderr) - assert_includes(stdout, "Tapioca::Dsl::Compilers::OpenAIBaseModel") - refute_includes(stdout, "Cannot find compiler 'OpenAIBaseModel'") - end - - def test_generates_typed_readers_for_structured_output_models - stdout, stderr, status = - Open3.capture3( - {"RUBYOPT" => nil}, - RbConfig.ruby, - "-I", - File.join(ROOT, "lib"), - "-e", - COMPILER_SCRIPT, - chdir: ROOT - ) - - assert_predicate(status, :success?, stderr) - 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(OpenAIBaseModelCompilerFixtures::Participant) }\n def participant; end" - ) - assert_includes( - stdout, - <<~RBI.chomp - sig { returns(T::Array[OpenAIBaseModelCompilerFixtures::Participant]) } - def participants; end - RBI - ) - assert_includes(stdout, "sig { returns(T::Array[T.nilable(String)]) }\n def aliases; end") - assert_includes(stdout, "sig { returns(Symbol) }\n def status; end") - assert_includes( - stdout, - <<~RBI.chomp - sig { returns(T.any(OpenAIBaseModelCompilerFixtures::Participant, String)) } - def detail; end - RBI - ) - end -end