From 1e3d8d041cd4910b250b7e66b313718f38821bd5 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:11:26 +0000 Subject: [PATCH] fix(api): preserve plaintext and rich audio response types Castiron-Internal-PR: https://github.com/openai/openai-ruby-internal/pull/127 Castiron-Source-SHA: c1e514cd869d015b12d40a18d4d0e4524100f8a1 Castiron-Public-Base-SHA: 33435479c2d98603b93c810183d314bc55cd1ee7 --- .castiron.stats.yml | 14 +- api_reference/openapi.transformed.yml | 206 +++++++++++++++--- examples/audio/record_and_play.rb | 2 +- lib/openai/resources/admin/organization.rb | 8 +- lib/openai/resources/audio/transcriptions.rb | 20 +- lib/openai/resources/audio/translations.rb | 12 +- lib/openai/resources/safety.rb | 8 +- rbi/openai/resources/admin/organization.rbi | 6 +- rbi/openai/resources/audio/transcriptions.rbi | 8 +- rbi/openai/resources/audio/translations.rbi | 2 +- rbi/openai/resources/safety.rbi | 6 +- sig/openai/resources/admin/organization.rbs | 4 +- sig/openai/resources/audio/transcriptions.rbs | 2 +- sig/openai/resources/audio/translations.rbs | 2 +- sig/openai/resources/safety.rbs | 4 +- test/openai/audio_response_format_test.rb | 178 +++++++++++++++ 16 files changed, 411 insertions(+), 71 deletions(-) create mode 100644 test/openai/audio_response_format_test.rb diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 516b8e5e0..327037823 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: 8e5ebfdb-d6fd-41d4-9173-a575e6ba13fc +openapi_spec_hash: 8c73dea75f8570b7ea967542dafa0b0f +openapi_transformed_spec_hash: 19d647b4e088bfa2499083e65eacd54e +config_hash: ad0fbf2fbcf7981fe7a9910664285991 +codegen_sha: 9244e44f047f4d2a30e58bf5423011673623ce81 +codegen_hash: 391329cd5b43dc65c96676993a33fa22103908dd8b8e2f3cf461a837b31df84b +public_codegen_sha: 1252578bddc8b8637004578d7a16f3c8ee7ee22e diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index 30f3e6581..f8d2c4599 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -601,11 +601,64 @@ paths: schema: type: string format: binary + audio/mpeg: + schema: + type: string + format: binary + audio/aac: + schema: + type: string + format: binary + audio/opus: + schema: + type: string + format: binary + audio/flac: + schema: + type: string + format: binary + audio/pcm: + schema: + type: string + format: binary + audio/wav: + schema: + type: string + format: binary text/event-stream: schema: $ref: '#/components/schemas/CreateSpeechResponseStreamEvent' + '400': + description: Invalid speech request, input, output format, or voice. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication failed because the API key is missing or revoked, or the client IP is not authorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + text/plain: + schema: + type: string + description: JSON-encoded error text containing an error object and the HTTP status. + '403': + description: Access to a personal API organization is blocked by the organization policy. + content: + text/plain: + schema: + type: string + description: JSON-encoded error text containing an error object and the HTTP status. '429': $ref: '#/components/responses/InferenceRateLimited' + '500': + description: The voice or speech audio could not be processed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '503': $ref: '#/components/responses/InferenceServiceUnavailable' x-oaiMeta: @@ -694,7 +747,8 @@ paths: Transcribes audio into the input language. Returns a transcription object in `json`, `diarized_json`, or `verbose_json` - format, or a stream of transcript events. + format, plain text in `text`, `srt`, or `vtt` format, or a stream of transcript + events. Supported formats depend on the model. requestBody: required: true content: @@ -710,16 +764,58 @@ paths: anyOf: - $ref: '#/components/schemas/CreateTranscriptionResponseJson' - $ref: '#/components/schemas/CreateTranscriptionResponseDiarizedJson' - x-stainless-skip: - - go - $ref: '#/components/schemas/CreateTranscriptionResponseVerboseJson' discriminator: propertyName: task + text/plain: + schema: + type: string text/event-stream: schema: $ref: '#/components/schemas/CreateTranscriptionResponseStreamEvent' + '400': + description: Invalid audio input or request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication failed because the API key is missing or revoked, or the client IP is not authorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + text/plain: + schema: + type: string + description: JSON-encoded error text containing an error object and the HTTP status. + '403': + description: Access to a personal API organization is blocked by the organization policy. + content: + text/plain: + schema: + type: string + description: JSON-encoded error text containing an error object and the HTTP status. + '413': + description: The audio request exceeds the supported size limit. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '429': $ref: '#/components/responses/InferenceRateLimited' + '500': + description: The audio could not be processed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: The upstream audio service connection failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '503': $ref: '#/components/responses/InferenceServiceUnavailable' x-oaiMeta: @@ -1271,8 +1367,52 @@ paths: - $ref: '#/components/schemas/CreateTranslationResponseVerboseJson' x-stainless-skip: - go + text/plain: + schema: + type: string + '400': + description: Invalid audio input or request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication failed because the API key is missing or revoked, or the client IP is not authorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + text/plain: + schema: + type: string + description: JSON-encoded error text containing an error object and the HTTP status. + '403': + description: Access to a personal API organization is blocked by the organization policy. + content: + text/plain: + schema: + type: string + description: JSON-encoded error text containing an error object and the HTTP status. + '413': + description: The audio request exceeds the supported size limit. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '429': $ref: '#/components/responses/InferenceRateLimited' + '500': + description: The audio could not be processed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: The upstream audio service connection failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '503': $ref: '#/components/responses/InferenceServiceUnavailable' x-oaiMeta: @@ -66730,6 +66870,36 @@ components: "type": "safety.warning_issued", "data": {"id": "C-abc123"} } + _MisalignmentErrorType: + anyOf: + - type: string + - type: string + enum: + - potentially_unintended_data_transfer + - potentially_unintended_data_access + - potentially_unintended_destructive_activity + - other + _MisalignmentSteer: + properties: + message: + type: string + description: The public continuation instruction. + type: object + required: + - message + MisalignmentErrorDetailsResource: + properties: + error_type: + $ref: '#/components/schemas/_MisalignmentErrorType' + description: An optional classification; clients must accept additional values. + detailed_explanation: + type: string + description: The public explanation for this block. + steer: + $ref: '#/components/schemas/_MisalignmentSteer' + description: An optional public continuation instruction. + type: object + required: [] ModerationInputType: type: string enum: @@ -70206,36 +70376,6 @@ components: - object - metadata - created_at - _MisalignmentErrorType: - anyOf: - - type: string - - type: string - enum: - - potentially_unintended_data_transfer - - potentially_unintended_data_access - - potentially_unintended_destructive_activity - - other - _MisalignmentSteer: - properties: - message: - type: string - description: The public continuation instruction. - type: object - required: - - message - MisalignmentErrorDetailsResource: - properties: - error_type: - $ref: '#/components/schemas/_MisalignmentErrorType' - description: An optional classification; clients must accept additional values. - detailed_explanation: - type: string - description: The public explanation for this block. - steer: - $ref: '#/components/schemas/_MisalignmentSteer' - description: An optional public continuation instruction. - type: object - required: [] ImageGenOutputTokensDetails: properties: image_tokens: diff --git a/examples/audio/record_and_play.rb b/examples/audio/record_and_play.rb index 4974cf42e..a9fc3d56b 100755 --- a/examples/audio/record_and_play.rb +++ b/examples/audio/record_and_play.rb @@ -14,6 +14,6 @@ client = OpenAI::Client.new clip = OpenAI::LocalAudio.record(duration: 5, timeout: 10) transcription = client.audio.transcriptions.create(model: TRANSCRIPTION_MODEL, file: clip) -puts(transcription.text) +puts(transcription.is_a?(StringIO) ? transcription.read : transcription.text) speech = client.audio.speech.create(model: SPEECH_MODEL, voice: VOICE, input: "Recording complete.") OpenAI::LocalAudio.play(speech, timeout: 30) diff --git a/lib/openai/resources/admin/organization.rb b/lib/openai/resources/admin/organization.rb index 9325a4af9..6f76d6133 100644 --- a/lib/openai/resources/admin/organization.rb +++ b/lib/openai/resources/admin/organization.rb @@ -4,9 +4,6 @@ module OpenAI module Resources class Admin class Organization - # @return [OpenAI::Resources::Admin::Organization::ExternalStorage] - attr_reader :external_storage - # List user actions and configuration changes within this organization. # @return [OpenAI::Resources::Admin::Organization::AuditLogs] attr_reader :audit_logs @@ -32,6 +29,9 @@ class Organization # @return [OpenAI::Resources::Admin::Organization::DataRetention] attr_reader :data_retention + # @return [OpenAI::Resources::Admin::Organization::ExternalStorage] + attr_reader :external_storage + # @return [OpenAI::Resources::Admin::Organization::SpendLimit] attr_reader :spend_limit @@ -49,7 +49,6 @@ class Organization # @param client [OpenAI::Client] def initialize(client:) @client = client - @external_storage = OpenAI::Resources::Admin::Organization::ExternalStorage.new(client: client) @audit_logs = OpenAI::Resources::Admin::Organization::AuditLogs.new(client: client) @admin_api_keys = OpenAI::Resources::Admin::Organization::AdminAPIKeys.new(client: client) @usage = OpenAI::Resources::Admin::Organization::Usage.new(client: client) @@ -58,6 +57,7 @@ def initialize(client:) @groups = OpenAI::Resources::Admin::Organization::Groups.new(client: client) @roles = OpenAI::Resources::Admin::Organization::Roles.new(client: client) @data_retention = OpenAI::Resources::Admin::Organization::DataRetention.new(client: client) + @external_storage = OpenAI::Resources::Admin::Organization::ExternalStorage.new(client: client) @spend_limit = OpenAI::Resources::Admin::Organization::SpendLimit.new(client: client) @spend_alerts = OpenAI::Resources::Admin::Organization::SpendAlerts.new(client: client) @certificates = OpenAI::Resources::Admin::Organization::Certificates.new(client: client) diff --git a/lib/openai/resources/audio/transcriptions.rb b/lib/openai/resources/audio/transcriptions.rb index 50bdb1bba..92c9258e5 100644 --- a/lib/openai/resources/audio/transcriptions.rb +++ b/lib/openai/resources/audio/transcriptions.rb @@ -11,7 +11,8 @@ class Transcriptions # Transcribes audio into the input language. # # Returns a transcription object in `json`, `diarized_json`, or `verbose_json` - # format, or a stream of transcript events. + # format, plain text in `text`, `srt`, or `vtt` format, or a stream of transcript + # events. Supported formats depend on the model. # # `String`, `StringIO`, and pathless `IO` inputs are sent with generic upload # metadata. Use `OpenAI::FilePart` when you need to override the filename or @@ -108,7 +109,7 @@ class Transcriptions # # @param request_options [OpenAI::RequestOptions, Hash{Symbol=>Object}, nil] # - # @return [OpenAI::Models::Audio::Transcription, OpenAI::Models::Audio::TranscriptionDiarized, OpenAI::Models::Audio::TranscriptionVerbose] + # @return [OpenAI::Models::Audio::Transcription, OpenAI::Models::Audio::TranscriptionDiarized, OpenAI::Models::Audio::TranscriptionVerbose, StringIO] # # @see OpenAI::Models::Audio::TranscriptionCreateParams def create(params) @@ -118,12 +119,22 @@ def create(params) raise ArgumentError.new(message) end + body = OpenAI::Internal::Transport::RequestBodyMerge.merge(parsed, options[:extra_body]) + model = case body.fetch(:response_format) { body["response_format"] } + in :verbose_json | "verbose_json" + OpenAI::UnionOf[OpenAI::Audio::TranscriptionVerbose, OpenAI::Audio::TranscriptionCreateResponse] + in :diarized_json | "diarized_json" + OpenAI::UnionOf[OpenAI::Audio::TranscriptionDiarized, OpenAI::Audio::TranscriptionCreateResponse] + else + OpenAI::Audio::TranscriptionCreateResponse + end + @client.request( method: :post, path: "audio/transcriptions", headers: {"content-type" => "multipart/form-data"}, body: parsed, - model: OpenAI::Models::Audio::TranscriptionCreateResponse, + model: model, security: {bearer_auth: true}, options: options ) @@ -135,7 +146,8 @@ def create(params) # Transcribes audio into the input language. # # Returns a transcription object in `json`, `diarized_json`, or `verbose_json` - # format, or a stream of transcript events. + # format, plain text in `text`, `srt`, or `vtt` format, or a stream of transcript + # events. Supported formats depend on the model. # # `String`, `StringIO`, and pathless `IO` inputs are sent with generic upload # metadata. Use `OpenAI::FilePart` when you need to override the filename or diff --git a/lib/openai/resources/audio/translations.rb b/lib/openai/resources/audio/translations.rb index 3f579cdd1..4b6dffe89 100644 --- a/lib/openai/resources/audio/translations.rb +++ b/lib/openai/resources/audio/translations.rb @@ -46,17 +46,25 @@ class Translations # # @param request_options [OpenAI::RequestOptions, Hash{Symbol=>Object}, nil] # - # @return [OpenAI::Models::Audio::Translation, OpenAI::Models::Audio::TranslationVerbose] + # @return [OpenAI::Models::Audio::Translation, OpenAI::Models::Audio::TranslationVerbose, StringIO] # # @see OpenAI::Models::Audio::TranslationCreateParams def create(params) parsed, options = OpenAI::Audio::TranslationCreateParams.dump_request(params) + body = OpenAI::Internal::Transport::RequestBodyMerge.merge(parsed, options[:extra_body]) + model = case body.fetch(:response_format) { body["response_format"] } + in :verbose_json | "verbose_json" + OpenAI::UnionOf[OpenAI::Audio::TranslationVerbose, OpenAI::Audio::TranslationCreateResponse] + else + OpenAI::Audio::TranslationCreateResponse + end + @client.request( method: :post, path: "audio/translations", headers: {"content-type" => "multipart/form-data"}, body: parsed, - model: OpenAI::Models::Audio::TranslationCreateResponse, + model: model, security: {bearer_auth: true}, options: options ) diff --git a/lib/openai/resources/safety.rb b/lib/openai/resources/safety.rb index 901e5d385..0086eacf0 100644 --- a/lib/openai/resources/safety.rb +++ b/lib/openai/resources/safety.rb @@ -3,19 +3,19 @@ module OpenAI module Resources class Safety - # @return [OpenAI::Resources::Safety::Alerts] - attr_reader :alerts - # @return [OpenAI::Resources::Safety::Cases] attr_reader :cases + # @return [OpenAI::Resources::Safety::Alerts] + attr_reader :alerts + # @api private # # @param client [OpenAI::Client] def initialize(client:) @client = client - @alerts = OpenAI::Resources::Safety::Alerts.new(client: client) @cases = OpenAI::Resources::Safety::Cases.new(client: client) + @alerts = OpenAI::Resources::Safety::Alerts.new(client: client) end end end diff --git a/rbi/openai/resources/admin/organization.rbi b/rbi/openai/resources/admin/organization.rbi index c5b58b4ff..d26de70ba 100644 --- a/rbi/openai/resources/admin/organization.rbi +++ b/rbi/openai/resources/admin/organization.rbi @@ -7,9 +7,6 @@ module OpenAI class Organization - sig { returns(OpenAI::Resources::Admin::Organization::ExternalStorage) } - attr_reader :external_storage - # List user actions and configuration changes within this organization. sig { returns(OpenAI::Resources::Admin::Organization::AuditLogs) } attr_reader :audit_logs @@ -35,6 +32,9 @@ module OpenAI sig { returns(OpenAI::Resources::Admin::Organization::DataRetention) } attr_reader :data_retention + sig { returns(OpenAI::Resources::Admin::Organization::ExternalStorage) } + attr_reader :external_storage + sig { returns(OpenAI::Resources::Admin::Organization::SpendLimit) } attr_reader :spend_limit diff --git a/rbi/openai/resources/audio/transcriptions.rbi b/rbi/openai/resources/audio/transcriptions.rbi index bd0ed6dc0..fe1ae4d85 100644 --- a/rbi/openai/resources/audio/transcriptions.rbi +++ b/rbi/openai/resources/audio/transcriptions.rbi @@ -14,7 +14,8 @@ module OpenAI # Transcribes audio into the input language. # # Returns a transcription object in `json`, `diarized_json`, or `verbose_json` - # format, or a stream of transcript events. + # format, plain text in `text`, `srt`, or `vtt` format, or a stream of transcript + # events. Supported formats depend on the model. sig { params( file: OpenAI::Internal::FileInput, @@ -35,7 +36,7 @@ module OpenAI stream: T.noreturn, request_options: OpenAI::RequestOptions::OrHash ) - .returns(OpenAI::Models::Audio::TranscriptionCreateResponse::Variants) + .returns(T.any(OpenAI::Models::Audio::TranscriptionCreateResponse::Variants, StringIO)) } def create( # The audio file object (not file name) to transcribe, in one of these formats: @@ -125,7 +126,8 @@ module OpenAI # Transcribes audio into the input language. # # Returns a transcription object in `json`, `diarized_json`, or `verbose_json` - # format, or a stream of transcript events. + # format, plain text in `text`, `srt`, or `vtt` format, or a stream of transcript + # events. Supported formats depend on the model. sig { params( file: OpenAI::Internal::FileInput, diff --git a/rbi/openai/resources/audio/translations.rbi b/rbi/openai/resources/audio/translations.rbi index 332cf184a..681559837 100644 --- a/rbi/openai/resources/audio/translations.rbi +++ b/rbi/openai/resources/audio/translations.rbi @@ -18,7 +18,7 @@ module OpenAI temperature: Float, request_options: OpenAI::RequestOptions::OrHash ) - .returns(OpenAI::Models::Audio::TranslationCreateResponse::Variants) + .returns(T.any(OpenAI::Models::Audio::TranslationCreateResponse::Variants, StringIO)) } def create( # The audio file object (not file name) translate, in one of these formats: flac, diff --git a/rbi/openai/resources/safety.rbi b/rbi/openai/resources/safety.rbi index b8efbfae6..fe85088bc 100644 --- a/rbi/openai/resources/safety.rbi +++ b/rbi/openai/resources/safety.rbi @@ -5,12 +5,12 @@ module OpenAI class Safety - sig { returns(OpenAI::Resources::Safety::Alerts) } - attr_reader :alerts - sig { returns(OpenAI::Resources::Safety::Cases) } attr_reader :cases + sig { returns(OpenAI::Resources::Safety::Alerts) } + attr_reader :alerts + # @api private sig { params(client: OpenAI::Client).returns(T.attached_class) } def self.new(client:) diff --git a/sig/openai/resources/admin/organization.rbs b/sig/openai/resources/admin/organization.rbs index fcedc6219..589bd92cb 100644 --- a/sig/openai/resources/admin/organization.rbs +++ b/sig/openai/resources/admin/organization.rbs @@ -2,8 +2,6 @@ module OpenAI module Resources class Admin class Organization - attr_reader external_storage: OpenAI::Resources::Admin::Organization::ExternalStorage - attr_reader audit_logs: OpenAI::Resources::Admin::Organization::AuditLogs attr_reader admin_api_keys: OpenAI::Resources::Admin::Organization::AdminAPIKeys @@ -20,6 +18,8 @@ module OpenAI attr_reader data_retention: OpenAI::Resources::Admin::Organization::DataRetention + attr_reader external_storage: OpenAI::Resources::Admin::Organization::ExternalStorage + attr_reader spend_limit: OpenAI::Resources::Admin::Organization::SpendLimit attr_reader spend_alerts: OpenAI::Resources::Admin::Organization::SpendAlerts diff --git a/sig/openai/resources/audio/transcriptions.rbs b/sig/openai/resources/audio/transcriptions.rbs index 4ced1012e..77b8a6233 100644 --- a/sig/openai/resources/audio/transcriptions.rbs +++ b/sig/openai/resources/audio/transcriptions.rbs @@ -17,7 +17,7 @@ module OpenAI ?temperature: Float, ?timestamp_granularities: ::Array[OpenAI::Models::Audio::TranscriptionCreateParams::timestamp_granularity], ?request_options: OpenAI::request_opts - ) -> OpenAI::Models::Audio::transcription_create_response + ) -> (OpenAI::Models::Audio::transcription_create_response | StringIO) def create_streaming: ( file: OpenAI::Internal::file_input, diff --git a/sig/openai/resources/audio/translations.rbs b/sig/openai/resources/audio/translations.rbs index bd3560b75..504e6d563 100644 --- a/sig/openai/resources/audio/translations.rbs +++ b/sig/openai/resources/audio/translations.rbs @@ -9,7 +9,7 @@ module OpenAI ?response_format: OpenAI::Models::Audio::TranslationCreateParams::response_format, ?temperature: Float, ?request_options: OpenAI::request_opts - ) -> OpenAI::Models::Audio::translation_create_response + ) -> (OpenAI::Models::Audio::translation_create_response | StringIO) def initialize: (client: OpenAI::Client) -> void end diff --git a/sig/openai/resources/safety.rbs b/sig/openai/resources/safety.rbs index 8569b7e90..9b9257931 100644 --- a/sig/openai/resources/safety.rbs +++ b/sig/openai/resources/safety.rbs @@ -1,10 +1,10 @@ module OpenAI module Resources class Safety - attr_reader alerts: OpenAI::Resources::Safety::Alerts - attr_reader cases: OpenAI::Resources::Safety::Cases + attr_reader alerts: OpenAI::Resources::Safety::Alerts + def initialize: (client: OpenAI::Client) -> void end end diff --git a/test/openai/audio_response_format_test.rb b/test/openai/audio_response_format_test.rb new file mode 100644 index 000000000..2d384141a --- /dev/null +++ b/test/openai/audio_response_format_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class OpenAI::Test::AudioResponseFormatTest < Minitest::Test + class Capture < OpenAI::HTTPClient + attr_reader :requests + + def initialize(body, content_type: "application/json") + super() + @body = body + @content_type = content_type + @requests = [] + end + + def execute(request) + @requests << request + OpenAI::HTTPClient::Response.new( + status: 200, + headers: {"content-type" => @content_type, "x-request-id" => "req_audio_format"}, + body: [@body] + ) + end + end + + def test_verbose_translation_uses_the_effective_format + body = JSON.generate( + duration: 1.25, + language: "english", + text: "hello", + segments: [verbose_segment], + future: {value: 1} + ) + [ + {response_format: :verbose_json}, + {response_format: "verbose_json"}, + {response_format: :json, request_options: {extra_body: {response_format: :verbose_json}}}, + {response_format: :json, request_options: {extra_body: {"response_format" => "verbose_json"}}}, + {request_options: OpenAI::RequestOptions.new(extra_body: {"response_format" => "verbose_json"})} + ].each do |params| + client, transport = client_for(body) + response = client.audio.translations.create(**audio_params, **params) + + assert_instance_of(OpenAI::Audio::TranslationVerbose, response) + assert_instance_of(OpenAI::Audio::TranscriptionSegment, response.segments.fetch(0)) + assert_equal(7, response.segments.fetch(0).id) + assert_equal({value: 1}, response[:future]) + assert_equal("req_audio_format", response._request_id) + assert_match(/name="response_format".*?\r\n\r\nverbose_json\r\n/m, transport.requests.last.body.to_a.join) + end + end + + def test_empty_transcription_segments_follow_the_effective_rich_format + body = JSON.generate(duration: 1.25, language: "en", task: "transcribe", text: "hello", segments: []) + [ + [:diarized_json, {}, OpenAI::Audio::TranscriptionDiarized], + ["diarized_json", {}, OpenAI::Audio::TranscriptionDiarized], + [:verbose_json, {extra_body: {response_format: :diarized_json}}, OpenAI::Audio::TranscriptionDiarized], + [:verbose_json, {extra_body: {"response_format" => "diarized_json"}}, OpenAI::Audio::TranscriptionDiarized], + [:diarized_json, {extra_body: {response_format: :verbose_json}}, OpenAI::Audio::TranscriptionVerbose], + [:diarized_json, {extra_body: {"response_format" => "verbose_json"}}, OpenAI::Audio::TranscriptionVerbose] + ].each do |format, options, expected| + client, = client_for(body) + response = client.audio.transcriptions.create( + **audio_params, + response_format: format, + request_options: {**options, include_raw_body: true} + ) + + assert_instance_of(expected, response) + assert_empty(response.segments) + assert_equal(body, response.last_response.body) + assert_equal("req_audio_format", response.last_response.request_id) + end + end + + def test_diarized_override_preserves_typed_segments_and_future_fields + body = JSON.generate( + duration: 1.25, + task: "transcribe", + text: "hello", + segments: [diarized_segment], + future: {value: 1} + ) + client, transport = client_for(body) + response = client.audio.transcriptions.create( + **audio_params, + request_options: OpenAI::RequestOptions.new(extra_body: {"response_format" => "diarized_json"}) + ) + + assert_instance_of(OpenAI::Audio::TranscriptionDiarized, response) + segment = response.segments.fetch(0) + assert_instance_of(OpenAI::Audio::TranscriptionDiarizedSegment, segment) + assert_equal("seg_1", segment.id) + assert_equal("A", segment.speaker) + assert_equal({value: 1}, response[:future]) + assert_match(/name="response_format".*?\r\n\r\ndiarized_json\r\n/m, transport.requests.last.body.to_a.join) + end + + def test_missing_speaker_falls_back_to_basic_with_overrides + body = JSON.generate( + duration: 1.25, + task: "transcribe", + text: "hello", + segments: [diarized_segment.except(:speaker)] + ) + [{response_format: :diarized_json}, {"response_format" => "diarized_json"}].each do |extra_body| + client, = client_for(body) + response = client.audio.transcriptions.create( + **audio_params, + response_format: :json, + request_options: {extra_body: extra_body} + ) + + assert_instance_of(OpenAI::Audio::Transcription, response) + assert_instance_of(Hash, response[:segments].fetch(0)) + assert_equal("seg_1", response[:segments].fetch(0).fetch(:id)) + end + end + + def test_translation_default_json_and_future_formats_keep_aggregate_fallback + body = JSON.generate(duration: 1.25, language: "english", text: "hello", segments: []) + [ + {}, + {response_format: :json}, + {response_format: "future_format"}, + {response_format: :verbose_json, request_options: {extra_body: {response_format: :json}}}, + {response_format: :verbose_json, request_options: {extra_body: {"response_format" => "json"}}} + ].each do |params| + client, = client_for(body) + response = client.audio.translations.create(**audio_params, **params) + + assert_instance_of(OpenAI::Audio::Translation, response) + assert_equal(1.25, response[:duration]) + end + end + + def test_plaintext_overrides_keep_string_io + ["text", "srt", "vtt"].each do |format| + client, = client_for("synthetic plaintext", content_type: "text/plain") + options = {extra_body: {"response_format" => format}} + [client.audio.transcriptions, client.audio.translations].each do |resource| + response = resource.create(**audio_params, response_format: :verbose_json, request_options: options) + + assert_instance_of(StringIO, response) + assert_equal("synthetic plaintext", response.read) + refute_respond_to(response, :last_response) + end + end + end + + private def client_for(body, content_type: "application/json") + transport = Capture.new(body, content_type: content_type) + client = OpenAI::Client.new(base_url: "http://localhost", api_key: "fake-api-key", http_client: transport) + [client, transport] + end + + private def audio_params = {file: StringIO.new("synthetic audio"), model: :"whisper-1"} + + private def verbose_segment + { + id: 7, + avg_logprob: -0.1, + compression_ratio: 1.0, + end: 1.25, + no_speech_prob: 0.0, + seek: 0, + start: 0.0, + temperature: 0.0, + text: "hello", + tokens: [1] + } + end + + private def diarized_segment + {id: "seg_1", end: 1.25, speaker: "A", start: 0.0, text: "hello", type: "transcript.text.segment"} + end +end