From bc4f8efdbc1f8fa6b78935e205780e259d83576d Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Tue, 25 Aug 2026 16:05:26 +0000 Subject: [PATCH 1/2] feat(api): add project residency configuration and cost quantity units (#3726) ## Summary Adds optional `residency` support to synchronous and asynchronous Admin Organization project creation, with typed residency configuration values, and exposes `residency` on returned Project models. Marks `geography` as deprecated for project creation while retaining it for backward compatibility. Adds optional `quantity_unit` fields to organization usage cost result models across supported usage response types. Project responses and project-creation inputs now reuse a shared residency model. Usage cost responses reuse a shared quantity-unit model, with unknown unit values and optional/null fields preserved. Co-authored-by: apcha-oai <228803254+apcha-oai@users.noreply.github.com> --- .castiron.stats.yml | 14 ++--- api.md | 3 +- api_reference/openapi.transformed.yml | 56 +++++++++++++++++-- .../admin/organization/projects/projects.py | 32 ++++++++++- .../types/admin/organization/__init__.py | 2 + .../admin/organization/cost_quantity_unit.py | 20 +++++++ .../types/admin/organization/project.py | 4 ++ .../organization/project_create_params.py | 12 ++++ .../admin/organization/project_residency.py | 20 +++++++ .../organization/project_update_params.py | 5 +- .../usage_audio_speeches_response.py | 7 +++ .../usage_audio_transcriptions_response.py | 7 +++ ...sage_code_interpreter_sessions_response.py | 7 +++ .../usage_completions_response.py | 7 +++ .../organization/usage_costs_response.py | 7 +++ .../organization/usage_embeddings_response.py | 7 +++ .../usage_file_search_calls_response.py | 7 +++ .../organization/usage_images_response.py | 7 +++ .../usage_moderations_response.py | 7 +++ .../usage_vector_stores_response.py | 7 +++ .../usage_web_search_calls_response.py | 7 +++ .../admin/organization/test_projects.py | 2 + 22 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 src/openai/types/admin/organization/cost_quantity_unit.py create mode 100644 src/openai/types/admin/organization/project_residency.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index f04d6d8ac0..1e91cdbfc1 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: 64f4392f-6816-485e-8210-b9a1c2ec7def -openapi_spec_hash: 92700e1a33a4f6174a01b46648c8186a -openapi_transformed_spec_hash: e37bbe0f04caa6093f1cb5d65d23ad03 -config_hash: beab4f058e80bab30085bb6c8467b65f -codegen_sha: 23e8f705c06ddc2c2f80e8444b294793771e4f47 -codegen_hash: 717da37491ba2e308f370daeb4883c974f792a4385c5cd411d46904796787997 -public_codegen_sha: ffde304f4cfdd6d0ea6cd5ee855cc48efc89fc5d +generation_id: 743ac88c-c04a-4f93-b104-39ff2b29f860 +openapi_spec_hash: b2c62b342b04685226037c980f2b5a7a +openapi_transformed_spec_hash: ba44bf31326f01a1d886b7510dd9da8a +config_hash: 1e11a5becf7bc1c2e9e07b8654872dc4 +codegen_sha: a0e85ddf73948ad851577815ee7b872fac9ee98f +codegen_hash: c19628547a78939134912b93344668c37d65464bc08e3b3b9d22ef9a05fb264e +public_codegen_sha: b8a8c67c1d0d8f12ada756a7d7f90bfac10330b0 diff --git a/api.md b/api.md index cf6ae87931..d9efdbcc79 100644 --- a/api.md +++ b/api.md @@ -1010,6 +1010,7 @@ Types: ```python from openai.types.admin.organization import ( + CostQuantityUnit, UsageAudioSpeechesResponse, UsageAudioTranscriptionsResponse, UsageCodeInterpreterSessionsResponse, @@ -1232,7 +1233,7 @@ Methods: Types: ```python -from openai.types.admin.organization import Project +from openai.types.admin.organization import Project, ProjectResidency ``` Methods: diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index c12baba61f..164fe8c284 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -9552,7 +9552,8 @@ paths: "line_item": null, "project_id": null, "api_key_id": null, - "quantity": null + "quantity": null, + "quantity_unit": null } ] } @@ -31011,6 +31012,21 @@ components: - type: number description: When `group_by=line_item`, this field provides the quantity of the grouped costs result. - type: 'null' + quantity_unit: + description: The unit of the `quantity` value. If no single supported unit applies to the result, this field is `null`. + anyOf: + - type: string + - type: string + enum: + - tokens + - 1000_tokens + - duration_seconds + - duration_minutes + - duration_hours + - gibibyte_hours + - images + - characters + - type: 'null' required: - object x-oaiMeta: @@ -31022,9 +31038,10 @@ components: "value": 0.06, "currency": "usd" }, - "line_item": "Image models", + "line_item": "input_tokens", "project_id": "proj_abc", - "quantity": 10000 + "quantity": 10000, + "quantity_unit": "tokens" } CreateAssistantRequest: type: object @@ -42278,6 +42295,9 @@ components: - type: string - type: 'null' description: The external key associated with the project. + residency: + $ref: '#/components/schemas/PublicProjectResidency' + description: The residency configuration for the project. required: - id - object @@ -42473,7 +42493,15 @@ components: anyOf: - type: string - type: 'null' - description: Create the project with the specified data residency region. Your organization must have access to Data residency functionality in order to use. See [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) to review the functionality and limitations of setting this field. + deprecated: true + description: |- + Create the project with the specified data residency region. Your organization must have access to Data residency functionality in order to use. See [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) to review the functionality and limitations of setting this field. + Deprecated: use `residency` instead. Do not provide both `geography` and `residency`. + residency: + anyOf: + - $ref: '#/components/schemas/PublicProjectResidency' + - type: 'null' + description: Create the project with the specified residency configuration. Your organization must have access to the requested residency configuration in order to use it. See [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) to review the functionality and limitations of setting this field. external_key_id: anyOf: - type: string @@ -43170,7 +43198,10 @@ components: anyOf: - type: string - type: 'null' - description: Geography for the project. + deprecated: true + description: |- + Geography for the project. + Deprecated: use `residency` when creating a project to configure data residency. This field is retained for backward compatibility. ProjectUser: type: object description: Represents an individual user in a project. @@ -64729,6 +64760,21 @@ components: - input_tokens_details title: Image generation usage description: For `gpt-image-1` only, the token usage information for the image generation. + PublicProjectResidency: + type: string + enum: + - GLOBAL + - US_STORAGE_PROCESSING + - EU_STORAGE_PROCESSING + - JP_STORAGE + - KR_STORAGE + - CA_STORAGE + - SG_STORAGE + - IN_STORAGE + - AU_STORAGE + - GB_STORAGE + - AE_STORAGE + - AE_STORAGE_PROCESSING SpecificProgrammaticToolCallingParam: properties: type: diff --git a/src/openai/resources/admin/organization/projects/projects.py b/src/openai/resources/admin/organization/projects/projects.py index 7696f2e9cb..3e6ae223b5 100644 --- a/src/openai/resources/admin/organization/projects/projects.py +++ b/src/openai/resources/admin/organization/projects/projects.py @@ -102,7 +102,12 @@ HostedToolPermissionsWithStreamingResponse, AsyncHostedToolPermissionsWithStreamingResponse, ) -from .....types.admin.organization import project_list_params, project_create_params, project_update_params +from .....types.admin.organization import ( + ProjectResidency, + project_list_params, + project_create_params, + project_update_params, +) from .service_accounts.service_accounts import ( ServiceAccounts, AsyncServiceAccounts, @@ -112,6 +117,7 @@ AsyncServiceAccountsWithStreamingResponse, ) from .....types.admin.organization.project import Project +from .....types.admin.organization.project_residency import ProjectResidency __all__ = ["Projects", "AsyncProjects"] @@ -190,6 +196,7 @@ def create( name: str, external_key_id: Optional[str] | Omit = omit, geography: Optional[str] | Omit = omit, + residency: Optional[ProjectResidency] | 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, @@ -210,6 +217,13 @@ def create( geography: Create the project with the specified data residency region. Your organization must have access to Data residency functionality in order to use. See [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) + to review the functionality and limitations of setting this field. Deprecated: + use `residency` instead. Do not provide both `geography` and `residency`. + + residency: Create the project with the specified residency configuration. Your organization + must have access to the requested residency configuration in order to use it. + See + [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) to review the functionality and limitations of setting this field. extra_headers: Send extra headers @@ -227,6 +241,7 @@ def create( "name": name, "external_key_id": external_key_id, "geography": geography, + "residency": residency, }, project_create_params.ProjectCreateParams, ), @@ -297,7 +312,8 @@ def update( Args: external_key_id: External key ID to associate with the project. - geography: Geography for the project. + geography: Geography for the project. Deprecated: use `residency` when creating a project + to configure data residency. This field is retained for backward compatibility. name: The updated name of the project, this name appears in reports. @@ -503,6 +519,7 @@ async def create( name: str, external_key_id: Optional[str] | Omit = omit, geography: Optional[str] | Omit = omit, + residency: Optional[ProjectResidency] | 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, @@ -523,6 +540,13 @@ async def create( geography: Create the project with the specified data residency region. Your organization must have access to Data residency functionality in order to use. See [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) + to review the functionality and limitations of setting this field. Deprecated: + use `residency` instead. Do not provide both `geography` and `residency`. + + residency: Create the project with the specified residency configuration. Your organization + must have access to the requested residency configuration in order to use it. + See + [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) to review the functionality and limitations of setting this field. extra_headers: Send extra headers @@ -540,6 +564,7 @@ async def create( "name": name, "external_key_id": external_key_id, "geography": geography, + "residency": residency, }, project_create_params.ProjectCreateParams, ), @@ -610,7 +635,8 @@ async def update( Args: external_key_id: External key ID to associate with the project. - geography: Geography for the project. + geography: Geography for the project. Deprecated: use `residency` when creating a project + to configure data residency. This field is retained for backward compatibility. name: The updated name of the project, this name appears in reports. diff --git a/src/openai/types/admin/organization/__init__.py b/src/openai/types/admin/organization/__init__.py index 035dcc55cb..9dc7700603 100644 --- a/src/openai/types/admin/organization/__init__.py +++ b/src/openai/types/admin/organization/__init__.py @@ -12,6 +12,8 @@ from .user_list_params import UserListParams as UserListParams from .group_list_params import GroupListParams as GroupListParams from .organization_user import OrganizationUser as OrganizationUser +from .project_residency import ProjectResidency as ProjectResidency +from .cost_quantity_unit import CostQuantityUnit as CostQuantityUnit from .invite_list_params import InviteListParams as InviteListParams from .role_create_params import RoleCreateParams as RoleCreateParams from .role_update_params import RoleUpdateParams as RoleUpdateParams diff --git a/src/openai/types/admin/organization/cost_quantity_unit.py b/src/openai/types/admin/organization/cost_quantity_unit.py new file mode 100644 index 0000000000..311c54212c --- /dev/null +++ b/src/openai/types/admin/organization/cost_quantity_unit.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Literal, TypeAlias + +__all__ = ["CostQuantityUnit"] + +CostQuantityUnit: TypeAlias = Union[ + str, + Literal[ + "tokens", + "1000_tokens", + "duration_seconds", + "duration_minutes", + "duration_hours", + "gibibyte_hours", + "images", + "characters", + ], +] diff --git a/src/openai/types/admin/organization/project.py b/src/openai/types/admin/organization/project.py index 0cda800aad..a0f51168c9 100644 --- a/src/openai/types/admin/organization/project.py +++ b/src/openai/types/admin/organization/project.py @@ -4,6 +4,7 @@ from typing_extensions import Literal from ...._models import BaseModel +from .project_residency import ProjectResidency __all__ = ["Project"] @@ -29,5 +30,8 @@ class Project(BaseModel): name: Optional[str] = None """The name of the project. This appears in reporting.""" + residency: Optional[ProjectResidency] = None + """The residency configuration for the project.""" + status: Optional[str] = None """`active` or `archived`""" diff --git a/src/openai/types/admin/organization/project_create_params.py b/src/openai/types/admin/organization/project_create_params.py index 9a750d86cc..a6adc120bc 100644 --- a/src/openai/types/admin/organization/project_create_params.py +++ b/src/openai/types/admin/organization/project_create_params.py @@ -5,6 +5,8 @@ from typing import Optional from typing_extensions import Required, TypedDict +from .project_residency import ProjectResidency + __all__ = ["ProjectCreateParams"] @@ -21,5 +23,15 @@ class ProjectCreateParams(TypedDict, total=False): Your organization must have access to Data residency functionality in order to use. See [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) + to review the functionality and limitations of setting this field. Deprecated: + use `residency` instead. Do not provide both `geography` and `residency`. + """ + + residency: Optional[ProjectResidency] + """Create the project with the specified residency configuration. + + Your organization must have access to the requested residency configuration in + order to use it. See + [data residency controls](https://platform.openai.com/docs/guides/your-data#data-residency-controls) to review the functionality and limitations of setting this field. """ diff --git a/src/openai/types/admin/organization/project_residency.py b/src/openai/types/admin/organization/project_residency.py new file mode 100644 index 0000000000..93b506d2a9 --- /dev/null +++ b/src/openai/types/admin/organization/project_residency.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["ProjectResidency"] + +ProjectResidency: TypeAlias = Literal[ + "GLOBAL", + "US_STORAGE_PROCESSING", + "EU_STORAGE_PROCESSING", + "JP_STORAGE", + "KR_STORAGE", + "CA_STORAGE", + "SG_STORAGE", + "IN_STORAGE", + "AU_STORAGE", + "GB_STORAGE", + "AE_STORAGE", + "AE_STORAGE_PROCESSING", +] diff --git a/src/openai/types/admin/organization/project_update_params.py b/src/openai/types/admin/organization/project_update_params.py index fae8147acf..a16d25c1eb 100644 --- a/src/openai/types/admin/organization/project_update_params.py +++ b/src/openai/types/admin/organization/project_update_params.py @@ -13,7 +13,10 @@ class ProjectUpdateParams(TypedDict, total=False): """External key ID to associate with the project.""" geography: Optional[str] - """Geography for the project.""" + """ + Geography for the project. Deprecated: use `residency` when creating a project + to configure data residency. This field is retained for backward compatibility. + """ name: Optional[str] """The updated name of the project, this name appears in reports.""" diff --git a/src/openai/types/admin/organization/usage_audio_speeches_response.py b/src/openai/types/admin/organization/usage_audio_speeches_response.py index 215b94edd9..4ce555d3fb 100644 --- a/src/openai/types/admin/organization/usage_audio_speeches_response.py +++ b/src/openai/types/admin/organization/usage_audio_speeches_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageAudioSpeechesResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_audio_transcriptions_response.py b/src/openai/types/admin/organization/usage_audio_transcriptions_response.py index 77c9b50647..65d0ef1b0b 100644 --- a/src/openai/types/admin/organization/usage_audio_transcriptions_response.py +++ b/src/openai/types/admin/organization/usage_audio_transcriptions_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageAudioTranscriptionsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_code_interpreter_sessions_response.py b/src/openai/types/admin/organization/usage_code_interpreter_sessions_response.py index 34fc489886..6dd529ac3c 100644 --- a/src/openai/types/admin/organization/usage_code_interpreter_sessions_response.py +++ b/src/openai/types/admin/organization/usage_code_interpreter_sessions_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageCodeInterpreterSessionsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_completions_response.py b/src/openai/types/admin/organization/usage_completions_response.py index 40f23e9d3f..d7c116e29b 100644 --- a/src/openai/types/admin/organization/usage_completions_response.py +++ b/src/openai/types/admin/organization/usage_completions_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageCompletionsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_costs_response.py b/src/openai/types/admin/organization/usage_costs_response.py index aca7d4d045..c77c34d22e 100644 --- a/src/openai/types/admin/organization/usage_costs_response.py +++ b/src/openai/types/admin/organization/usage_costs_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageCostsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_embeddings_response.py b/src/openai/types/admin/organization/usage_embeddings_response.py index 3e555793d9..fe70897c0e 100644 --- a/src/openai/types/admin/organization/usage_embeddings_response.py +++ b/src/openai/types/admin/organization/usage_embeddings_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageEmbeddingsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_file_search_calls_response.py b/src/openai/types/admin/organization/usage_file_search_calls_response.py index 12d938a7a1..e1d8d9164f 100644 --- a/src/openai/types/admin/organization/usage_file_search_calls_response.py +++ b/src/openai/types/admin/organization/usage_file_search_calls_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageFileSearchCallsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_images_response.py b/src/openai/types/admin/organization/usage_images_response.py index adc428963d..32f88b8629 100644 --- a/src/openai/types/admin/organization/usage_images_response.py +++ b/src/openai/types/admin/organization/usage_images_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageImagesResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_moderations_response.py b/src/openai/types/admin/organization/usage_moderations_response.py index 2b3c21fef7..8c71218e5c 100644 --- a/src/openai/types/admin/organization/usage_moderations_response.py +++ b/src/openai/types/admin/organization/usage_moderations_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageModerationsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_vector_stores_response.py b/src/openai/types/admin/organization/usage_vector_stores_response.py index 18ae9425da..148050378a 100644 --- a/src/openai/types/admin/organization/usage_vector_stores_response.py +++ b/src/openai/types/admin/organization/usage_vector_stores_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageVectorStoresResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/src/openai/types/admin/organization/usage_web_search_calls_response.py b/src/openai/types/admin/organization/usage_web_search_calls_response.py index bb88718080..f38fa0b532 100644 --- a/src/openai/types/admin/organization/usage_web_search_calls_response.py +++ b/src/openai/types/admin/organization/usage_web_search_calls_response.py @@ -5,6 +5,7 @@ from ...._utils import PropertyInfo from ...._models import BaseModel +from .cost_quantity_unit import CostQuantityUnit __all__ = [ "UsageWebSearchCallsResponse", @@ -470,6 +471,12 @@ class DataResultOrganizationCostsResult(BaseModel): result. """ + quantity_unit: Optional[CostQuantityUnit] = None + """The unit of the `quantity` value. + + If no single supported unit applies to the result, this field is `null`. + """ + DataResult: TypeAlias = Annotated[ Union[ diff --git a/tests/api_resources/admin/organization/test_projects.py b/tests/api_resources/admin/organization/test_projects.py index 4e13ab7cfa..17f40e5e47 100644 --- a/tests/api_resources/admin/organization/test_projects.py +++ b/tests/api_resources/admin/organization/test_projects.py @@ -31,6 +31,7 @@ def test_method_create_with_all_params(self, client: OpenAI) -> None: name="name", external_key_id="external_key_id", geography="geography", + residency="GLOBAL", ) assert_matches_type(Project, project, path=["response"]) @@ -235,6 +236,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncOpenAI) -> name="name", external_key_id="external_key_id", geography="geography", + residency="GLOBAL", ) assert_matches_type(Project, project, path=["response"]) From 555ac487f450f24928d859478ea2f41b58906206 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Tue, 25 Aug 2026 20:32:14 +0000 Subject: [PATCH 2/2] fix(api): encode Realtime call offers and session configuration (#3736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Use generated request encoding for Realtime WebRTC setup so future schema changes no longer require a parallel handwritten implementation. Applications can send an SDP offer and optional session configuration through the existing synchronous and asynchronous `calls.create` methods. Requests now preserve the API’s declared SDP and JSON content types, so offers and session configuration reach the API in the expected format. SDP-only requests use the supported raw-SDP form; the method signatures and SDP-response interface stay the same. Co-authored-by: apcha-oai <228803254+apcha-oai@users.noreply.github.com> --- .castiron.stats.yml | 10 +-- src/openai/_multipart.py | 48 ++++++++++++ src/openai/lib/_realtime.py | 91 +--------------------- src/openai/resources/realtime/calls.py | 77 ++++++++++++++----- tests/lib/test_realtime_calls.py | 101 +++++++++++++++++++++++++ tests/test_multipart_encoding.py | 69 +++++++++++++++++ 6 files changed, 283 insertions(+), 113 deletions(-) create mode 100644 src/openai/_multipart.py create mode 100644 tests/lib/test_realtime_calls.py create mode 100644 tests/test_multipart_encoding.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 1e91cdbfc1..9320ad8d4a 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: 743ac88c-c04a-4f93-b104-39ff2b29f860 +generation_id: 894a0870-fe44-47b8-ae0e-d852e0a642a9 openapi_spec_hash: b2c62b342b04685226037c980f2b5a7a openapi_transformed_spec_hash: ba44bf31326f01a1d886b7510dd9da8a -config_hash: 1e11a5becf7bc1c2e9e07b8654872dc4 -codegen_sha: a0e85ddf73948ad851577815ee7b872fac9ee98f -codegen_hash: c19628547a78939134912b93344668c37d65464bc08e3b3b9d22ef9a05fb264e -public_codegen_sha: b8a8c67c1d0d8f12ada756a7d7f90bfac10330b0 +config_hash: 4fbbbcf377bcc17646cccb9fcfbac14a +codegen_sha: 29ea2f9fd4247a24ce57aec59ee2b9240745fcdf +codegen_hash: dcd47732b7809ce7566ec5162d689b5320158e41aebcce2b31eaa9cd97fc4da2 +public_codegen_sha: eb1e776d29014be85225038a072a679387bda999 diff --git a/src/openai/_multipart.py b/src/openai/_multipart.py new file mode 100644 index 0000000000..a1257b1f41 --- /dev/null +++ b/src/openai/_multipart.py @@ -0,0 +1,48 @@ +# File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Mapping, cast + +from ._types import Body, Omit, NotGiven, FileTypes, RequestFiles +from ._utils._json import openapi_dumps + + +def encode_multipart( + body: object, + extra_body: Body | None, + encodings: Mapping[str, tuple[str, bool]], + raw_body_field: str | None = None, + existing_files: RequestFiles | None = None, +) -> tuple[dict[str, object] | None, RequestFiles | None, bytes | None, str]: + """Prepare explicitly encoded form fields without flattening their JSON contents.""" + if not isinstance(body, Mapping): + raise TypeError("Multipart request body must be a mapping") + if extra_body is not None and not isinstance(extra_body, Mapping): + raise TypeError("Multipart extra_body must be a mapping") + original = cast(Mapping[str, object], body) + overrides = cast(Mapping[str, object], extra_body or {}) + merged = {key: value for key, value in {**original, **overrides}.items() if not isinstance(value, (Omit, NotGiven))} + # A raw request alternative is safe only when there is no other payload to lose. + # Explicit null remains a JSON part; only an omitted field selects the raw body. + if not existing_files and raw_body_field is not None and set(merged) == {raw_body_field}: + value = merged[raw_body_field] + if not isinstance(value, str): + raise TypeError("Raw multipart alternative must be a string") + return None, None, value.encode("utf-8"), encodings[raw_body_field][0] + + files: list[tuple[str, FileTypes]] = list( + existing_files.items() if isinstance(existing_files, Mapping) else (existing_files or []) + ) + for name, (content_type, as_json) in encodings.items(): + if name not in merged: + continue + value = merged.pop(name) + if as_json: + data = openapi_dumps(value) + else: + if not isinstance(value, str): + raise TypeError(f"Multipart field {name!r} must be a string") + data = value.encode("utf-8") + files.append((name, (None, data, content_type))) + return merged or None, files, None, "multipart/form-data" diff --git a/src/openai/lib/_realtime.py b/src/openai/lib/_realtime.py index e894d8e451..d106b987bc 100644 --- a/src/openai/lib/_realtime.py +++ b/src/openai/lib/_realtime.py @@ -1,92 +1,7 @@ from __future__ import annotations -import json -from typing_extensions import override - -import httpx2 - -from openai import _legacy_response -from openai._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from openai._utils import maybe_transform, async_maybe_transform -from openai._base_client import make_request_options -from openai.resources.realtime.calls import Calls, AsyncCalls -from openai.types.realtime.realtime_session_create_request_param import RealtimeSessionCreateRequestParam +# Compatibility aliases for the former handwritten call-creation implementation. +# Multipart and raw SDP serialization now come from the generated resources. +from openai.resources.realtime.calls import Calls as _Calls, AsyncCalls as _AsyncCalls __all__ = ["_Calls", "_AsyncCalls"] - - -# Custom code to override the `create` method to have correct behavior with -# application/sdp and multipart/form-data. -# Ideally we can cutover to the generated code this overrides eventually and remove this. -class _Calls(Calls): - @override - def create( - self, - *, - sdp: str, - session: RealtimeSessionCreateRequestParam | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx2.Timeout | None | NotGiven = not_given, - ) -> _legacy_response.HttpxBinaryResponseContent: - if session is omit: - extra_headers = {"Accept": "application/sdp", "Content-Type": "application/sdp", **(extra_headers or {})} - return self._post( - "/realtime/calls", - content=sdp.encode("utf-8"), - options=make_request_options(extra_headers=extra_headers, extra_query=extra_query, timeout=timeout), - cast_to=_legacy_response.HttpxBinaryResponseContent, - ) - - extra_headers = {"Accept": "application/sdp", "Content-Type": "multipart/form-data", **(extra_headers or {})} - session_payload = maybe_transform(session, RealtimeSessionCreateRequestParam) - files = [ - ("sdp", (None, sdp.encode("utf-8"), "application/sdp")), - ("session", (None, json.dumps(session_payload).encode("utf-8"), "application/json")), - ] - return self._post( - "/realtime/calls", - files=files, - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=_legacy_response.HttpxBinaryResponseContent, - ) - - -class _AsyncCalls(AsyncCalls): - @override - async def create( - self, - *, - sdp: str, - session: RealtimeSessionCreateRequestParam | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx2.Timeout | None | NotGiven = not_given, - ) -> _legacy_response.HttpxBinaryResponseContent: - if session is omit: - extra_headers = {"Accept": "application/sdp", "Content-Type": "application/sdp", **(extra_headers or {})} - return await self._post( - "/realtime/calls", - content=sdp.encode("utf-8"), - options=make_request_options(extra_headers=extra_headers, extra_query=extra_query, timeout=timeout), - cast_to=_legacy_response.HttpxBinaryResponseContent, - ) - - extra_headers = {"Accept": "application/sdp", "Content-Type": "multipart/form-data", **(extra_headers or {})} - session_payload = await async_maybe_transform(session, RealtimeSessionCreateRequestParam) - files = [ - ("sdp", (None, sdp.encode("utf-8"), "application/sdp")), - ("session", (None, json.dumps(session_payload).encode("utf-8"), "application/json")), - ] - return await self._post( - "/realtime/calls", - files=files, - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=_legacy_response.HttpxBinaryResponseContent, - ) diff --git a/src/openai/resources/realtime/calls.py b/src/openai/resources/realtime/calls.py index 3bc6817f5d..09b81af264 100644 --- a/src/openai/resources/realtime/calls.py +++ b/src/openai/resources/realtime/calls.py @@ -20,6 +20,7 @@ to_custom_streamed_response_wrapper, async_to_custom_streamed_response_wrapper, ) +from ..._multipart import encode_multipart from ..._base_client import make_request_options from ...types.realtime import ( call_refer_params, @@ -71,9 +72,8 @@ def create( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> _legacy_response.HttpxBinaryResponseContent: - """ - Create a new Realtime API call over WebRTC and receive the SDP answer needed to - complete the peer connection. + """Create a new Realtime API call over WebRTC and receive the SDP answer needed + to complete the peer connection. Args: sdp: WebRTC Session Description Protocol (SDP) offer generated by the caller. @@ -89,15 +89,34 @@ def create( timeout: Override the client-level default timeout for this request, in seconds """ extra_headers = {"Accept": "application/sdp", **(extra_headers or {})} + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + multipart_body = maybe_transform( + { + "sdp": sdp, + "session": session, + }, + call_create_params.CallCreateParams, + ) + multipart_body, multipart_files, multipart_content, content_type = encode_multipart( + multipart_body, + extra_body, + {"sdp": ("application/sdp", False), "session": ("application/json", True)}, + "sdp", + None, + ) + extra_body = None + extra_headers = { + **{key: value for key, value in (extra_headers or {}).items() if key.lower() != "content-type"}, + "Content-Type": content_type, + } return self._post( "/realtime/calls", - body=maybe_transform( - { - "sdp": sdp, - "session": session, - }, - call_create_params.CallCreateParams, - ), + body=multipart_body, + files=multipart_files, + content=multipart_content, options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -433,9 +452,8 @@ async def create( extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, ) -> _legacy_response.HttpxBinaryResponseContent: - """ - Create a new Realtime API call over WebRTC and receive the SDP answer needed to - complete the peer connection. + """Create a new Realtime API call over WebRTC and receive the SDP answer needed + to complete the peer connection. Args: sdp: WebRTC Session Description Protocol (SDP) offer generated by the caller. @@ -451,15 +469,34 @@ async def create( timeout: Override the client-level default timeout for this request, in seconds """ extra_headers = {"Accept": "application/sdp", **(extra_headers or {})} + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + multipart_body = await async_maybe_transform( + { + "sdp": sdp, + "session": session, + }, + call_create_params.CallCreateParams, + ) + multipart_body, multipart_files, multipart_content, content_type = encode_multipart( + multipart_body, + extra_body, + {"sdp": ("application/sdp", False), "session": ("application/json", True)}, + "sdp", + None, + ) + extra_body = None + extra_headers = { + **{key: value for key, value in (extra_headers or {}).items() if key.lower() != "content-type"}, + "Content-Type": content_type, + } return await self._post( "/realtime/calls", - body=await async_maybe_transform( - { - "sdp": sdp, - "session": session, - }, - call_create_params.CallCreateParams, - ), + body=multipart_body, + files=multipart_files, + content=multipart_content, options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/tests/lib/test_realtime_calls.py b/tests/lib/test_realtime_calls.py new file mode 100644 index 0000000000..c2c4da4f90 --- /dev/null +++ b/tests/lib/test_realtime_calls.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +from email import policy +from typing import Any, Callable, cast +from email.parser import BytesParser +from email.message import EmailMessage + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI, omit +from openai.resources.realtime.calls import Calls, AsyncCalls + +OFFER = "v=0\r\ns=Unicode π\r\n" +ANSWER = "v=0\r\ns=answer\r\n" +SETTINGS = {"type": "realtime", "model": "test-model", "future": {"flags": [False, None]}} +MODES = ["multipart", "omitted", "null", "extra-only", "extra-null", "omit-override", "sdp-override"] + + +def request_args(mode: str) -> dict[str, Any]: + args: dict[str, Any] = {"sdp": OFFER, "extra_headers": {"content-type": "application/json"}} + if mode == "multipart": + args.update(session={"type": "realtime", "model": "overridden"}, extra_body={"session": SETTINGS}) + elif mode == "null": + args["session"] = None + elif mode == "extra-only": + args["extra_body"] = {"session": SETTINGS} + elif mode == "extra-null": + args["extra_body"] = {"session": None} + elif mode == "omit-override": + args.update(session=SETTINGS, extra_body={"session": omit}) + elif mode == "sdp-override": + args.update(sdp="superseded", extra_body={"sdp": OFFER, "session": SETTINGS}) + return args + + +def handler_for(mode: str, calls: list[httpx2.Request]) -> Callable[[httpx2.Request], httpx2.Response]: + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(request) + assert request.url.path == "/v1/realtime/calls" + assert request.headers["accept"] == "application/sdp" + if mode in {"omitted", "omit-override"}: + assert request.headers["content-type"] == "application/sdp" + assert request.read() == OFFER.encode() + else: + assert request.headers["content-type"].startswith("multipart/form-data; boundary=") + message = BytesParser(_class=EmailMessage, policy=policy.default).parsebytes( + ("Content-Type: " + request.headers["content-type"] + "\r\n\r\n").encode() + request.read() + ) + parts = list(message.iter_parts()) + assert len(parts) == 2 + named = {part.get_param("name", header="content-disposition"): part for part in parts} + assert named["sdp"].get_content_type() == "application/sdp" + assert named["sdp"].get_payload(decode=True) == OFFER.encode() + assert named["session"].get_content_type() == "application/json" + assert json.loads(cast(bytes, named["session"].get_payload(decode=True))) == ( + None if mode in {"null", "extra-null"} else SETTINGS + ) + assert all(part.get_filename() is None for part in parts) + if len(calls) == 1: + return httpx2.Response(500, json={"error": {"message": "synthetic retry"}}, headers={"retry-after-ms": "1"}) + return httpx2.Response( + 201, content=ANSWER, headers={"content-type": "application/sdp", "location": "/v1/realtime/calls/test-call"} + ) + + return handler + + +@pytest.mark.parametrize("mode", MODES) +def test_public_create_uses_generated_wire_contract(mode: str) -> None: + calls: list[httpx2.Request] = [] + with OpenAI( + api_key="synthetic-test-key", + base_url="https://example.test/v1", + max_retries=1, + http_client=httpx2.Client(transport=httpx2.MockTransport(handler_for(mode, calls))), + ) as client: + assert type(client.realtime.calls).create is Calls.create + response = client.realtime.calls.with_raw_response.create(**request_args(mode)) + assert response.status_code == 201 + assert response.headers["location"].endswith("/test-call") + assert response.parse().text == ANSWER + assert len(calls) == 2 + + +@pytest.mark.parametrize("mode", MODES) +async def test_public_async_create_uses_generated_wire_contract(mode: str) -> None: + calls: list[httpx2.Request] = [] + async with AsyncOpenAI( + api_key="synthetic-test-key", + base_url="https://example.test/v1", + max_retries=1, + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler_for(mode, calls))), + ) as client: + assert type(client.realtime.calls).create is AsyncCalls.create + response = await client.realtime.calls.with_raw_response.create(**request_args(mode)) + assert response.status_code == 201 + assert response.headers["location"].endswith("/test-call") + assert response.parse().text == ANSWER + assert len(calls) == 2 diff --git a/tests/test_multipart_encoding.py b/tests/test_multipart_encoding.py new file mode 100644 index 0000000000..5c2b4823df --- /dev/null +++ b/tests/test_multipart_encoding.py @@ -0,0 +1,69 @@ +# File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import json +from email import policy +from typing import cast +from email.parser import BytesParser +from email.message import EmailMessage + +import httpx2 + +from openai import omit +from openai._files import to_httpx_files +from openai._multipart import encode_multipart + +ENCODINGS = {"offer": ("application/sdp", False), "settings": ("application/json", True)} +OFFER = "v=0\r\ns=Unicode π\r\n" + + +def test_encoded_parts_preserve_json_and_text() -> None: + settings = {"nested": {"future": [False, None, "π"]}} + body, files, content, content_type = encode_multipart( + {"offer": OFFER, "settings": {"old": True}}, {"settings": settings}, ENCODINGS + ) + request = httpx2.Request("POST", "https://example.test", data=body, files=to_httpx_files(files), content=content) + payload = request.read() + message = BytesParser(_class=EmailMessage, policy=policy.default).parsebytes( + ("Content-Type: " + request.headers["content-type"] + "\r\n\r\n").encode() + payload + ) + assert content_type == "multipart/form-data" + parts = list(message.iter_parts()) + assert len(parts) == 2 + named = {part.get_param("name", header="content-disposition"): part for part in parts} + assert named["offer"].get_content_type() == "application/sdp" + assert named["offer"].get_payload(decode=True) == OFFER.encode() + assert named["settings"].get_content_type() == "application/json" + assert json.loads(cast(bytes, named["settings"].get_payload(decode=True))) == settings + assert all(part.get_filename() is None for part in parts) + + +def test_raw_alternative_omits_absent_settings() -> None: + body, files, content, content_type = encode_multipart({"offer": OFFER, "settings": omit}, None, ENCODINGS, "offer") + assert body is None and files is None + assert content == OFFER.encode() + assert content_type == "application/sdp" + + +def test_explicit_null_is_a_json_part_not_a_raw_alternative() -> None: + _body, files, content, content_type = encode_multipart({"offer": OFFER, "settings": None}, None, ENCODINGS, "offer") + assert content is None + assert content_type == "multipart/form-data" + assert dict(files or [])["settings"] == (None, b"null", "application/json") + + +def test_extra_body_does_not_get_dropped_by_raw_alternative() -> None: + _body, files, content, content_type = encode_multipart( + {"offer": OFFER}, {"settings": {"future": True}}, ENCODINGS, "offer" + ) + assert files is not None + assert content is None + assert content_type == "multipart/form-data" + + +def test_omit_override_removes_field() -> None: + _body, _files, content, _content_type = encode_multipart( + {"offer": OFFER, "settings": {}}, {"settings": omit}, ENCODINGS, "offer" + ) + assert content == OFFER.encode()