diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 516b8e5e0..1f10298b9 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: cf94c82f-0604-4dfc-aff0-cec482e84953 -openapi_spec_hash: ce912f79d7f8cb19a7a515513c214b8a -openapi_transformed_spec_hash: 352b5a7a028f990b6475b6593e381eee -config_hash: dde98d9c41cd6f4163320f92c70f83e6 -codegen_sha: 326831af0c0fdf859335a189df2b6716e4b605d4 -codegen_hash: 00e7f6a53e1a6a1adf02385bc368fce999fdd775d1f1c28615f790c7a4109ad0 -public_codegen_sha: c2abe7c282c83c8fb9a7330254235c20b67a2b10 +generation_id: 1adb01cf-0ccd-40c4-9b52-01a4c8a93334 +openapi_spec_hash: f6e87553c8927460254053bc57e15965 +openapi_transformed_spec_hash: 6e67b20b0fdae6b9604eddb74b8ed579 +config_hash: 9e898f4e0d2bc336a854079cff546681 +codegen_sha: 04de33a1417a85263b0056f306860d548229620b +codegen_hash: b10163fadd6166d075d7a4a8b2b340da85dab58cfda9325f03146d618d53e2a6 +public_codegen_sha: d955f54bcb210631fb4a210427ecd96aa764e2c7 diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index 30f3e6581..5f013e996 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -53927,6 +53927,8 @@ components: const: session.created session: description: The session configuration. + discriminator: + propertyName: type anyOf: - $ref: '#/components/schemas/RealtimeSessionCreateRequestGA' - $ref: '#/components/schemas/RealtimeTranscriptionSessionCreateRequestGA' @@ -54001,6 +54003,8 @@ components: const: session.updated session: description: The session configuration. + discriminator: + propertyName: type anyOf: - $ref: '#/components/schemas/RealtimeSessionCreateRequestGA' - $ref: '#/components/schemas/RealtimeTranscriptionSessionCreateRequestGA' diff --git a/lib/openai/helpers/realtime/connection.rb b/lib/openai/helpers/realtime/connection.rb index 3dff32c81..08242c519 100644 --- a/lib/openai/helpers/realtime/connection.rb +++ b/lib/openai/helpers/realtime/connection.rb @@ -39,6 +39,24 @@ def parse_event(data) return OpenAI::Realtime::UnknownServerEvent.new(data: parsed) end + session_union = case type.to_s + when "session.created" + OpenAI::Realtime::SessionCreatedEvent::Session + when "session.updated" + OpenAI::Realtime::SessionUpdatedEvent::Session + end + + if session_union + session = parsed[:session] + unless session.is_a?(Hash) && session[:type].is_a?(String) + raise ArgumentError, "Realtime session must be an object with a string type" + end + + unless discriminator_values(session_union).key?(session[:type]) + raise ArgumentError, "Unsupported Realtime session type" + end + end + state = OpenAI::Internal::Type::Converter.new_coerce_state event = OpenAI::Internal::Type::Converter.coerce( OpenAI::Realtime::RealtimeServerEvent, diff --git a/lib/openai/models/realtime/session_created_event.rb b/lib/openai/models/realtime/session_created_event.rb index ca2b4f15b..9e61223e1 100644 --- a/lib/openai/models/realtime/session_created_event.rb +++ b/lib/openai/models/realtime/session_created_event.rb @@ -42,11 +42,13 @@ class SessionCreatedEvent < OpenAI::Internal::Type::BaseModel module Session extend OpenAI::Internal::Type::Union + discriminator :type + # Realtime session object configuration. - variant -> { OpenAI::Realtime::RealtimeSessionCreateRequest } + variant :realtime, -> { OpenAI::Realtime::RealtimeSessionCreateRequest } # Realtime transcription session object configuration. - variant -> { OpenAI::Realtime::RealtimeTranscriptionSessionCreateRequest } + variant :transcription, -> { OpenAI::Realtime::RealtimeTranscriptionSessionCreateRequest } # @!method self.variants # @return [Array(OpenAI::Models::Realtime::RealtimeSessionCreateRequest, OpenAI::Models::Realtime::RealtimeTranscriptionSessionCreateRequest)] diff --git a/lib/openai/models/realtime/session_updated_event.rb b/lib/openai/models/realtime/session_updated_event.rb index 712e9c2a5..9e4ecb4cd 100644 --- a/lib/openai/models/realtime/session_updated_event.rb +++ b/lib/openai/models/realtime/session_updated_event.rb @@ -41,11 +41,13 @@ class SessionUpdatedEvent < OpenAI::Internal::Type::BaseModel module Session extend OpenAI::Internal::Type::Union + discriminator :type + # Realtime session object configuration. - variant -> { OpenAI::Realtime::RealtimeSessionCreateRequest } + variant :realtime, -> { OpenAI::Realtime::RealtimeSessionCreateRequest } # Realtime transcription session object configuration. - variant -> { OpenAI::Realtime::RealtimeTranscriptionSessionCreateRequest } + variant :transcription, -> { OpenAI::Realtime::RealtimeTranscriptionSessionCreateRequest } # @!method self.variants # @return [Array(OpenAI::Models::Realtime::RealtimeSessionCreateRequest, OpenAI::Models::Realtime::RealtimeTranscriptionSessionCreateRequest)] diff --git a/test/openai/realtime/session_event_validation_test.rb b/test/openai/realtime/session_event_validation_test.rb new file mode 100644 index 000000000..1f96bf66b --- /dev/null +++ b/test/openai/realtime/session_event_validation_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require_relative "connection_test_support" + +class OpenAI::Test::RealtimeSessionEventValidationTest < Minitest::Test + include OpenAI::Test::RealtimeConnectionTestSupport + + def test_supported_session_events_remain_typed + event_classes.each do |type, event_class| + event = receive({type: type, session: {type: "realtime", model: "gpt-realtime"}}) + + assert_instance_of(event_class, event) + assert_instance_of(OpenAI::Realtime::RealtimeSessionCreateRequest, event.session) + assert_equal(:realtime, event.session.type) + + event = receive({type: type, session: transcription_session}, transcription: true) + + assert_instance_of(event_class, event) + assert_instance_of(OpenAI::Realtime::RealtimeTranscriptionSessionCreateRequest, event.session) + assert_equal(:transcription, event.session.type) + assert_equal("sess_synthetic", event.session.to_h.fetch(:id)) + refute_includes(event.session.to_h.keys, :client_secret) + assert_nil(event.session.include) + assert_nil(event.session.audio.input.transcription) + assert_nil(event.session.audio.input.noise_reduction) + assert_nil(event.session.audio.input.turn_detection) + end + end + + def test_known_session_events_reject_missing_or_invalid_session_shapes + invalid_sessions = [nil, [], "transcription", 7, true, {}] + invalid_sessions.concat( + [nil, 7, true, {}, [], "unsupported-private-session"].map { {type: _1} } + ) + + event_classes.each_key do |type| + assert_protocol_error(type: type) + invalid_sessions.each { |session| assert_protocol_error(type: type, session: session) } + end + end + + def test_supported_transcription_tag_still_rejects_malformed_fields + event_classes.each_key do |type| + assert_protocol_error(type: type, session: {type: "transcription", include: 7}) + end + end + + def test_unknown_outer_event_names_remain_observable + %w[future.unknown.event transcription_session.created transcription_session.updated].each do |type| + payload = {type: type, event_id: "evt_synthetic", session: {type: "unsupported-private-session"}} + event = receive(payload) + + assert_instance_of(OpenAI::Realtime::UnknownServerEvent, event) + assert_equal(type.to_sym, event.type) + assert_equal(payload, event.to_h) + assert_predicate(event, :frozen?) + assert_predicate(event.data.fetch(:session), :frozen?) + end + end + + private def event_classes + { + "session.created" => OpenAI::Realtime::SessionCreatedEvent, + "session.updated" => OpenAI::Realtime::SessionUpdatedEvent + } + end + + private def transcription_session + { + type: "transcription", + object: "realtime.transcription_session", + id: "sess_synthetic", + expires_at: 2_000_000_000, + include: nil, + audio: { + input: { + format: {type: "audio/pcm", rate: 24_000}, + transcription: nil, + noise_reduction: nil, + turn_detection: nil + } + } + } + end + + private def receive(payload, transcription: false) + socket = FakeSocket.new(JSON.generate({event_id: "evt_synthetic", **payload})) + transport = FakeTransport.new(socket) + if transcription + client.realtime.connect_transcription(transport: transport, &:receive) + else + client.realtime.connect(model: "gpt-realtime", transport: transport, &:receive) + end + + ensure + assert_predicate(socket, :closed?) + end + + private def assert_protocol_error(payload) + error = assert_raises(OpenAI::Errors::RealtimeProtocolError) { receive(payload) } + assert_equal("Invalid Realtime WebSocket event.", error.message) + refute_includes(error.message, "unsupported-private-session") + refute_includes(error.full_message, "unsupported-private-session") + refute_includes(error.cause.message, "unsupported-private-session") + end +end