Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 0 additions & 39 deletions lib/tapioca/dsl/compilers/openai_base_model.rb

This file was deleted.

53 changes: 53 additions & 0 deletions test/openai/helpers/structured_output_api_names_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions test/openai/helpers/structured_output_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"},
Expand Down
24 changes: 24 additions & 0 deletions test/openai/internal/type/array_of_sorbet_type_test.rb
Original file line number Diff line number Diff line change
@@ -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
142 changes: 0 additions & 142 deletions test/openai/tapioca/base_model_compiler_test.rb

This file was deleted.