From 60aed096148bdc1527a1d1c0e0f3ca61842f28f4 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:08:35 +0000 Subject: [PATCH] fix(api): preserve audio formats and diarized transcription types Castiron-Internal-PR: https://github.com/openai/openai-python-internal/pull/121 Castiron-Source-SHA: f1cabf8850274ebf78d553df3bc1c5b11b7bebb4 Castiron-Public-Base-SHA: 69a2c1db6feacf32be6693809e7cab1c3b49cad7 --- .castiron.stats.yml | 14 +- api.md | 54 ++--- api_reference/openapi.transformed.yml | 206 +++++++++++++++--- scripts/utils/validate-httpx2-wheel.py | 36 ++- .../resources/admin/organization/__init__.py | 24 +- .../admin/organization/organization.py | 48 ++-- src/openai/resources/audio/transcriptions.py | 63 +++++- src/openai/resources/safety/__init__.py | 24 +- src/openai/resources/safety/safety.py | 48 ++-- tests/lib/test_audio_diarized_types.py | 64 ++++++ .../lib/test_audio_transcription_multipart.py | 148 +++++++++++++ 11 files changed, 576 insertions(+), 153 deletions(-) create mode 100644 tests/lib/test_audio_diarized_types.py create mode 100644 tests/lib/test_audio_transcription_multipart.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 944ef561cc..a65f41b2fd 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: feaf88e6-0c6a-4d57-95a5-0bcc23d7105c -openapi_spec_hash: ce912f79d7f8cb19a7a515513c214b8a -openapi_transformed_spec_hash: 352b5a7a028f990b6475b6593e381eee -config_hash: dde98d9c41cd6f4163320f92c70f83e6 -codegen_sha: 799a1f4d5f6b415088641ca4d3bbb804443ec30e -codegen_hash: 7d4da50897149f4eab2fa63ac3dbc972694c6dfeb79a01531da408010bff5dcd -public_codegen_sha: 4b121f228fc198913ea2a56404f38d8f18ca0e86 +generation_id: 811c58b1-fc79-472f-966f-d709343450e8 +openapi_spec_hash: 8c73dea75f8570b7ea967542dafa0b0f +openapi_transformed_spec_hash: 19d647b4e088bfa2499083e65eacd54e +config_hash: ad0fbf2fbcf7981fe7a9910664285991 +codegen_sha: 252064cd9cef4d4db1f9101d5929573ae9964d7c +codegen_hash: 30b56c8802b480afdea6feade2b7ea1ec31e9ebce33e9b64f0ff4da1154e2a1b +public_codegen_sha: 660cdc509c69dafb285c7c5cba7fd1781276d33c diff --git a/api.md b/api.md index dd48a632c8..fb2f91789f 100644 --- a/api.md +++ b/api.md @@ -451,29 +451,29 @@ Methods: # Safety -## Alerts +## Cases Types: ```python -from openai.types.safety import SafetyAlert +from openai.types.safety import SafetyCase ``` Methods: -- client.safety.alerts.retrieve(id) -> SafetyAlert +- client.safety.cases.retrieve(id) -> SafetyCase -## Cases +## Alerts Types: ```python -from openai.types.safety import SafetyCase +from openai.types.safety import SafetyAlert ``` Methods: -- client.safety.cases.retrieve(id) -> SafetyCase +- client.safety.alerts.retrieve(id) -> SafetyAlert # [Webhooks](src/openai/resources/webhooks/api.md) @@ -1299,27 +1299,6 @@ Methods: ## Organization -### ExternalStorage - -Types: - -```python -from openai.types.admin.organization import ( - AwsExternalStorageProvider, - AzureExternalStorageProvider, - ExternalStorageConfiguration, - ExternalStorageDeleted, -) -``` - -Methods: - -- client.admin.organization.external_storage.create(\*\*params) -> ExternalStorageConfiguration -- client.admin.organization.external_storage.retrieve(external_storage_id) -> ExternalStorageConfiguration -- client.admin.organization.external_storage.list(\*\*params) -> SyncCursorPage[ExternalStorageConfiguration] -- client.admin.organization.external_storage.delete(external_storage_id) -> ExternalStorageDeleted -- client.admin.organization.external_storage.validate(external_storage_id) -> ExternalStorageConfiguration - ### AuditLogs Types: @@ -1521,6 +1500,27 @@ Methods: - client.admin.organization.data_retention.retrieve() -> OrganizationDataRetention - client.admin.organization.data_retention.update(\*\*params) -> OrganizationDataRetention +### ExternalStorage + +Types: + +```python +from openai.types.admin.organization import ( + AwsExternalStorageProvider, + AzureExternalStorageProvider, + ExternalStorageConfiguration, + ExternalStorageDeleted, +) +``` + +Methods: + +- client.admin.organization.external_storage.create(\*\*params) -> ExternalStorageConfiguration +- client.admin.organization.external_storage.retrieve(external_storage_id) -> ExternalStorageConfiguration +- client.admin.organization.external_storage.list(\*\*params) -> SyncCursorPage[ExternalStorageConfiguration] +- client.admin.organization.external_storage.delete(external_storage_id) -> ExternalStorageDeleted +- client.admin.organization.external_storage.validate(external_storage_id) -> ExternalStorageConfiguration + ### SpendLimit Types: diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index 30f3e65810..f8d2c45991 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/scripts/utils/validate-httpx2-wheel.py b/scripts/utils/validate-httpx2-wheel.py index 2d3e0bba5d..91ef3aa953 100644 --- a/scripts/utils/validate-httpx2-wheel.py +++ b/scripts/utils/validate-httpx2-wheel.py @@ -14,6 +14,7 @@ HTTPX2_TEST = ROOT / "tests/test_httpx2.py" LEGACY_TEST = ROOT / "tests/test_httpx_compat.py" TLS_TEST = ROOT / "tests/test_tls_hostname.py" +MULTIPART_TEST = ROOT / "tests/lib/test_audio_transcription_multipart.py" def venv_python(environment_path: Path) -> Path: @@ -76,17 +77,42 @@ def run_case( check=True, ) + test_environment = environment.copy() + for name in list(test_environment): + if name.startswith(("OPENAI_", "AZURE_OPENAI_")) or name in ( + "ALL_PROXY", + "HTTPS_PROXY", + "HTTP_PROXY", + "all_proxy", + "https_proxy", + "http_proxy", + ): + test_environment.pop(name) + subprocess.run( + [ + str(python), + "-c", + "".join( + [ + "from pathlib import Path; import openai, sysconfig; ", + "origin = Path(openai.__file__).resolve(); ", + "assert origin.is_relative_to(Path(sysconfig.get_path('purelib')).resolve()), origin", + ] + ), + ], + cwd=directory, + env=test_environment, + check=True, + ) + if not legacy: subprocess.run( [str(python), "-c", "import importlib.util; assert importlib.util.find_spec('httpx') is None"], cwd=directory, - env=environment, + env=test_environment, check=True, ) - test_environment = environment.copy() - for name in ("ALL_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "all_proxy", "https_proxy", "http_proxy"): - test_environment.pop(name, None) if legacy: test_environment["OPENAI_TEST_LEGACY_HTTPX"] = "1" subprocess.run( @@ -105,7 +131,7 @@ def main() -> None: validate_metadata(wheel) common = ["pytest==9.0.3", "pytest-asyncio==1.4.0", "pygments==2.20.0"] - run_case(wheel, extra=None, tests=[BASE_TEST, HTTPX2_TEST], dependencies=common) + run_case(wheel, extra=None, tests=[BASE_TEST, HTTPX2_TEST, MULTIPART_TEST], dependencies=common) run_case(wheel, extra="aiohttp", tests=[BASE_TEST], dependencies=common) run_case( wheel, diff --git a/src/openai/resources/admin/organization/__init__.py b/src/openai/resources/admin/organization/__init__.py index e402b55d71..d1036e4846 100644 --- a/src/openai/resources/admin/organization/__init__.py +++ b/src/openai/resources/admin/organization/__init__.py @@ -118,12 +118,6 @@ else: _EXPORTS = { - "ExternalStorage": (".external_storage", "ExternalStorage"), - "AsyncExternalStorage": (".external_storage", "AsyncExternalStorage"), - "ExternalStorageWithRawResponse": (".external_storage", "ExternalStorageWithRawResponse"), - "AsyncExternalStorageWithRawResponse": (".external_storage", "AsyncExternalStorageWithRawResponse"), - "ExternalStorageWithStreamingResponse": (".external_storage", "ExternalStorageWithStreamingResponse"), - "AsyncExternalStorageWithStreamingResponse": (".external_storage", "AsyncExternalStorageWithStreamingResponse"), "AuditLogs": (".audit_logs", "AuditLogs"), "AsyncAuditLogs": (".audit_logs", "AsyncAuditLogs"), "AuditLogsWithRawResponse": (".audit_logs", "AuditLogsWithRawResponse"), @@ -172,6 +166,12 @@ "AsyncDataRetentionWithRawResponse": (".data_retention", "AsyncDataRetentionWithRawResponse"), "DataRetentionWithStreamingResponse": (".data_retention", "DataRetentionWithStreamingResponse"), "AsyncDataRetentionWithStreamingResponse": (".data_retention", "AsyncDataRetentionWithStreamingResponse"), + "ExternalStorage": (".external_storage", "ExternalStorage"), + "AsyncExternalStorage": (".external_storage", "AsyncExternalStorage"), + "ExternalStorageWithRawResponse": (".external_storage", "ExternalStorageWithRawResponse"), + "AsyncExternalStorageWithRawResponse": (".external_storage", "AsyncExternalStorageWithRawResponse"), + "ExternalStorageWithStreamingResponse": (".external_storage", "ExternalStorageWithStreamingResponse"), + "AsyncExternalStorageWithStreamingResponse": (".external_storage", "AsyncExternalStorageWithStreamingResponse"), "SpendLimit": (".spend_limit", "SpendLimit"), "AsyncSpendLimit": (".spend_limit", "AsyncSpendLimit"), "SpendLimitWithRawResponse": (".spend_limit", "SpendLimitWithRawResponse"), @@ -238,12 +238,6 @@ def __dir__() -> list[str]: __all__ = [ - "ExternalStorage", - "AsyncExternalStorage", - "ExternalStorageWithRawResponse", - "AsyncExternalStorageWithRawResponse", - "ExternalStorageWithStreamingResponse", - "AsyncExternalStorageWithStreamingResponse", "AuditLogs", "AsyncAuditLogs", "AuditLogsWithRawResponse", @@ -292,6 +286,12 @@ def __dir__() -> list[str]: "AsyncDataRetentionWithRawResponse", "DataRetentionWithStreamingResponse", "AsyncDataRetentionWithStreamingResponse", + "ExternalStorage", + "AsyncExternalStorage", + "ExternalStorageWithRawResponse", + "AsyncExternalStorageWithRawResponse", + "ExternalStorageWithStreamingResponse", + "AsyncExternalStorageWithStreamingResponse", "SpendLimit", "AsyncSpendLimit", "SpendLimitWithRawResponse", diff --git a/src/openai/resources/admin/organization/organization.py b/src/openai/resources/admin/organization/organization.py index 0f2bf69fb3..f9d95942bd 100644 --- a/src/openai/resources/admin/organization/organization.py +++ b/src/openai/resources/admin/organization/organization.py @@ -113,10 +113,6 @@ class Organization(SyncAPIResource): - @cached_property - def external_storage(self) -> ExternalStorage: - return ExternalStorage(self._client) - @cached_property def audit_logs(self) -> AuditLogs: """List user actions and configuration changes within this organization.""" @@ -150,6 +146,10 @@ def roles(self) -> Roles: def data_retention(self) -> DataRetention: return DataRetention(self._client) + @cached_property + def external_storage(self) -> ExternalStorage: + return ExternalStorage(self._client) + @cached_property def spend_limit(self) -> SpendLimit: return SpendLimit(self._client) @@ -187,10 +187,6 @@ def with_streaming_response(self) -> OrganizationWithStreamingResponse: class AsyncOrganization(AsyncAPIResource): - @cached_property - def external_storage(self) -> AsyncExternalStorage: - return AsyncExternalStorage(self._client) - @cached_property def audit_logs(self) -> AsyncAuditLogs: """List user actions and configuration changes within this organization.""" @@ -224,6 +220,10 @@ def roles(self) -> AsyncRoles: def data_retention(self) -> AsyncDataRetention: return AsyncDataRetention(self._client) + @cached_property + def external_storage(self) -> AsyncExternalStorage: + return AsyncExternalStorage(self._client) + @cached_property def spend_limit(self) -> AsyncSpendLimit: return AsyncSpendLimit(self._client) @@ -264,10 +264,6 @@ class OrganizationWithRawResponse: def __init__(self, organization: Organization) -> None: self._organization = organization - @cached_property - def external_storage(self) -> ExternalStorageWithRawResponse: - return ExternalStorageWithRawResponse(self._organization.external_storage) - @cached_property def audit_logs(self) -> AuditLogsWithRawResponse: """List user actions and configuration changes within this organization.""" @@ -301,6 +297,10 @@ def roles(self) -> RolesWithRawResponse: def data_retention(self) -> DataRetentionWithRawResponse: return DataRetentionWithRawResponse(self._organization.data_retention) + @cached_property + def external_storage(self) -> ExternalStorageWithRawResponse: + return ExternalStorageWithRawResponse(self._organization.external_storage) + @cached_property def spend_limit(self) -> SpendLimitWithRawResponse: return SpendLimitWithRawResponse(self._organization.spend_limit) @@ -322,10 +322,6 @@ class AsyncOrganizationWithRawResponse: def __init__(self, organization: AsyncOrganization) -> None: self._organization = organization - @cached_property - def external_storage(self) -> AsyncExternalStorageWithRawResponse: - return AsyncExternalStorageWithRawResponse(self._organization.external_storage) - @cached_property def audit_logs(self) -> AsyncAuditLogsWithRawResponse: """List user actions and configuration changes within this organization.""" @@ -359,6 +355,10 @@ def roles(self) -> AsyncRolesWithRawResponse: def data_retention(self) -> AsyncDataRetentionWithRawResponse: return AsyncDataRetentionWithRawResponse(self._organization.data_retention) + @cached_property + def external_storage(self) -> AsyncExternalStorageWithRawResponse: + return AsyncExternalStorageWithRawResponse(self._organization.external_storage) + @cached_property def spend_limit(self) -> AsyncSpendLimitWithRawResponse: return AsyncSpendLimitWithRawResponse(self._organization.spend_limit) @@ -380,10 +380,6 @@ class OrganizationWithStreamingResponse: def __init__(self, organization: Organization) -> None: self._organization = organization - @cached_property - def external_storage(self) -> ExternalStorageWithStreamingResponse: - return ExternalStorageWithStreamingResponse(self._organization.external_storage) - @cached_property def audit_logs(self) -> AuditLogsWithStreamingResponse: """List user actions and configuration changes within this organization.""" @@ -417,6 +413,10 @@ def roles(self) -> RolesWithStreamingResponse: def data_retention(self) -> DataRetentionWithStreamingResponse: return DataRetentionWithStreamingResponse(self._organization.data_retention) + @cached_property + def external_storage(self) -> ExternalStorageWithStreamingResponse: + return ExternalStorageWithStreamingResponse(self._organization.external_storage) + @cached_property def spend_limit(self) -> SpendLimitWithStreamingResponse: return SpendLimitWithStreamingResponse(self._organization.spend_limit) @@ -438,10 +438,6 @@ class AsyncOrganizationWithStreamingResponse: def __init__(self, organization: AsyncOrganization) -> None: self._organization = organization - @cached_property - def external_storage(self) -> AsyncExternalStorageWithStreamingResponse: - return AsyncExternalStorageWithStreamingResponse(self._organization.external_storage) - @cached_property def audit_logs(self) -> AsyncAuditLogsWithStreamingResponse: """List user actions and configuration changes within this organization.""" @@ -475,6 +471,10 @@ def roles(self) -> AsyncRolesWithStreamingResponse: def data_retention(self) -> AsyncDataRetentionWithStreamingResponse: return AsyncDataRetentionWithStreamingResponse(self._organization.data_retention) + @cached_property + def external_storage(self) -> AsyncExternalStorageWithStreamingResponse: + return AsyncExternalStorageWithStreamingResponse(self._organization.external_storage) + @cached_property def spend_limit(self) -> AsyncSpendLimitWithStreamingResponse: return AsyncSpendLimitWithStreamingResponse(self._organization.spend_limit) diff --git a/src/openai/resources/audio/transcriptions.py b/src/openai/resources/audio/transcriptions.py index f5ec09441d..06d7f0667c 100644 --- a/src/openai/resources/audio/transcriptions.py +++ b/src/openai/resources/audio/transcriptions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from typing import List, Union, Mapping, Optional, cast from typing_extensions import Literal, overload @@ -21,7 +22,7 @@ omit, not_given, ) -from ..._utils import extract_files, required_args, maybe_transform, async_maybe_transform +from ..._utils import is_mapping, extract_files, required_args, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -92,7 +93,8 @@ def create( 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. Args: file: The audio file object (not file name) to transcribe, in one of these formats: @@ -260,7 +262,8 @@ def create( 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. Args: file: The audio file object (not file name) to transcribe, in one of these formats: @@ -381,7 +384,8 @@ def create( 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. Args: file: The audio file object (not file name) to transcribe, in one of these formats: @@ -498,7 +502,7 @@ def create( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> str | Transcription | TranscriptionDiarized | TranscriptionVerbose | Stream[TranscriptionStreamEvent]: - body = deepcopy_with_paths( + body: dict[str, object] = deepcopy_with_paths( { "file": file, "model": model, @@ -517,6 +521,7 @@ def create( }, [["file"]], ) + extra_body = _serialize_chunking_strategy(body, extra_body) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. @@ -595,7 +600,8 @@ async def create( 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. Args: file: The audio file object (not file name) to transcribe, in one of these formats: @@ -728,6 +734,30 @@ async def create( timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> str: ... + @overload + async def create( + self, + *, + file: FileTypes, + model: Union[str, AudioModel], + chunking_strategy: Optional[transcription_create_params.ChunkingStrategy] | Omit = omit, + response_format: Literal["diarized_json"], + stream: Optional[Literal[False]] | Omit = omit, + keywords: SequenceNotStr[str] | Omit = omit, + known_speaker_names: SequenceNotStr[str] | Omit = omit, + known_speaker_references: SequenceNotStr[str] | Omit = omit, + language: str | Omit = omit, + languages: SequenceNotStr[str] | Omit = omit, + temperature: float | Omit = omit, + timestamp_granularities: List[Literal["word", "segment"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx2.Timeout | None | NotGiven = not_given, + ) -> TranscriptionDiarized: ... + @overload async def create( self, @@ -757,7 +787,8 @@ async def create( 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. Args: file: The audio file object (not file name) to transcribe, in one of these formats: @@ -878,7 +909,8 @@ async def create( 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. Args: file: The audio file object (not file name) to transcribe, in one of these formats: @@ -995,7 +1027,7 @@ async def create( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> Transcription | TranscriptionVerbose | TranscriptionDiarized | str | AsyncStream[TranscriptionStreamEvent]: - body = deepcopy_with_paths( + body: dict[str, object] = deepcopy_with_paths( { "file": file, "model": model, @@ -1014,6 +1046,7 @@ async def create( }, [["file"]], ) + extra_body = _serialize_chunking_strategy(body, extra_body) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. @@ -1041,6 +1074,18 @@ async def create( ) +def _serialize_chunking_strategy(body: dict[str, object], extra_body: Body | None) -> Body | None: + # The service expects one JSON-text field. Normalize only the winning value. + if is_mapping(extra_body) and "chunking_strategy" in extra_body: + body.pop("chunking_strategy", None) + strategy = extra_body["chunking_strategy"] + if isinstance(strategy, dict): + return {**extra_body, "chunking_strategy": json.dumps(strategy)} + elif isinstance(body.get("chunking_strategy"), dict): + body["chunking_strategy"] = json.dumps(body["chunking_strategy"]) + return extra_body + + class TranscriptionsWithRawResponse: def __init__(self, transcriptions: Transcriptions) -> None: self._transcriptions = transcriptions diff --git a/src/openai/resources/safety/__init__.py b/src/openai/resources/safety/__init__.py index 57d50e6bc3..c25334f1b4 100644 --- a/src/openai/resources/safety/__init__.py +++ b/src/openai/resources/safety/__init__.py @@ -30,18 +30,18 @@ else: _EXPORTS = { - "Alerts": (".alerts", "Alerts"), - "AsyncAlerts": (".alerts", "AsyncAlerts"), - "AlertsWithRawResponse": (".alerts", "AlertsWithRawResponse"), - "AsyncAlertsWithRawResponse": (".alerts", "AsyncAlertsWithRawResponse"), - "AlertsWithStreamingResponse": (".alerts", "AlertsWithStreamingResponse"), - "AsyncAlertsWithStreamingResponse": (".alerts", "AsyncAlertsWithStreamingResponse"), "Cases": (".cases", "Cases"), "AsyncCases": (".cases", "AsyncCases"), "CasesWithRawResponse": (".cases", "CasesWithRawResponse"), "AsyncCasesWithRawResponse": (".cases", "AsyncCasesWithRawResponse"), "CasesWithStreamingResponse": (".cases", "CasesWithStreamingResponse"), "AsyncCasesWithStreamingResponse": (".cases", "AsyncCasesWithStreamingResponse"), + "Alerts": (".alerts", "Alerts"), + "AsyncAlerts": (".alerts", "AsyncAlerts"), + "AlertsWithRawResponse": (".alerts", "AlertsWithRawResponse"), + "AsyncAlertsWithRawResponse": (".alerts", "AsyncAlertsWithRawResponse"), + "AlertsWithStreamingResponse": (".alerts", "AlertsWithStreamingResponse"), + "AsyncAlertsWithStreamingResponse": (".alerts", "AsyncAlertsWithStreamingResponse"), "Safety": (".safety", "Safety"), "AsyncSafety": (".safety", "AsyncSafety"), "SafetyWithRawResponse": (".safety", "SafetyWithRawResponse"), @@ -73,18 +73,18 @@ def __dir__() -> list[str]: __all__ = [ - "Alerts", - "AsyncAlerts", - "AlertsWithRawResponse", - "AsyncAlertsWithRawResponse", - "AlertsWithStreamingResponse", - "AsyncAlertsWithStreamingResponse", "Cases", "AsyncCases", "CasesWithRawResponse", "AsyncCasesWithRawResponse", "CasesWithStreamingResponse", "AsyncCasesWithStreamingResponse", + "Alerts", + "AsyncAlerts", + "AlertsWithRawResponse", + "AsyncAlertsWithRawResponse", + "AlertsWithStreamingResponse", + "AsyncAlertsWithStreamingResponse", "Safety", "AsyncSafety", "SafetyWithRawResponse", diff --git a/src/openai/resources/safety/safety.py b/src/openai/resources/safety/safety.py index 4d80f73eda..ae72977ba4 100644 --- a/src/openai/resources/safety/safety.py +++ b/src/openai/resources/safety/safety.py @@ -25,14 +25,14 @@ class Safety(SyncAPIResource): - @cached_property - def alerts(self) -> Alerts: - return Alerts(self._client) - @cached_property def cases(self) -> Cases: return Cases(self._client) + @cached_property + def alerts(self) -> Alerts: + return Alerts(self._client) + @cached_property def with_raw_response(self) -> SafetyWithRawResponse: """ @@ -54,14 +54,14 @@ def with_streaming_response(self) -> SafetyWithStreamingResponse: class AsyncSafety(AsyncAPIResource): - @cached_property - def alerts(self) -> AsyncAlerts: - return AsyncAlerts(self._client) - @cached_property def cases(self) -> AsyncCases: return AsyncCases(self._client) + @cached_property + def alerts(self) -> AsyncAlerts: + return AsyncAlerts(self._client) + @cached_property def with_raw_response(self) -> AsyncSafetyWithRawResponse: """ @@ -86,49 +86,49 @@ class SafetyWithRawResponse: def __init__(self, safety: Safety) -> None: self._safety = safety - @cached_property - def alerts(self) -> AlertsWithRawResponse: - return AlertsWithRawResponse(self._safety.alerts) - @cached_property def cases(self) -> CasesWithRawResponse: return CasesWithRawResponse(self._safety.cases) + @cached_property + def alerts(self) -> AlertsWithRawResponse: + return AlertsWithRawResponse(self._safety.alerts) + class AsyncSafetyWithRawResponse: def __init__(self, safety: AsyncSafety) -> None: self._safety = safety - @cached_property - def alerts(self) -> AsyncAlertsWithRawResponse: - return AsyncAlertsWithRawResponse(self._safety.alerts) - @cached_property def cases(self) -> AsyncCasesWithRawResponse: return AsyncCasesWithRawResponse(self._safety.cases) + @cached_property + def alerts(self) -> AsyncAlertsWithRawResponse: + return AsyncAlertsWithRawResponse(self._safety.alerts) + class SafetyWithStreamingResponse: def __init__(self, safety: Safety) -> None: self._safety = safety - @cached_property - def alerts(self) -> AlertsWithStreamingResponse: - return AlertsWithStreamingResponse(self._safety.alerts) - @cached_property def cases(self) -> CasesWithStreamingResponse: return CasesWithStreamingResponse(self._safety.cases) + @cached_property + def alerts(self) -> AlertsWithStreamingResponse: + return AlertsWithStreamingResponse(self._safety.alerts) + class AsyncSafetyWithStreamingResponse: def __init__(self, safety: AsyncSafety) -> None: self._safety = safety - @cached_property - def alerts(self) -> AsyncAlertsWithStreamingResponse: - return AsyncAlertsWithStreamingResponse(self._safety.alerts) - @cached_property def cases(self) -> AsyncCasesWithStreamingResponse: return AsyncCasesWithStreamingResponse(self._safety.cases) + + @cached_property + def alerts(self) -> AsyncAlertsWithStreamingResponse: + return AsyncAlertsWithStreamingResponse(self._safety.alerts) diff --git a/tests/lib/test_audio_diarized_types.py b/tests/lib/test_audio_diarized_types.py new file mode 100644 index 0000000000..d423b8799e --- /dev/null +++ b/tests/lib/test_audio_diarized_types.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing_extensions import assert_type + +import httpx2 +import pytest + +from openai import AsyncOpenAI, omit +from tests.respx2 import MockRouter +from openai.types.audio import TranscriptionDiarized, TranscriptionDiarizedSegment + + +@pytest.mark.respx2() +async def test_async_diarized_nonstreaming_types(async_client: AsyncOpenAI, respx2_mock: MockRouter) -> None: + route = respx2_mock.post(f"{str(async_client.base_url).rstrip('/')}/audio/transcriptions").mock( + return_value=httpx2.Response( + 200, + json={ + "text": "hello", + "duration": 1.0, + "task": "transcribe", + "segments": [ + { + "id": "segment-1", + "type": "transcript.text.segment", + "start": 0.0, + "end": 1.0, + "text": "hello", + "speaker": "A", + } + ], + }, + ) + ) + default = assert_type( + await async_client.audio.transcriptions.create( + file=b"audio", model="gpt-4o-transcribe-diarize", response_format="diarized_json" + ), + TranscriptionDiarized, + ) + false = assert_type( + await async_client.audio.transcriptions.create( + file=b"audio", model="gpt-4o-transcribe-diarize", response_format="diarized_json", stream=False + ), + TranscriptionDiarized, + ) + null = assert_type( + await async_client.audio.transcriptions.create( + file=b"audio", model="gpt-4o-transcribe-diarize", response_format="diarized_json", stream=None + ), + TranscriptionDiarized, + ) + omitted = assert_type( + await async_client.audio.transcriptions.create( + file=b"audio", model="gpt-4o-transcribe-diarize", response_format="diarized_json", stream=omit + ), + TranscriptionDiarized, + ) + for response in (default, false, null, omitted): + assert isinstance(response, TranscriptionDiarized) + assert_type(response.segments[0], TranscriptionDiarizedSegment) + assert response.segments[0].id == "segment-1" + assert response.segments[0].speaker == "A" + assert route.call_count == 4 diff --git a/tests/lib/test_audio_transcription_multipart.py b/tests/lib/test_audio_transcription_multipart.py new file mode 100644 index 0000000000..155dd83358 --- /dev/null +++ b/tests/lib/test_audio_transcription_multipart.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import io +import json +from copy import deepcopy +from email import policy +from typing import Any, cast +from email.parser import BytesParser + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI, omit +from openai.types.audio import Transcription + +FILE_BYTES = b"RIFF\x00synthetic audio\xff\r\nWAVE" +VAD = {"type": "server_vad", "threshold": 0, "prefix_padding_ms": 0, "silence_duration_ms": 0} +UNSERIALIZABLE = object() +CASES = [ + pytest.param({"chunking_strategy": VAD}, VAD, id="vad-zero"), + pytest.param({"chunking_strategy": {"type": "server_vad"}}, {"type": "server_vad"}, id="vad-defaults"), + pytest.param({"chunking_strategy": {**VAD, "future": True}}, {**VAD, "future": True}, id="extra-object-field"), + pytest.param( + {"chunking_strategy": "auto", "extra_body": {"chunking_strategy": VAD, "extra_flag": False}}, + VAD, + id="override-vad", + ), + pytest.param( + {"chunking_strategy": {"unserializable": UNSERIALIZABLE}, "extra_body": {"chunking_strategy": VAD}}, + VAD, + id="override-losing-object", + ), + pytest.param( + {"chunking_strategy": {"unserializable": UNSERIALIZABLE}, "extra_body": {"chunking_strategy": "auto"}}, + "auto", + id="override-auto", + ), + pytest.param({"chunking_strategy": VAD, "extra_body": {"chunking_strategy": None}}, None, id="override-null"), + pytest.param({"chunking_strategy": VAD, "extra_body": {"chunking_strategy": omit}}, None, id="override-omit"), + pytest.param({"chunking_strategy": "auto"}, "auto", id="auto"), + pytest.param({}, None, id="omitted"), + pytest.param({"chunking_strategy": None}, None, id="null"), + pytest.param({"chunking_strategy": omit}, None, id="omit"), + pytest.param({"chunking_strategy": {}}, {}, id="empty-object"), + pytest.param({"extra_body": {"chunking_strategy": 0}}, "0", id="scalar-zero"), + pytest.param({"extra_body": {"chunking_strategy": False}}, "false", id="scalar-false"), + pytest.param({"extra_body": {"chunking_strategy": json.dumps(VAD)}}, VAD, id="preencoded-json"), + pytest.param({"chunking_strategy": VAD, "extra_body": {"extra_flag": False}}, VAD, id="unrelated-override"), +] + + +@pytest.fixture(autouse=True) +def clean_sdk_environment(monkeypatch: pytest.MonkeyPatch) -> None: + import os + + for key in os.environ: + if key.startswith(("OPENAI_", "AZURE_OPENAI_")): + monkeypatch.delenv(key) + + +def assert_multipart(request: httpx2.Request, expected: object, *, has_extra_flag: bool) -> None: + assert request.url.path == "/v1/audio/transcriptions" + assert request.headers["x-request-marker"] == "request" + assert request.url.params["probe"] == "request" + content_type = request.headers["content-type"] + assert content_type.startswith("multipart/form-data; boundary=") + message = BytesParser(policy=policy.default).parsebytes( + f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + request.read() + ) + fields: dict[str, list[bytes]] = {} + files: list[tuple[str, str | None, str, bytes]] = [] + for part in message.iter_parts(): + name = part.get_param("name", header="content-disposition") + assert isinstance(name, str) + payload = part.get_payload(decode=True) + assert isinstance(payload, bytes) + if part.get_filename() is not None: + files.append((name, part.get_filename(), part.get_content_type(), payload)) + else: + fields.setdefault(name, []).append(payload) + + assert files == [("file", "test.wav", "audio/wav", FILE_BYTES)] + assert fields["model"] == [b"gpt-4o-transcribe"] + assert fields["temperature"] == [b"0"] + assert fields["stream"] == [b"false"] + assert fields["include[]"] == [b"logprobs"] + assert fields["timestamp_granularities[]"] == [b"word", b"segment"] + assert fields["known_speaker_names[]"] == [b"Alice", b"Bob"] + assert fields["known_speaker_references[]"] == [b"data:audio/wav;base64,AA==", b"data:audio/wav;base64,AQ=="] + assert fields.get("extra_flag", []) == ([b"false"] if has_extra_flag else []) + assert not any(name.startswith("chunking_strategy[") for name in fields) + values = fields.get("chunking_strategy", []) + if isinstance(expected, dict): + assert len(values) == 1 + assert json.loads(values[0]) == expected + else: + assert values == ([] if expected is None else [str(expected).encode()]) + + +@pytest.mark.parametrize("changes,expected", CASES) +@pytest.mark.parametrize("async_client", [False, True], ids=["sync", "async"]) +async def test_chunking_strategy_wire(changes: dict[str, Any], expected: object, async_client: bool) -> None: + changes = deepcopy(changes, {id(UNSERIALIZABLE): UNSERIALIZABLE, id(omit): omit}) + before = deepcopy(changes, {id(UNSERIALIZABLE): UNSERIALIZABLE, id(omit): omit}) + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + request.read() + requests.append(request) + return httpx2.Response(200, json={"text": "synthetic"}) + + common: dict[str, Any] = { + "api_key": "sk-synthetic", + "base_url": "https://multipart.invalid/v1", + "max_retries": 0, + "default_headers": {"x-request-marker": "client"}, + "default_query": {"probe": "client"}, + } + with io.BytesIO(FILE_BYTES) as file: + kwargs: dict[str, Any] = { + "file": ("test.wav", file, "audio/wav"), + "model": "gpt-4o-transcribe", + "temperature": 0, + "stream": False, + "include": ["logprobs"], + "timestamp_granularities": ["word", "segment"], + "known_speaker_names": ["Alice", "Bob"], + "known_speaker_references": ["data:audio/wav;base64,AA==", "data:audio/wav;base64,AQ=="], + "extra_headers": {"x-request-marker": "request"}, + "extra_query": {"probe": "request"}, + **changes, + } + if async_client: + async with AsyncOpenAI( + **common, http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False) + ) as client: + response = cast(Transcription, await client.audio.transcriptions.create(**kwargs)) + assert response.text == "synthetic" + else: + with OpenAI( + **common, http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False) + ) as sync_client: + response = cast(Transcription, sync_client.audio.transcriptions.create(**kwargs)) + assert response.text == "synthetic" + assert not file.closed + assert changes == before + assert len(requests) == 1 + assert_multipart(requests[0], expected, has_extra_flag="extra_flag" in changes.get("extra_body", {}))