From 528c49c788f4655a578283b8c862052d0599c17c Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:14:44 +0000 Subject: [PATCH] feat(api): improve WebSocket recovery and delivery reporting Castiron-Internal-PR: https://github.com/openai/openai-python-internal/pull/381 Castiron-Source-SHA: 4343ad7f8f297487a52e0f24c50a2e6f4335ca9b Castiron-Public-Base-SHA: 05992039b9f7b78a48cf1e5a35727ba27d96db6e --- .castiron.stats.yml | 12 +- api.md | 32 +- api_reference/openapi.transformed.yml | 5595 +++++++++-------- scripts/castiron/generated-files.json | 22 +- src/openai/_event_handler.py | 37 +- src/openai/_send_queue.py | 167 +- src/openai/_websocket_connection.py | 207 +- .../lib/responses_websocket/_session.py | 16 +- .../resources/admin/organization/__init__.py | 24 +- .../admin/organization/organization.py | 48 +- .../resources/beta/responses/responses.py | 44 +- src/openai/resources/live/forks.py | 44 +- src/openai/resources/live/live.py | 44 +- src/openai/resources/live/sideband.py | 44 +- src/openai/resources/realtime/realtime.py | 46 +- .../realtime/translations/translations.py | 50 +- src/openai/resources/responses/responses.py | 44 +- .../admin/organization/test_external_keys.py | 16 +- tests/lib/responses/test_websocket_session.py | 5 +- tests/test_send_queue.py | 88 + tests/test_send_queue_reconnect.py | 14 +- tests/test_websocket_initial_backlog.py | 602 ++ tests/test_websocket_recovery_regressions.py | 567 ++ tests/test_websocket_terminal_flush.py | 1196 ++++ 24 files changed, 5986 insertions(+), 2978 deletions(-) create mode 100644 tests/test_websocket_initial_backlog.py create mode 100644 tests/test_websocket_recovery_regressions.py create mode 100644 tests/test_websocket_terminal_flush.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 92eb4f5552..43732832aa 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,7 +1,7 @@ schema_version: 1 -generation_id: edefdf16-46c7-4f93-94a6-10028a087b3e -openapi_spec_hash: 0c25fc15ac5b26e2054196d579cc67a0 -openapi_transformed_spec_hash: 651cea16581547a33af56bd631894a03 -config_hash: 8a684aec06907ff8ced7809753e16225 -codegen_sha: a1dd47dd01ce07be1ecbc39e2f66f2156d89c6f3 -codegen_hash: 2c7827fd0ccd38edcd80a97839ae5c10cf89f8da20ea468d4b20c41468607b81 +generation_id: e511c488-31b9-4833-baf5-cfb49bbba7ac +openapi_spec_hash: d57efec7c76a156447c9ab739b446dc1 +openapi_transformed_spec_hash: b34c897c8cec867ec4ac8e22727c5975 +config_hash: 8da38e8d8f706786c2d8586691168746 +codegen_sha: e46410d5a9751f068b9afce57ecf25abda75de09 +codegen_hash: 33536449fa4898b45941d5fa4b5a958f88e2701a4b85fae21c372b75614198cd diff --git a/api.md b/api.md index 5c1bbf74e9..a4f50d2ebf 100644 --- a/api.md +++ b/api.md @@ -1524,6 +1524,22 @@ Methods: - client.admin.organization.data_retention.retrieve() -> OrganizationDataRetention - client.admin.organization.data_retention.update(\*\*params) -> OrganizationDataRetention +### ExternalKeys + +Types: + +```python +from openai.types.admin.organization import ExternalKey, ExternalKeyValidateResponse +``` + +Methods: + +- client.admin.organization.external_keys.create(\*\*params) -> ExternalKey +- client.admin.organization.external_keys.retrieve(external_key_id) -> ExternalKey +- client.admin.organization.external_keys.list(\*\*params) -> SyncCursorPage[ExternalKey] +- client.admin.organization.external_keys.delete(external_key_id) -> ExternalKey +- client.admin.organization.external_keys.validate(external_key_id) -> ExternalKeyValidateResponse + ### ExternalStorage Types: @@ -1855,22 +1871,6 @@ Methods: - client.admin.organization.projects.certificates.activate(project_id, \*\*params) -> SyncPage[CertificateActivateResponse] - client.admin.organization.projects.certificates.deactivate(project_id, \*\*params) -> SyncPage[CertificateDeactivateResponse] -### ExternalKeys - -Types: - -```python -from openai.types.admin.organization import ExternalKey, ExternalKeyValidateResponse -``` - -Methods: - -- client.admin.organization.external_keys.create(\*\*params) -> ExternalKey -- client.admin.organization.external_keys.retrieve(external_key_id) -> ExternalKey -- client.admin.organization.external_keys.list(\*\*params) -> SyncCursorPage[ExternalKey] -- client.admin.organization.external_keys.delete(external_key_id) -> ExternalKey -- client.admin.organization.external_keys.validate(external_key_id) -> ExternalKeyValidateResponse - # [Responses](src/openai/resources/responses/api.md) # [Live](src/openai/resources/live/api.md) diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index ff6378fcfa..1593241a6e 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -1348,6 +1348,7 @@ paths: /audio/translations: post: operationId: createTranslation + deprecated: true tags: - Audio summary: Create translation @@ -1477,7 +1478,6 @@ paths: { "text": "Hello, my name is Wolfgang and I come from Germany. Where are you heading today?" } - deprecated: true /audio/voice_consents: post: operationId: createVoiceConsent @@ -26741,6 +26741,125 @@ paths: "error_type": "potentially_unintended_data_access", "reason": "Potentially unintended data access." } + /organization/external_keys: + post: + summary: Add an external key config for an organization + description: Add an external key config for an organization + operationId: Addanexternalkeyconfigforanorganization + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/V1OrganizationExternalKeysPostRequest' + required: true + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalKeyResource' + security: + - AdminApiKeyAuth: [] + get: + summary: List the external key configs registered on the user's organization + description: List the external key configs registered on the user's organization + operationId: Listtheexternalkeyconfigsregisteredontheuser'sorganization + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 0 + maximum: 100 + - name: order + in: query + required: false + schema: + $ref: '#/components/schemas/OrderEnum' + - name: after + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + - name: purpose + in: query + description: Filter by key purpose. When omitted, returns keys of all purposes. + required: false + schema: + $ref: '#/components/schemas/ExternalKeyPurpose' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ListExternalKeysResource' + security: + - AdminApiKeyAuth: [] + /organization/external_keys/{external_key_id}: + get: + summary: Retrieve an external key registration + description: Retrieve a stored external key registration for the user's organization. + operationId: Retrieveanexternalkeyregistration + parameters: + - name: external_key_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalKeyResource' + security: + - AdminApiKeyAuth: [] + delete: + summary: Delete an external key config for the user's organization + description: Delete an external key config for the user's organization + operationId: Deleteanexternalkeyconfigfortheuser'sorganization + parameters: + - name: external_key_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalKeyResource' + security: + - AdminApiKeyAuth: [] + /organization/external_keys/{external_key_id}/validate: + post: + summary: Validate an external key config for the user's organization + description: Validate an external key config for the user's organization + operationId: Validateanexternalkeyconfigfortheuser'sorganization + parameters: + - name: external_key_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateExternalKeyResource' + security: + - AdminApiKeyAuth: [] /live/sessions/{session_id}/content: get: tags: @@ -27104,10 +27223,24 @@ paths: "geography": "US", "status": "validated", "created_at": 1711471400 + }, + { + "object": "organization.external_storage", + "id": "extstorage_oci123", + "project_id": "proj_oci123", + "provider": { + "type": "oci", + "tenancy_ocid": "ocid1.tenancy.oc1..example", + "region": "us-ashburn-1", + "bucket": "customer-oci-retention" + }, + "geography": "US", + "status": "validated", + "created_at": 1711471300 } ], "first_id": "extstorage_abc123", - "last_id": "extstorage_gcp123", + "last_id": "extstorage_oci123", "has_more": false } security: @@ -27257,6 +27390,36 @@ paths: "status": "pending", "created_at": 1711471400 } + - title: OCI Object Storage + request: + curl: | + curl -X POST https://api.openai.com/v1/organization/external_storage \ + -H "Authorization: Bearer $OPENAI_ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "project_id": "proj_oci123", + "provider": { + "type": "oci", + "tenancy_ocid": "ocid1.tenancy.oc1..example", + "region": "us-ashburn-1", + "bucket": "customer-oci-retention" + } + }' + response: | + { + "object": "organization.external_storage", + "id": "extstorage_oci123", + "project_id": "proj_oci123", + "provider": { + "type": "oci", + "tenancy_ocid": "ocid1.tenancy.oc1..example", + "region": "us-ashburn-1", + "bucket": "customer-oci-retention" + }, + "geography": "US", + "status": "pending", + "created_at": 1711471300 + } security: - AdminApiKeyAuth: [] /organization/external_storage/{external_storage_id}: @@ -33188,6 +33351,7 @@ paths: maxLength: 64 minProperties: 0 maxProperties: 16 + x-openai-go-public-type: pointer_collection description: Exact string matches supplied as `metadata[key]=value`. All supplied pairs must match. Up to 16 pairs, with keys from 1 to 64 characters and values up to 512 characters. New sessions and metadata updates may take time to appear in filtered results. style: deepObject explode: true @@ -37508,125 +37672,6 @@ paths: "object": "response.input_tokens", "input_tokens": 11 } - /organization/external_keys: - post: - summary: Add an external key config for an organization - description: Add an external key config for an organization - operationId: Addanexternalkeyconfigforanorganization - parameters: [] - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/V1OrganizationExternalKeysPostRequest' - required: true - responses: - '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/ExternalKeyResource' - security: - - AdminApiKeyAuth: [] - get: - summary: List the external key configs registered on the user's organization - description: List the external key configs registered on the user's organization - operationId: Listtheexternalkeyconfigsregisteredontheuser'sorganization - parameters: - - name: limit - in: query - required: false - schema: - type: integer - minimum: 0 - maximum: 100 - - name: order - in: query - required: false - schema: - $ref: '#/components/schemas/OrderEnum' - - name: after - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - - name: purpose - in: query - description: Filter by key purpose. When omitted, returns keys of all purposes. - required: false - schema: - $ref: '#/components/schemas/ExternalKeyPurpose' - responses: - '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/ListExternalKeysResource' - security: - - AdminApiKeyAuth: [] - /organization/external_keys/{external_key_id}: - get: - summary: Retrieve an external key registration - description: Retrieve a stored external key registration for the user's organization. - operationId: Retrieveanexternalkeyregistration - parameters: - - name: external_key_id - in: path - required: true - schema: - type: string - responses: - '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/ExternalKeyResource' - security: - - AdminApiKeyAuth: [] - delete: - summary: Delete an external key config for the user's organization - description: Delete an external key config for the user's organization - operationId: Deleteanexternalkeyconfigfortheuser'sorganization - parameters: - - name: external_key_id - in: path - required: true - schema: - type: string - responses: - '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/ExternalKeyResource' - security: - - AdminApiKeyAuth: [] - /organization/external_keys/{external_key_id}/validate: - post: - summary: Validate an external key config for the user's organization - description: Validate an external key config for the user's organization - operationId: Validateanexternalkeyconfigfortheuser'sorganization - parameters: - - name: external_key_id - in: path - required: true - schema: - type: string - responses: - '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/ValidateExternalKeyResource' - security: - - AdminApiKeyAuth: [] webhooks: agent_environment_expired: post: @@ -38504,14 +38549,6 @@ components: - gpt-3.5-turbo-0125 - gpt-3.5-turbo-16k-0613 x-openai-enum-deprecations: - gpt-4.1: - message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-4.1-mini: - message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-4o: - message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-4o-mini: - message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-5: message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-5-mini: @@ -38524,6 +38561,14 @@ components: message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-5-nano-2025-08-07: message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-4.1: + message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-4.1-mini: + message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-4o: + message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-4o-mini: + message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' AssistantToolsCode: type: object title: Code interpreter tool @@ -44256,330 +44301,330 @@ components: format: binary x-oaiMeta: exampleFilePath: mask.png - background: - anyOf: - - type: string - enum: - - transparent - - opaque - - auto - - type: 'null' - default: auto - example: transparent - description: | - Allows to set transparency for the background of the generated image(s). Must - be one of `transparent`, `opaque`, or `auto` (default value). When `auto` is - used, the model will automatically determine the best background for the - image. - - `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their - `2026-09-08` snapshots, support `opaque` and `transparent` backgrounds. - Transparent backgrounds are available for supported GPT Image models. For - `gpt-image-2` and `gpt-image-2-2026-04-21`, this support is in preview. When - using `transparent`, set the output format to `png` or `webp`. - model: - anyOf: - - type: string - - type: string - enum: - - gpt-image-1.5 - - gpt-image-2 - - gpt-image-2-2026-04-21 - - gpt-image-2.5-sunburst - - gpt-image-2.5-sunburst-2026-09-08 - - gpt-image-2.5-flare - - gpt-image-2.5-flare-2026-09-08 - - gpt-image-1 - - gpt-image-1-mini - - chatgpt-image-latest - - dall-e-2 - x-openai-enum-deprecations: - gpt-image-1: - message: GPT Image 1 was shut down on October 23, 2026. Use gpt-image-2.5-sunburst or gpt-image-2.5-flare instead. - dall-e-2: - message: This model was retired on May 12, 2026. Use a GPT image model instead. - x-stainless-const: true - x-oaiTypeLabel: string - example: gpt-image-1.5 - description: | - The GPT image model to use for image editing - (`gpt-image-1-mini`, `gpt-image-1.5`, `gpt-image-2`, - `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, - `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, - `gpt-image-2.5-flare-2026-09-08`, or `chatgpt-image-latest`). - - `gpt-image-1` was shut down on October 23, 2026. Use - `gpt-image-2.5-sunburst` or `gpt-image-2.5-flare` instead. - nullable: true - n: - type: - - integer - - 'null' - minimum: 1 - maximum: 10 - default: 1 - example: 1 - description: The number of images to generate. Must be between 1 and 10. - size: - anyOf: - - type: string - - type: string - enum: - - 1024x1024 - - 1536x1024 - - 1024x1536 - - auto - - 256x256 - - 512x512 - x-openai-enum-deprecations: - 256x256: - message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. - 512x512: - message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. - nullable: true - example: 1024x1024 - description: The size of the generated images. Defaults to `auto`. For `gpt-image-2`, `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, and `gpt-image-2.5-flare-2026-09-08`, arbitrary resolutions are supported as `WIDTHxHEIGHT` strings, for example `1536x864`. Width and height must both be divisible by 16 and the requested aspect ratio must be between 1:3 and 3:1. Resolutions above `2560x1440` are experimental, and the maximum supported resolution is `3840x2160`. The requested size must also satisfy the model's current pixel and edge limits. The standard sizes `1024x1024`, `1536x1024`, and `1024x1536` are supported by the GPT image models; `auto` is supported for models that allow automatic sizing. - response_format: - type: - - string - - 'null' - enum: - - url - - b64_json - - null - deprecated: true - description: Legacy response format parameter for retired image models. Unsupported for GPT image models, which always return base64-encoded images. - output_format: - anyOf: - - type: string - enum: - - png - - jpeg - - webp - - type: 'null' - default: png - example: png - description: | - The format in which the generated images are returned. This parameter is - only supported for the GPT image models. Must be one of `png`, `jpeg`, or `webp`. - The default value is `png`. - output_compression: - type: - - integer - - 'null' - default: 100 - example: 100 - description: | - The compression level (0-100%) for the generated images. This parameter - is only supported for the GPT image models with the `webp` or `jpeg` output - formats, and defaults to 100. - safety_identifier: - type: - - string - - 'null' - maxLength: 128 - example: user-1234 - description: A stable identifier for an end user in your organization, used for safety monitoring and abuse detection. Hash a username or email address to avoid sending identifying information. A non-empty value takes precedence over the deprecated `user` parameter. If omitted, null, or empty, the API uses `user` when available. [Learn more](https://developers.openai.com/api/docs/guides/safety-best-practices#implement-safety-identifiers). - user: - type: string - example: user-1234 - deprecated: true - description: 'Deprecated: use `safety_identifier` instead. This parameter remains accepted for backward compatibility and is used when `safety_identifier` is omitted, null, or empty. [Learn more](https://developers.openai.com/api/docs/guides/safety-best-practices#implement-safety-identifiers).' - input_fidelity: - anyOf: - - $ref: '#/components/schemas/InputFidelity' - - type: 'null' - stream: - type: - - boolean - - 'null' - default: false - example: false - description: | - Edit the image in streaming mode. Defaults to `false`. See the - [Image generation guide](https://developers.openai.com/api/docs/guides/image-generation) for more information. - partial_images: - $ref: '#/components/schemas/PartialImages' - quality: - anyOf: - - type: string - enum: - - low - - medium - - high - - xhigh - - max - - auto - - standard - x-openai-enum-deprecations: - standard: - message: This quality was only supported by retired DALL·E models. Use a GPT image quality instead. - - type: 'null' - default: auto - example: high - description: | - The quality of the image that will be generated for GPT image models. The GPT image models support `low`, `medium`, and `high`. `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their `2026-09-08` snapshots, also support `xhigh` and `max`. Defaults to `auto`. markup: $ref: '#/components/schemas/markup' - required: - - prompt - - image - CreateImageRequest: - type: object - properties: - prompt: - description: A text description of the desired image(s). The maximum length is 32000 characters. - type: string - example: A cute baby sea otter - model: - anyOf: - - type: string - - type: string - enum: - - gpt-image-1.5 - - gpt-image-2 - - gpt-image-2-2026-04-21 - - gpt-image-2.5-sunburst - - gpt-image-2.5-sunburst-2026-09-08 - - gpt-image-2.5-flare - - gpt-image-2.5-flare-2026-09-08 - - gpt-image-1 - - gpt-image-1-mini - - chatgpt-image-latest - - dall-e-2 - - dall-e-3 - x-openai-enum-deprecations: - gpt-image-1: - message: GPT Image 1 was shut down on October 23, 2026. Use gpt-image-2.5-sunburst or gpt-image-2.5-flare instead. - dall-e-2: - message: This model was retired on May 12, 2026. Use a GPT image model instead. - dall-e-3: - message: This model was retired on May 12, 2026. Use a GPT image model instead. - x-stainless-nominal: false - x-oaiTypeLabel: string - example: gpt-image-2.5-flare - description: | - The GPT image model to use for image generation. Specify a model explicitly. - Supported models include `gpt-image-1-mini`, `gpt-image-1.5`, `gpt-image-2`, - `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, - `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, - `gpt-image-2.5-flare-2026-09-08`, and `chatgpt-image-latest`. - - `gpt-image-1` was shut down on October 23, 2026. Use - `gpt-image-2.5-sunburst` or `gpt-image-2.5-flare` instead. - nullable: true - n: - type: - - integer - - 'null' - minimum: 1 - maximum: 10 - default: 1 - example: 1 - description: The number of images to generate. Must be between 1 and 10. - quality: - anyOf: - - type: string - enum: - - low - - medium - - high - - xhigh - - max - - auto - - standard - - hd - x-openai-enum-deprecations: - standard: - message: This quality was only supported by retired DALL·E models. Use a GPT image quality instead. - hd: - message: This quality was only supported by retired DALL·E models. Use a GPT image quality instead. - - type: 'null' - default: auto - example: medium - description: | - The quality of the image that will be generated. - - - `auto` (default value) will automatically select the best quality for the given - model. - - `high`, `medium` and `low` are supported for the GPT image models. - - `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their `2026-09-08` - snapshots, also support `xhigh` and `max`. - response_format: - type: - - string - - 'null' - enum: - - url - - b64_json - - null - deprecated: true - description: Legacy response format parameter for retired image models. Unsupported for GPT image models, which always return base64-encoded images. - output_format: - anyOf: - - type: string - enum: - - png - - jpeg - - webp - - type: 'null' - default: png - example: png - description: The format in which the generated images are returned. This parameter is only supported for the GPT image models. Must be one of `png`, `jpeg`, or `webp`. - output_compression: - type: - - integer - - 'null' - default: 100 - example: 100 - description: The compression level (0-100%) for the generated images. This parameter is only supported for the GPT image models with the `webp` or `jpeg` output formats, and defaults to 100. - stream: - type: - - boolean - - 'null' - default: false - example: false - description: | - Generate the image in streaming mode. Defaults to `false`. See the - [Image generation guide](https://developers.openai.com/api/docs/guides/image-generation) for more information. - This parameter is only supported for the GPT image models. - partial_images: - $ref: '#/components/schemas/PartialImages' - size: - anyOf: - - type: string - - type: string - enum: - - auto - - 1024x1024 - - 1536x1024 - - 1024x1536 - - 256x256 - - 512x512 - - 1792x1024 - - 1024x1792 - x-openai-enum-deprecations: - 256x256: - message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. - 512x512: - message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. - 1792x1024: - message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. - 1024x1792: - message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. - nullable: true - example: 1024x1024 - description: The size of the generated images. For `gpt-image-2`, `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, and `gpt-image-2.5-flare-2026-09-08`, arbitrary resolutions are supported as `WIDTHxHEIGHT` strings, for example `1536x864`. Width and height must both be divisible by 16 and the requested aspect ratio must be between 1:3 and 3:1. Resolutions above `2560x1440` are experimental, and the maximum supported resolution is `3840x2160`. The requested size must also satisfy the model's current pixel and edge limits. The standard sizes `1024x1024`, `1536x1024`, and `1024x1536` are supported by the GPT image models; `auto` is supported for models that allow automatic sizing. - moderation: - anyOf: - - type: string - enum: - - low - - auto - - type: 'null' - default: auto - example: low - description: Control the content-moderation level for images generated by the GPT image models. Must be either `low` for less restrictive filtering or `auto` (default value). + background: + anyOf: + - type: string + enum: + - transparent + - opaque + - auto + - type: 'null' + default: auto + example: transparent + description: | + Allows to set transparency for the background of the generated image(s). Must + be one of `transparent`, `opaque`, or `auto` (default value). When `auto` is + used, the model will automatically determine the best background for the + image. + + `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their + `2026-09-08` snapshots, support `opaque` and `transparent` backgrounds. + Transparent backgrounds are available for supported GPT Image models. For + `gpt-image-2` and `gpt-image-2-2026-04-21`, this support is in preview. When + using `transparent`, set the output format to `png` or `webp`. + model: + anyOf: + - type: string + - type: string + enum: + - gpt-image-1.5 + - gpt-image-2 + - gpt-image-2-2026-04-21 + - gpt-image-2.5-sunburst + - gpt-image-2.5-sunburst-2026-09-08 + - gpt-image-2.5-flare + - gpt-image-2.5-flare-2026-09-08 + - gpt-image-1 + - gpt-image-1-mini + - chatgpt-image-latest + - dall-e-2 + x-openai-enum-deprecations: + gpt-image-1: + message: GPT Image 1 was shut down on October 23, 2026. Use gpt-image-2.5-sunburst or gpt-image-2.5-flare instead. + dall-e-2: + message: This model was retired on May 12, 2026. Use a GPT image model instead. + x-stainless-const: true + x-oaiTypeLabel: string + example: gpt-image-1.5 + description: | + The GPT image model to use for image editing + (`gpt-image-1-mini`, `gpt-image-1.5`, `gpt-image-2`, + `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, + `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, + `gpt-image-2.5-flare-2026-09-08`, or `chatgpt-image-latest`). + + `gpt-image-1` was shut down on October 23, 2026. Use + `gpt-image-2.5-sunburst` or `gpt-image-2.5-flare` instead. + nullable: true + n: + type: + - integer + - 'null' + minimum: 1 + maximum: 10 + default: 1 + example: 1 + description: The number of images to generate. Must be between 1 and 10. + size: + anyOf: + - type: string + - type: string + enum: + - 1024x1024 + - 1536x1024 + - 1024x1536 + - auto + - 256x256 + - 512x512 + x-openai-enum-deprecations: + 256x256: + message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. + 512x512: + message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. + nullable: true + example: 1024x1024 + description: The size of the generated images. Defaults to `auto`. For `gpt-image-2`, `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, and `gpt-image-2.5-flare-2026-09-08`, arbitrary resolutions are supported as `WIDTHxHEIGHT` strings, for example `1536x864`. Width and height must both be divisible by 16 and the requested aspect ratio must be between 1:3 and 3:1. Resolutions above `2560x1440` are experimental, and the maximum supported resolution is `3840x2160`. The requested size must also satisfy the model's current pixel and edge limits. The standard sizes `1024x1024`, `1536x1024`, and `1024x1536` are supported by the GPT image models; `auto` is supported for models that allow automatic sizing. + response_format: + type: + - string + - 'null' + enum: + - url + - b64_json + - null + deprecated: true + description: Legacy response format parameter for retired image models. Unsupported for GPT image models, which always return base64-encoded images. + output_format: + anyOf: + - type: string + enum: + - png + - jpeg + - webp + - type: 'null' + default: png + example: png + description: | + The format in which the generated images are returned. This parameter is + only supported for the GPT image models. Must be one of `png`, `jpeg`, or `webp`. + The default value is `png`. + output_compression: + type: + - integer + - 'null' + default: 100 + example: 100 + description: | + The compression level (0-100%) for the generated images. This parameter + is only supported for the GPT image models with the `webp` or `jpeg` output + formats, and defaults to 100. + safety_identifier: + type: + - string + - 'null' + maxLength: 128 + example: user-1234 + description: A stable identifier for an end user in your organization, used for safety monitoring and abuse detection. Hash a username or email address to avoid sending identifying information. A non-empty value takes precedence over the deprecated `user` parameter. If omitted, null, or empty, the API uses `user` when available. [Learn more](https://developers.openai.com/api/docs/guides/safety-best-practices#implement-safety-identifiers). + user: + type: string + example: user-1234 + deprecated: true + description: 'Deprecated: use `safety_identifier` instead. This parameter remains accepted for backward compatibility and is used when `safety_identifier` is omitted, null, or empty. [Learn more](https://developers.openai.com/api/docs/guides/safety-best-practices#implement-safety-identifiers).' + input_fidelity: + anyOf: + - $ref: '#/components/schemas/InputFidelity' + - type: 'null' + stream: + type: + - boolean + - 'null' + default: false + example: false + description: | + Edit the image in streaming mode. Defaults to `false`. See the + [Image generation guide](https://developers.openai.com/api/docs/guides/image-generation) for more information. + partial_images: + $ref: '#/components/schemas/PartialImages' + quality: + anyOf: + - type: string + enum: + - low + - medium + - high + - xhigh + - max + - auto + - standard + x-openai-enum-deprecations: + standard: + message: This quality was only supported by retired DALL·E models. Use a GPT image quality instead. + - type: 'null' + default: auto + example: high + description: | + The quality of the image that will be generated for GPT image models. The GPT image models support `low`, `medium`, and `high`. `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their `2026-09-08` snapshots, also support `xhigh` and `max`. Defaults to `auto`. + required: + - prompt + - image + CreateImageRequest: + type: object + properties: + prompt: + description: A text description of the desired image(s). The maximum length is 32000 characters. + type: string + example: A cute baby sea otter + model: + anyOf: + - type: string + - type: string + enum: + - gpt-image-1.5 + - gpt-image-2 + - gpt-image-2-2026-04-21 + - gpt-image-2.5-sunburst + - gpt-image-2.5-sunburst-2026-09-08 + - gpt-image-2.5-flare + - gpt-image-2.5-flare-2026-09-08 + - gpt-image-1 + - gpt-image-1-mini + - chatgpt-image-latest + - dall-e-2 + - dall-e-3 + x-openai-enum-deprecations: + gpt-image-1: + message: GPT Image 1 was shut down on October 23, 2026. Use gpt-image-2.5-sunburst or gpt-image-2.5-flare instead. + dall-e-2: + message: This model was retired on May 12, 2026. Use a GPT image model instead. + dall-e-3: + message: This model was retired on May 12, 2026. Use a GPT image model instead. + x-stainless-nominal: false + x-oaiTypeLabel: string + example: gpt-image-2.5-flare + description: | + The GPT image model to use for image generation. Specify a model explicitly. + Supported models include `gpt-image-1-mini`, `gpt-image-1.5`, `gpt-image-2`, + `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, + `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, + `gpt-image-2.5-flare-2026-09-08`, and `chatgpt-image-latest`. + + `gpt-image-1` was shut down on October 23, 2026. Use + `gpt-image-2.5-sunburst` or `gpt-image-2.5-flare` instead. + nullable: true + n: + type: + - integer + - 'null' + minimum: 1 + maximum: 10 + default: 1 + example: 1 + description: The number of images to generate. Must be between 1 and 10. + quality: + anyOf: + - type: string + enum: + - low + - medium + - high + - xhigh + - max + - auto + - standard + - hd + x-openai-enum-deprecations: + standard: + message: This quality was only supported by retired DALL·E models. Use a GPT image quality instead. + hd: + message: This quality was only supported by retired DALL·E models. Use a GPT image quality instead. + - type: 'null' + default: auto + example: medium + description: | + The quality of the image that will be generated. + + - `auto` (default value) will automatically select the best quality for the given + model. + - `high`, `medium` and `low` are supported for the GPT image models. + - `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their `2026-09-08` + snapshots, also support `xhigh` and `max`. + response_format: + type: + - string + - 'null' + enum: + - url + - b64_json + - null + deprecated: true + description: Legacy response format parameter for retired image models. Unsupported for GPT image models, which always return base64-encoded images. + output_format: + anyOf: + - type: string + enum: + - png + - jpeg + - webp + - type: 'null' + default: png + example: png + description: The format in which the generated images are returned. This parameter is only supported for the GPT image models. Must be one of `png`, `jpeg`, or `webp`. + output_compression: + type: + - integer + - 'null' + default: 100 + example: 100 + description: The compression level (0-100%) for the generated images. This parameter is only supported for the GPT image models with the `webp` or `jpeg` output formats, and defaults to 100. + stream: + type: + - boolean + - 'null' + default: false + example: false + description: | + Generate the image in streaming mode. Defaults to `false`. See the + [Image generation guide](https://developers.openai.com/api/docs/guides/image-generation) for more information. + This parameter is only supported for the GPT image models. + partial_images: + $ref: '#/components/schemas/PartialImages' + size: + anyOf: + - type: string + - type: string + enum: + - auto + - 1024x1024 + - 1536x1024 + - 1024x1536 + - 256x256 + - 512x512 + - 1792x1024 + - 1024x1792 + x-openai-enum-deprecations: + 256x256: + message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. + 512x512: + message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. + 1792x1024: + message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. + 1024x1792: + message: This legacy size suggestion is retained for SDK compatibility. Check the selected GPT image model's supported sizes. + nullable: true + example: 1024x1024 + description: The size of the generated images. For `gpt-image-2`, `gpt-image-2-2026-04-21`, `gpt-image-2.5-sunburst`, `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, and `gpt-image-2.5-flare-2026-09-08`, arbitrary resolutions are supported as `WIDTHxHEIGHT` strings, for example `1536x864`. Width and height must both be divisible by 16 and the requested aspect ratio must be between 1:3 and 3:1. Resolutions above `2560x1440` are experimental, and the maximum supported resolution is `3840x2160`. The requested size must also satisfy the model's current pixel and edge limits. The standard sizes `1024x1024`, `1536x1024`, and `1024x1536` are supported by the GPT image models; `auto` is supported for models that allow automatic sizing. + moderation: + anyOf: + - type: string + enum: + - low + - auto + - type: 'null' + default: auto + example: low + description: Control the content-moderation level for images generated by the GPT image models. Must be either `low` for less restrictive filtering or `auto` (default value). background: anyOf: - type: string @@ -45807,7 +45852,6 @@ components: - gpt-4o-mini-transcribe - gpt-4o-mini-transcribe-2025-12-15 - gpt-4o-transcribe-diarize - x-stainless-const: true x-openai-enum-deprecations: gpt-4o-mini-transcribe-2025-12-15: message: 'Announced shutdown date: 2027-02-26. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' @@ -45819,6 +45863,7 @@ components: message: 'Announced shutdown date: 2027-02-26. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-4o-transcribe-diarize: message: 'Announced shutdown date: 2027-02-26. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + x-stainless-const: true x-stainless-nominal: false x-oaiTypeLabel: string language: @@ -46873,6 +46918,9 @@ components: $ref: '#/components/schemas/ImageRefParam' mask: $ref: '#/components/schemas/ImageRefParam' + markup: + $ref: '#/components/schemas/ImageRefParam' + description: A transparent PNG overlaid on the first image, referenced by file ID, HTTPS URL, or base64 data URL. Must match that image's dimensions after orientation and contain transparent pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. prompt: type: string minLength: 1 @@ -46987,9 +47035,6 @@ components: description: Stream partial image results as events. partial_images: $ref: '#/components/schemas/PartialImages' - markup: - $ref: '#/components/schemas/ImageRefParam' - description: A transparent PNG overlaid on the first image, referenced by file ID, HTTPS URL, or base64 data URL. Must match that image's dimensions after orientation and contain transparent pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. required: - images - prompt @@ -53131,11 +53176,10 @@ components: x-openai-go-public-shape: variant_names: '#/components/schemas/AssistantToolsFileSearchTypeOnly': FileSearchTool + '#/components/schemas/MessageObject/properties/attachments/anyOf/0/items/properties/tools/items/anyOf/1': FileSearchTool anyOf: - $ref: '#/components/schemas/AssistantToolsCode' - $ref: '#/components/schemas/AssistantToolsFileSearchTypeOnly' - x-openai-go-public-shape: - type_name: MessageAttachmentToolFileSearchTool description: A list of files attached to the message, and the tools they were added to. - type: 'null' metadata: @@ -58347,7 +58391,7 @@ components: example: | // Trigger a response with the default Conversation and no special parameters { - "type": "response.create", + "type": "response.create" } // Trigger an out-of-band response that does not write to the default Conversation @@ -58505,6 +58549,27 @@ components: ] } } + RealtimeConnectDocumentationParams: + type: object + properties: + model: + type: string + call_id: + type: string + intent: + type: string + enum: + - realtime + - transcription + description: | + Selects the session type. Set `transcription` and omit `model` for a transcription session. If omitted, the session type from a client secret applies when present; otherwise the intent is `realtime`. + RealtimeConnectParams: + type: object + properties: + model: + type: string + call_id: + type: string RealtimeConversationItem: description: A single item within a Realtime conversation. anyOf: @@ -59488,457 +59553,1062 @@ components: using their id. items: $ref: '#/components/schemas/RealtimeConversationItem' - RealtimeServerEvent: - discriminator: - propertyName: type - description: | - A realtime server event. - anyOf: - - $ref: '#/components/schemas/RealtimeServerEventConversationCreated' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemCreated' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemDeleted' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionCompleted' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionDelta' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionFailed' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemRetrieved' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemTruncated' - - $ref: '#/components/schemas/RealtimeServerEventError' - - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferCleared' - - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferCommitted' - - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferDtmfEventReceived' - - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferSpeechStarted' - - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferSpeechStopped' - - $ref: '#/components/schemas/RealtimeServerEventRateLimitsUpdated' - - $ref: '#/components/schemas/RealtimeServerEventResponseAudioDelta' - - $ref: '#/components/schemas/RealtimeServerEventResponseAudioDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseAudioTranscriptDelta' - - $ref: '#/components/schemas/RealtimeServerEventResponseAudioTranscriptDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseContentPartAdded' - - $ref: '#/components/schemas/RealtimeServerEventResponseContentPartDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseCreated' - - $ref: '#/components/schemas/RealtimeServerEventResponseDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseFunctionCallArgumentsDelta' - - $ref: '#/components/schemas/RealtimeServerEventResponseFunctionCallArgumentsDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseOutputItemAdded' - - $ref: '#/components/schemas/RealtimeServerEventResponseOutputItemDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseTextDelta' - - $ref: '#/components/schemas/RealtimeServerEventResponseTextDone' - - $ref: '#/components/schemas/RealtimeServerEventSessionCreated' - - $ref: '#/components/schemas/RealtimeServerEventSessionUpdated' - - $ref: '#/components/schemas/RealtimeServerEventOutputAudioBufferStarted' - - $ref: '#/components/schemas/RealtimeServerEventOutputAudioBufferStopped' - - $ref: '#/components/schemas/RealtimeServerEventOutputAudioBufferCleared' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemAdded' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemDone' - - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferTimeoutTriggered' - - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionSegment' - - $ref: '#/components/schemas/RealtimeServerEventMCPListToolsInProgress' - - $ref: '#/components/schemas/RealtimeServerEventMCPListToolsCompleted' - - $ref: '#/components/schemas/RealtimeServerEventMCPListToolsFailed' - - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallArgumentsDelta' - - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallArgumentsDone' - - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallInProgress' - - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallCompleted' - - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallFailed' - RealtimeServerEventConversationCreated: + RunStatus: + description: The status of the run, which can be either `queued`, `in_progress`, `requires_action`, `cancelling`, `cancelled`, `failed`, `completed`, `incomplete`, or `expired`. + type: string + enum: + - queued + - in_progress + - requires_action + - cancelling + - cancelled + - failed + - completed + - incomplete + - expired + LiveMCPToolChoiceParam: type: object - description: | - Returned when a conversation is created. Emitted right after session creation. properties: - event_id: - type: string - description: The unique ID of the server event. type: + default: mcp x-stainless-const: true - description: The event type, must be `conversation.created`. - const: conversation.created - conversation: - type: object - description: The conversation resource. - properties: - id: - type: string - description: The unique ID of the conversation. - object: - description: The object type, must be `realtime.conversation`. - const: realtime.conversation + type: string + enum: + - mcp + server_label: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[a-zA-Z0-9_-]+$ + name: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[a-zA-Z0-9_-]+$ required: - - event_id - type - - conversation - x-oaiMeta: - name: conversation.created - group: realtime - example: | - { - "event_id": "event_9101", - "type": "conversation.created", - "conversation": { - "id": "conv_001", - "object": "realtime.conversation" - } - } - RealtimeServerEventConversationItemAdded: + - server_label + - name + LiveFunctionToolChoiceParam: type: object - description: | - Sent by the server when an Item is added to the default Conversation. This can happen in several cases: - - When the client sends a `conversation.item.create` event. - - When the input audio buffer is committed. In this case the item will be a user message containing the audio from the buffer. - - When the model is generating a Response. In this case the `conversation.item.added` event will be sent when the model starts generating a specific Item, and thus it will not yet have any content (and `status` will be `in_progress`). - - The event will include the full content of the Item (except when model is generating a Response) except for audio data, which can be retrieved separately with a `conversation.item.retrieve` event if necessary. properties: - event_id: - type: string - description: The unique ID of the server event. type: + default: function x-stainless-const: true - description: The event type, must be `conversation.item.added`. - const: conversation.item.added - previous_item_id: - anyOf: - - type: string - description: | - The ID of the item that precedes this one, if any. This is used to - maintain ordering when items are inserted. - - type: 'null' - item: - $ref: '#/components/schemas/RealtimeConversationItem' + type: string + enum: + - function + name: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[a-zA-Z0-9_-]+$ required: - - event_id - type - - item - x-oaiMeta: - name: conversation.item.added - group: realtime - example: | - { - "type": "conversation.item.added", - "event_id": "event_C9G8pjSJCfRNEhMEnYAVy", - "previous_item_id": null, - "item": { - "id": "item_C9G8pGVKYnaZu8PH5YQ9O", - "type": "message", - "status": "completed", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hi" - } - ] - } - } - RealtimeServerEventConversationItemCreated: + - name + SubmitToolOutputsRunRequestWithoutStream: type: object - description: | - Returned when a conversation item is created. There are several scenarios that produce this event: - - The server is generating a Response, which if successful will produce - either one or two Items, which will be of type `message` - (role `assistant`) or type `function_call`. - - The input audio buffer has been committed, either by the client or the - server (in `server_vad` mode). The server will take the content of the - input audio buffer and add it to a new user message Item. - - The client has sent a `conversation.item.create` event to add a new Item - to the Conversation. + additionalProperties: false properties: - event_id: - type: string - description: The unique ID of the server event. - type: - x-stainless-const: true - description: The event type, must be `conversation.item.created`. - const: conversation.item.created - previous_item_id: - anyOf: - - type: string - description: | - The ID of the preceding item in the Conversation context, allows the - client to understand the order of the conversation. Can be `null` if the - item has no predecessor. - - type: 'null' - item: - $ref: '#/components/schemas/RealtimeConversationItem' + tool_outputs: + description: A list of tools for which the outputs are being submitted. + type: array + items: + type: object + properties: + tool_call_id: + type: string + description: The ID of the tool call in the `required_action` object within the run object the output is being submitted for. + output: + type: string + description: The output of the tool call to be submitted to continue the run. required: - - event_id - - type - - item - x-oaiMeta: - name: conversation.item.created - group: realtime - example: | - { - "event_id": "event_1920", - "type": "conversation.item.created", - "previous_item_id": "msg_002", - "item": { - "id": "msg_003", - "object": "realtime.item", - "type": "message", - "status": "completed", - "role": "user", - "content": [] - } - } - RealtimeServerEventConversationItemDeleted: + - tool_outputs + RealtimeClientEventResponseCreateWithReferencesParam: type: object - description: "Returned when an item in the conversation is deleted by the client with a \n`conversation.item.delete` event. This event is used to synchronize the \nserver's understanding of the conversation history with the client's view.\n" + description: "This event instructs the server to create a Response, which means triggering \nmodel inference. When in Server VAD mode, the server will create Responses \nautomatically.\n\nA Response will include at least one Item, and may have two, in which case \nthe second will be a function call. These Items will be appended to the \nconversation history by default.\n\nThe server will respond with a `response.created` event, events for Items \nand content created, and finally a `response.done` event to indicate the \nResponse is complete.\n\nThe `response.create` event includes inference configuration like \n`instructions` and `tools`. If these are set, they will override the Session's \nconfiguration for this Response only.\n\nResponses can be created out-of-band of the default Conversation, meaning that they can\nhave arbitrary input, and it's possible to disable writing the output to the Conversation.\nOnly one Response can write to the default Conversation at a time, but otherwise multiple\nResponses can be created in parallel. The `metadata` field is a good way to disambiguate\nmultiple simultaneous Responses.\n\nClients can set `conversation` to `none` to create a Response that does not write to the default\nConversation. Arbitrary input can be provided with the `input` field, which is an array accepting\nraw Items and references to existing Items.\n" properties: event_id: type: string - description: The unique ID of the server event. + maxLength: 512 + description: Optional client-generated ID used to identify this event. type: x-stainless-const: true - description: The event type, must be `conversation.item.deleted`. - const: conversation.item.deleted - item_id: - type: string - description: The ID of the item that was deleted. + description: The event type, must be `response.create`. + const: response.create + response: + $ref: '#/components/schemas/RealtimeResponseCreateParams' required: - - event_id - type - - item_id x-oaiMeta: - name: conversation.item.deleted + name: response.create group: realtime example: | + // Trigger a response with the default Conversation and no special parameters { - "event_id": "event_2728", - "type": "conversation.item.deleted", - "item_id": "msg_005" + "type": "response.create" } - RealtimeServerEventConversationItemDone: - type: object - description: | - Returned when a conversation item is finalized. - The event will include the full content of the Item except for audio data, which can be retrieved separately with a `conversation.item.retrieve` event if needed. - properties: - event_id: - type: string - description: The unique ID of the server event. - type: - x-stainless-const: true - description: The event type, must be `conversation.item.done`. - const: conversation.item.done - previous_item_id: - anyOf: - - type: string - description: | - The ID of the item that precedes this one, if any. This is used to - maintain ordering when items are inserted. - - type: 'null' - item: - $ref: '#/components/schemas/RealtimeConversationItem' - required: - - event_id - - type - - item - x-oaiMeta: - name: conversation.item.done - group: realtime - example: | + // Trigger an out-of-band response that does not write to the default Conversation { - "type": "conversation.item.done", - "event_id": "event_CCXLgMZPo3qioWCeQa4WH", - "previous_item_id": "item_CCXLecNJVIVR2HUy3ABLj", - "item": { - "id": "item_CCXLfxmM5sXVJVz4mCa2S", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ + "type": "response.create", + "response": { + "instructions": "Provide a concise answer.", + "tools": [], // clear any session tools + "conversation": "none", + "output_modalities": ["text"], + "metadata": { + "response_purpose": "summarization" + }, + "input": [ { - "type": "output_audio", - "transcript": "Oh, I can hear you loud and clear! Sounds like we're connected just fine. What can I help you with today?" + "type": "item_reference", + "id": "item_12345" + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Summarize the above message in one sentence." + } + ] } ] } } - RealtimeServerEventConversationItemInputAudioTranscriptionCompleted: + RealtimeResponseCreateParamsWithReferencesParam: type: object - description: | - This event is the output of audio transcription for user audio written to the - user audio buffer. Transcription begins when the input audio buffer is - committed by the client or server (when VAD is enabled). Transcription runs - asynchronously with Response creation, so this event may come before or after - the Response events. - - Realtime API models accept audio natively, and thus input transcription is a - separate process run on a separate ASR (Automatic Speech Recognition) model. - The transcript may diverge somewhat from the model's interpretation, and - should be treated as a rough guide. + description: Create a new Realtime response with these parameters properties: - event_id: - type: string - description: The unique ID of the server event. - type: - type: string - enum: - - conversation.item.input_audio_transcription.completed + output_modalities: + type: array description: | - The event type, must be - `conversation.item.input_audio_transcription.completed`. - x-stainless-const: true - item_id: - type: string - description: The ID of the item containing the audio that is being transcribed. - content_index: - type: integer - description: The index of the content part containing the audio. - transcript: + The set of modalities the model used to respond, currently the only possible values are + `[\"audio\"]`, `[\"text\"]`. Audio output always include a text transcript. Setting the + output to mode `text` will disable audio output from the model. + items: + type: string + enum: + - text + - audio + instructions: type: string - description: The transcribed text. - languages: - type: array description: | - The languages detected in the audio. Returned by `gpt-transcribe`. An empty array indicates that no language could be reliably detected. + The default system instructions (i.e. system message) prepended to model calls. This field allows the client to guide the model on desired responses. The model can be instructed on response content and format, (e.g. "be extremely succinct", "act friendly", "here are examples of good responses") and on audio behavior (e.g. "talk quickly", "inject emotion into your voice", "laugh frequently"). The instructions are not guaranteed to be followed by the model, but they provide guidance to the model on the desired behavior. + Note that the server sets default instructions which will be used if this field is not set and are visible in the `session.created` event at the start of the session. + audio: + type: object + description: Configuration for audio input and output. + properties: + output: + type: object + properties: + format: + $ref: '#/components/schemas/RealtimeAudioFormats' + description: The format of the output audio. + voice: + $ref: '#/components/schemas/VoiceIdsOrCustomVoice' + default: alloy + description: | + The voice the model uses to respond. Supported built-in voices are + `alloy`, `ash`, `ballad`, `coral`, `echo`, `sage`, `shimmer`, `verse`, + `marin`, and `cedar`. You may also provide a custom voice object with + an `id`, for example `{ "id": "voice_1234" }`. Voice cannot be changed + during the session once the model has responded with audio at least once. + Custom voices must be created from audio samples. Voices created from text prompts are supported only in Live. + We recommend `marin` and `cedar` for best quality. + tools: + type: array + description: Tools available to the model. items: - $ref: '#/components/schemas/TranscriptionLanguage' - logprobs: + anyOf: + - $ref: '#/components/schemas/RealtimeFunctionTool' + - $ref: '#/components/schemas/MCPTool' + tool_choice: + description: | + How the model chooses tools. Provide one of the string modes or force a specific + function/MCP tool. + default: auto anyOf: - - type: array - description: The log probabilities of the transcription. - items: - $ref: '#/components/schemas/LogProbProperties' - - type: 'null' - usage: - type: object - description: Usage statistics for the transcription, this is billed according to the ASR model's pricing rather than the realtime model's pricing. + - $ref: '#/components/schemas/ToolChoiceOptions' + - $ref: '#/components/schemas/ToolChoiceFunction' + - $ref: '#/components/schemas/ToolChoiceMCP' + parallel_tool_calls: + type: boolean + description: | + Whether the model may call multiple tools in parallel. Only supported by + reasoning Realtime models such as `gpt-realtime-2`. + reasoning: + $ref: '#/components/schemas/RealtimeReasoning' + max_output_tokens: + description: | + Maximum number of output tokens for a single assistant response, + inclusive of tool calls. Provide an integer between 1 and 4096 to + limit output tokens, or `inf` for the maximum available tokens for a + given model. Defaults to `inf`. anyOf: - - $ref: '#/components/schemas/TranscriptTextUsageTokens' - title: TranscriptTextUsageTokens - - $ref: '#/components/schemas/TranscriptTextUsageDuration' - title: TranscriptTextUsageDuration + - type: integer + - type: string + enum: + - inf + x-stainless-const: true + conversation: + description: | + Controls which conversation the response is added to. Currently supports + `auto` and `none`, with `auto` as the default value. The `auto` value + means that the contents of the response will be added to the default + conversation. Set this to `none` to create an out-of-band response which + will not add items to default conversation. + anyOf: + - type: string + - type: string + default: auto + enum: + - auto + - none + metadata: + $ref: '#/components/schemas/Metadata' + prompt: + $ref: '#/components/schemas/Prompt' + input: + type: array + description: | + Input items to include in the prompt for the model. Using this field + creates a new context for this Response instead of using the default + conversation. An empty array `[]` will clear the context for this Response. + Note that this can include references to items that previously appeared in the session + using their id. + items: + $ref: '#/components/schemas/RealtimeConversationItem' + Summary: + properties: + type: + type: string + enum: + - summary_text + description: The type of the object. Always `summary_text`. + default: summary_text + x-stainless-const: true + text: + type: string + description: A summary of the reasoning output from the model so far. + type: object required: - - event_id - type - - item_id - - content_index - - transcript - - usage - x-oaiMeta: - name: conversation.item.input_audio_transcription.completed - group: realtime - example: | - { - "type": "conversation.item.input_audio_transcription.completed", - "event_id": "event_CCXGRvtUVrax5SJAnNOWZ", - "item_id": "item_CCXGQ4e1ht4cOraEYcuR2", - "content_index": 0, - "transcript": "Hey, can you hear me?", - "usage": { - "type": "tokens", - "total_tokens": 22, - "input_tokens": 13, - "input_token_details": { - "text_tokens": 0, - "audio_tokens": 13 - }, - "output_tokens": 9 - } - } - RealtimeServerEventConversationItemInputAudioTranscriptionDelta: + - text + title: Summary text + description: A summary text from the model. + LiveDecisionStoredItemValidation: + discriminator: + propertyName: type + x-oai-default-discriminator-value: message + anyOf: + - discriminator: + propertyName: role + properties: + type: + type: string + enum: + - message + anyOf: + - $ref: '#/components/schemas/LiveDecisionStoredUserMessageParam' + - $ref: '#/components/schemas/LiveDecisionStoredDeveloperMessageParam' + - $ref: '#/components/schemas/LiveDecisionStoredSystemMessageParam' + - $ref: '#/components/schemas/LiveDecisionStoredAssistantMessageParam' + - $ref: '#/components/schemas/LiveDecisionStoredFunctionCallParam' + - $ref: '#/components/schemas/LiveDecisionStoredFunctionCallOutputParam' + - $ref: '#/components/schemas/LiveDecisionStoredCustomToolCallParam' + - $ref: '#/components/schemas/LiveDecisionStoredCustomToolCallOutputParam' + LiveDecisionInputItemValidation: + discriminator: + propertyName: type + x-oai-default-discriminator-value: message + anyOf: + - discriminator: + propertyName: role + properties: + type: + type: string + enum: + - message + anyOf: + - $ref: '#/components/schemas/LiveDecisionUserMessageParam' + - $ref: '#/components/schemas/LiveDecisionDeveloperMessageParam' + - $ref: '#/components/schemas/LiveDecisionSystemMessageParam' + - $ref: '#/components/schemas/LiveDecisionAssistantMessageParam' + - $ref: '#/components/schemas/LiveDecisionFunctionCallParam' + - $ref: '#/components/schemas/LiveDecisionFunctionCallOutputParam' + - $ref: '#/components/schemas/LiveDecisionCustomToolCallParam' + - $ref: '#/components/schemas/LiveDecisionCustomToolCallOutputParam' + CreateRunRequestWithoutStream: type: object - description: | - Returned when the text value of an input audio transcription content part is updated with incremental transcription results. + additionalProperties: false properties: - event_id: + assistant_id: + description: The ID of the [assistant](https://developers.openai.com/api/docs/assistants/migration) to use to execute this run. type: string - description: The unique ID of the server event. - type: - x-stainless-const: true - description: The event type, must be `conversation.item.input_audio_transcription.delta`. - const: conversation.item.input_audio_transcription.delta - item_id: + model: + description: The ID of the [Model](https://developers.openai.com/api/reference/resources/models) to be used to execute this run. If a value is provided here, it will override the model associated with the assistant. If not, the model associated with the assistant will be used. + example: gpt-4.1 + anyOf: + - type: string + - $ref: '#/components/schemas/AssistantSupportedModels' + x-oaiTypeLabel: string + nullable: true + reasoning_effort: + $ref: '#/components/schemas/ReasoningEffort' + instructions: + description: Overrides the [instructions](https://developers.openai.com/api/docs/assistants/migration) of the assistant. This is useful for modifying the behavior on a per-run basis. + type: + - string + - 'null' + additional_instructions: + description: Appends additional instructions at the end of the instructions for the run. This is useful for modifying the behavior on a per-run basis without overriding other instructions. + type: + - string + - 'null' + additional_messages: + description: Adds additional messages to the thread before creating the run. + type: + - array + - 'null' + items: + $ref: '#/components/schemas/CreateMessageRequest' + tools: + description: Override the tools the assistant can use for this run. This is useful for modifying the behavior on a per-run basis. + type: + - array + - 'null' + maxItems: 20 + items: + $ref: '#/components/schemas/AssistantTool' + metadata: + $ref: '#/components/schemas/Metadata' + temperature: + type: + - number + - 'null' + minimum: 0 + maximum: 2 + default: 1 + example: 1 + description: | + What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + top_p: + type: + - number + - 'null' + minimum: 0 + maximum: 1 + default: 1 + example: 1 + description: | + An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. + + We generally recommend altering this or temperature but not both. + response_format: + $ref: '#/components/schemas/AssistantsApiResponseFormatOption' + nullable: true + max_prompt_tokens: + type: + - integer + - 'null' + description: | + The maximum number of prompt tokens that may be used over the course of the run. The run will make a best effort to use only the number of prompt tokens specified, across multiple turns of the run. If the run exceeds the number of prompt tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. + minimum: 256 + max_completion_tokens: + type: + - integer + - 'null' + description: | + The maximum number of completion tokens that may be used over the course of the run. The run will make a best effort to use only the number of completion tokens specified, across multiple turns of the run. If the run exceeds the number of completion tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. + minimum: 256 + truncation_strategy: + allOf: + - $ref: '#/components/schemas/TruncationObject' + - nullable: true + tool_choice: + allOf: + - $ref: '#/components/schemas/AssistantsApiToolChoiceOption' + - nullable: true + parallel_tool_calls: + $ref: '#/components/schemas/ParallelToolCalls' + required: + - assistant_id + CreateThreadAndRunRequestWithoutStream: + type: object + additionalProperties: false + properties: + assistant_id: + description: The ID of the [assistant](https://developers.openai.com/api/docs/assistants/migration) to use to execute this run. type: string - description: The ID of the item containing the audio that is being transcribed. - content_index: - type: integer - description: The index of the content part in the item's content array. - delta: + thread: + $ref: '#/components/schemas/CreateThreadRequest' + model: + description: The ID of the [Model](https://developers.openai.com/api/reference/resources/models) to be used to execute this run. If a value is provided here, it will override the model associated with the assistant. If not, the model associated with the assistant will be used. + example: gpt-4.1 + anyOf: + - type: string + - type: string + enum: + - gpt-5 + - gpt-5-mini + - gpt-5-nano + - gpt-5-2025-08-07 + - gpt-5-mini-2025-08-07 + - gpt-5-nano-2025-08-07 + - gpt-4.1 + - gpt-4.1-mini + - gpt-4.1-nano + - gpt-4.1-2025-04-14 + - gpt-4.1-mini-2025-04-14 + - gpt-4.1-nano-2025-04-14 + - gpt-4o + - gpt-4o-2024-11-20 + - gpt-4o-2024-08-06 + - gpt-4o-2024-05-13 + - gpt-4o-mini + - gpt-4o-mini-2024-07-18 + - gpt-4.5-preview + - gpt-4.5-preview-2025-02-27 + - gpt-4-turbo + - gpt-4-turbo-2024-04-09 + - gpt-4-0125-preview + - gpt-4-turbo-preview + - gpt-4-1106-preview + - gpt-4-vision-preview + - gpt-4 + - gpt-4-0314 + - gpt-4-0613 + - gpt-4-32k + - gpt-4-32k-0314 + - gpt-4-32k-0613 + - gpt-3.5-turbo + - gpt-3.5-turbo-16k + - gpt-3.5-turbo-0613 + - gpt-3.5-turbo-1106 + - gpt-3.5-turbo-0125 + - gpt-3.5-turbo-16k-0613 + x-openai-enum-deprecations: + gpt-5: + message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5-mini: + message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5-nano: + message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5-2025-08-07: + message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5-mini-2025-08-07: + message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5-nano-2025-08-07: + message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + x-oaiTypeLabel: string + nullable: true + instructions: + description: Override the default system message of the assistant. This is useful for modifying the behavior on a per-run basis. + type: + - string + - 'null' + tools: + description: Override the tools the assistant can use for this run. This is useful for modifying the behavior on a per-run basis. + type: + - array + - 'null' + maxItems: 20 + items: + $ref: '#/components/schemas/AssistantTool' + tool_resources: + type: + - object + - 'null' + description: | + A set of resources that are used by the assistant's tools. The resources are specific to the type of tool. For example, the `code_interpreter` tool requires a list of file IDs, while the `file_search` tool requires a list of vector store IDs. + properties: + code_interpreter: + type: object + properties: + file_ids: + type: array + description: | + A list of [file](https://developers.openai.com/api/reference/resources/files) IDs made available to the `code_interpreter` tool. There can be a maximum of 20 files associated with the tool. + default: [] + maxItems: 20 + items: + type: string + file_search: + type: object + properties: + vector_store_ids: + type: array + description: | + The ID of the [vector store](https://developers.openai.com/api/reference/resources/vector_stores) attached to this assistant. There can be a maximum of 1 vector store attached to the assistant. + maxItems: 1 + items: + type: string + metadata: + $ref: '#/components/schemas/Metadata' + temperature: + type: + - number + - 'null' + minimum: 0 + maximum: 2 + default: 1 + example: 1 + description: | + What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + top_p: + type: + - number + - 'null' + minimum: 0 + maximum: 1 + default: 1 + example: 1 + description: | + An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. + + We generally recommend altering this or temperature but not both. + response_format: + $ref: '#/components/schemas/AssistantsApiResponseFormatOption' + nullable: true + max_prompt_tokens: + type: + - integer + - 'null' + description: | + The maximum number of prompt tokens that may be used over the course of the run. The run will make a best effort to use only the number of prompt tokens specified, across multiple turns of the run. If the run exceeds the number of prompt tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. + minimum: 256 + max_completion_tokens: + type: + - integer + - 'null' + description: | + The maximum number of completion tokens that may be used over the course of the run. The run will make a best effort to use only the number of completion tokens specified, across multiple turns of the run. If the run exceeds the number of completion tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. + minimum: 256 + truncation_strategy: + allOf: + - $ref: '#/components/schemas/TruncationObject' + - nullable: true + tool_choice: + allOf: + - $ref: '#/components/schemas/AssistantsApiToolChoiceOption' + - nullable: true + parallel_tool_calls: + $ref: '#/components/schemas/ParallelToolCalls' + required: + - assistant_id + CodeInterpreterContainerAuto: + properties: + type: type: string - description: The text delta. - logprobs: + enum: + - auto + description: Always `auto`. + default: auto + x-stainless-const: true + file_ids: + items: + type: string + example: file-123 + type: array + maxItems: 50 + description: An optional list of uploaded files to make available to your code. + memory_limit: anyOf: - - type: array - description: 'The log probabilities of the transcription. These can be enabled by configuring the session with `"include": ["item.input_audio_transcription.logprobs"]`. Each entry in the array corresponds a log probability of which token would be selected for this chunk of transcription. This can help to identify if it was possible there were multiple valid options for a given chunk of transcription.' - items: - $ref: '#/components/schemas/LogProbProperties' + - $ref: '#/components/schemas/ContainerMemoryLimit' + description: The memory limit for the code interpreter container. - type: 'null' + network_policy: + discriminator: + propertyName: type + description: Network access policy for the container. + anyOf: + - $ref: '#/components/schemas/ContainerNetworkPolicyDisabledParam' + - $ref: '#/components/schemas/ContainerNetworkPolicyAllowlistParam' + type: object required: - - event_id - type - - item_id - x-oaiMeta: - name: conversation.item.input_audio_transcription.delta - group: realtime - example: | - { - "type": "conversation.item.input_audio_transcription.delta", - "event_id": "event_CCXGRxsAimPAs8kS2Wc7Z", - "item_id": "item_CCXGQ4e1ht4cOraEYcuR2", - "content_index": 0, - "delta": "Hey", - "obfuscation": "aLxx0jTEciOGe" - } - RealtimeServerEventConversationItemInputAudioTranscriptionFailed: + title: CodeInterpreterToolAuto + description: Configuration for a code interpreter container. Optionally specify the IDs of the files to run the code on. + x-stainless-naming: + go: + type_name: ToolCodeInterpreterContainerCodeInterpreterContainerAuto + RealtimeResponseItemReference: type: object - description: "Returned when input audio transcription is configured, and a transcription \nrequest for a user message failed. These events are separate from other \n`error` events so that the client can identify the related Item.\n" + description: | + Reference an existing item in this Realtime session as response.create input. + Item references are only accepted in the response input array, not as new + conversation items. properties: - event_id: - type: string - description: The unique ID of the server event. type: type: string enum: - - conversation.item.input_audio_transcription.failed - description: | - The event type, must be - `conversation.item.input_audio_transcription.failed`. + - item_reference x-stainless-const: true - item_id: + description: The type of this item. Always `item_reference`. + id: type: string - description: The ID of the user message item. - content_index: - type: integer - description: The index of the content part containing the audio. - error: + minLength: 1 + maxLength: 32 + pattern: ^[a-zA-Z0-9_-]+$ + description: The ID of an existing item in the same Realtime session. + required: + - type + - id + RealtimeServerEvent: + discriminator: + propertyName: type + description: | + A realtime server event. + anyOf: + - $ref: '#/components/schemas/RealtimeServerEventConversationCreated' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemCreated' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemDeleted' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionCompleted' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionDelta' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionFailed' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemRetrieved' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemTruncated' + - $ref: '#/components/schemas/RealtimeServerEventError' + - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferCleared' + - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferCommitted' + - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferDtmfEventReceived' + - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferSpeechStarted' + - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferSpeechStopped' + - $ref: '#/components/schemas/RealtimeServerEventRateLimitsUpdated' + - $ref: '#/components/schemas/RealtimeServerEventResponseAudioDelta' + - $ref: '#/components/schemas/RealtimeServerEventResponseAudioDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseAudioTranscriptDelta' + - $ref: '#/components/schemas/RealtimeServerEventResponseAudioTranscriptDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseContentPartAdded' + - $ref: '#/components/schemas/RealtimeServerEventResponseContentPartDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseCreated' + - $ref: '#/components/schemas/RealtimeServerEventResponseDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseFunctionCallArgumentsDelta' + - $ref: '#/components/schemas/RealtimeServerEventResponseFunctionCallArgumentsDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseOutputItemAdded' + - $ref: '#/components/schemas/RealtimeServerEventResponseOutputItemDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseTextDelta' + - $ref: '#/components/schemas/RealtimeServerEventResponseTextDone' + - $ref: '#/components/schemas/RealtimeServerEventSessionCreated' + - $ref: '#/components/schemas/RealtimeServerEventSessionUpdated' + - $ref: '#/components/schemas/RealtimeServerEventOutputAudioBufferStarted' + - $ref: '#/components/schemas/RealtimeServerEventOutputAudioBufferStopped' + - $ref: '#/components/schemas/RealtimeServerEventOutputAudioBufferCleared' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemAdded' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemDone' + - $ref: '#/components/schemas/RealtimeServerEventInputAudioBufferTimeoutTriggered' + - $ref: '#/components/schemas/RealtimeServerEventConversationItemInputAudioTranscriptionSegment' + - $ref: '#/components/schemas/RealtimeServerEventMCPListToolsInProgress' + - $ref: '#/components/schemas/RealtimeServerEventMCPListToolsCompleted' + - $ref: '#/components/schemas/RealtimeServerEventMCPListToolsFailed' + - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallArgumentsDelta' + - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallArgumentsDone' + - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallInProgress' + - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallCompleted' + - $ref: '#/components/schemas/RealtimeServerEventResponseMCPCallFailed' + RealtimeServerEventConversationCreated: + type: object + description: | + Returned when a conversation is created. Emitted right after session creation. + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + x-stainless-const: true + description: The event type, must be `conversation.created`. + const: conversation.created + conversation: type: object - description: Details of the transcription error. + description: The conversation resource. properties: - type: - type: string - description: The type of error. - code: - type: string - description: Error code, if any. - message: - type: string - description: A human-readable error message. - param: + id: type: string - description: Parameter related to the error, if any. + description: The unique ID of the conversation. + object: + description: The object type, must be `realtime.conversation`. + const: realtime.conversation required: - event_id - type - - item_id - - content_index - - error + - conversation x-oaiMeta: - name: conversation.item.input_audio_transcription.failed + name: conversation.created group: realtime example: | { - "event_id": "event_2324", - "type": "conversation.item.input_audio_transcription.failed", - "item_id": "msg_003", - "content_index": 0, - "error": { - "type": "transcription_error", - "code": "audio_unintelligible", - "message": "The audio could not be transcribed.", - "param": null + "event_id": "event_9101", + "type": "conversation.created", + "conversation": { + "id": "conv_001", + "object": "realtime.conversation" + } + } + RealtimeServerEventConversationItemAdded: + type: object + description: | + Sent by the server when an Item is added to the default Conversation. This can happen in several cases: + - When the client sends a `conversation.item.create` event. + - When the input audio buffer is committed. In this case the item will be a user message containing the audio from the buffer. + - When the model is generating a Response. In this case the `conversation.item.added` event will be sent when the model starts generating a specific Item, and thus it will not yet have any content (and `status` will be `in_progress`). + + The event will include the full content of the Item (except when model is generating a Response) except for audio data, which can be retrieved separately with a `conversation.item.retrieve` event if necessary. + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + x-stainless-const: true + description: The event type, must be `conversation.item.added`. + const: conversation.item.added + previous_item_id: + anyOf: + - type: string + description: | + The ID of the item that precedes this one, if any. This is used to + maintain ordering when items are inserted. + - type: 'null' + item: + $ref: '#/components/schemas/RealtimeConversationItem' + required: + - event_id + - type + - item + x-oaiMeta: + name: conversation.item.added + group: realtime + example: | + { + "type": "conversation.item.added", + "event_id": "event_C9G8pjSJCfRNEhMEnYAVy", + "previous_item_id": null, + "item": { + "id": "item_C9G8pGVKYnaZu8PH5YQ9O", + "type": "message", + "status": "completed", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hi" + } + ] + } + } + RealtimeServerEventConversationItemCreated: + type: object + description: | + Returned when a conversation item is created. There are several scenarios that produce this event: + - The server is generating a Response, which if successful will produce + either one or two Items, which will be of type `message` + (role `assistant`) or type `function_call`. + - The input audio buffer has been committed, either by the client or the + server (in `server_vad` mode). The server will take the content of the + input audio buffer and add it to a new user message Item. + - The client has sent a `conversation.item.create` event to add a new Item + to the Conversation. + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + x-stainless-const: true + description: The event type, must be `conversation.item.created`. + const: conversation.item.created + previous_item_id: + anyOf: + - type: string + description: | + The ID of the preceding item in the Conversation context, allows the + client to understand the order of the conversation. Can be `null` if the + item has no predecessor. + - type: 'null' + item: + $ref: '#/components/schemas/RealtimeConversationItem' + required: + - event_id + - type + - item + x-oaiMeta: + name: conversation.item.created + group: realtime + example: | + { + "event_id": "event_1920", + "type": "conversation.item.created", + "previous_item_id": "msg_002", + "item": { + "id": "msg_003", + "object": "realtime.item", + "type": "message", + "status": "completed", + "role": "user", + "content": [] + } + } + RealtimeServerEventConversationItemDeleted: + type: object + description: "Returned when an item in the conversation is deleted by the client with a \n`conversation.item.delete` event. This event is used to synchronize the \nserver's understanding of the conversation history with the client's view.\n" + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + x-stainless-const: true + description: The event type, must be `conversation.item.deleted`. + const: conversation.item.deleted + item_id: + type: string + description: The ID of the item that was deleted. + required: + - event_id + - type + - item_id + x-oaiMeta: + name: conversation.item.deleted + group: realtime + example: | + { + "event_id": "event_2728", + "type": "conversation.item.deleted", + "item_id": "msg_005" + } + RealtimeServerEventConversationItemDone: + type: object + description: | + Returned when a conversation item is finalized. + + The event will include the full content of the Item except for audio data, which can be retrieved separately with a `conversation.item.retrieve` event if needed. + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + x-stainless-const: true + description: The event type, must be `conversation.item.done`. + const: conversation.item.done + previous_item_id: + anyOf: + - type: string + description: | + The ID of the item that precedes this one, if any. This is used to + maintain ordering when items are inserted. + - type: 'null' + item: + $ref: '#/components/schemas/RealtimeConversationItem' + required: + - event_id + - type + - item + x-oaiMeta: + name: conversation.item.done + group: realtime + example: | + { + "type": "conversation.item.done", + "event_id": "event_CCXLgMZPo3qioWCeQa4WH", + "previous_item_id": "item_CCXLecNJVIVR2HUy3ABLj", + "item": { + "id": "item_CCXLfxmM5sXVJVz4mCa2S", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_audio", + "transcript": "Oh, I can hear you loud and clear! Sounds like we're connected just fine. What can I help you with today?" + } + ] + } + } + RealtimeServerEventConversationItemInputAudioTranscriptionCompleted: + type: object + description: | + This event is the output of audio transcription for user audio written to the + user audio buffer. Transcription begins when the input audio buffer is + committed by the client or server (when VAD is enabled). Transcription runs + asynchronously with Response creation, so this event may come before or after + the Response events. + + Realtime API models accept audio natively, and thus input transcription is a + separate process run on a separate ASR (Automatic Speech Recognition) model. + The transcript may diverge somewhat from the model's interpretation, and + should be treated as a rough guide. + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + type: string + enum: + - conversation.item.input_audio_transcription.completed + description: | + The event type, must be + `conversation.item.input_audio_transcription.completed`. + x-stainless-const: true + item_id: + type: string + description: The ID of the item containing the audio that is being transcribed. + content_index: + type: integer + description: The index of the content part containing the audio. + transcript: + type: string + description: The transcribed text. + languages: + type: array + description: | + The languages detected in the audio. Returned by `gpt-transcribe`. An empty array indicates that no language could be reliably detected. + items: + $ref: '#/components/schemas/TranscriptionLanguage' + logprobs: + anyOf: + - type: array + description: The log probabilities of the transcription. + items: + $ref: '#/components/schemas/LogProbProperties' + - type: 'null' + usage: + type: object + description: Usage statistics for the transcription, this is billed according to the ASR model's pricing rather than the realtime model's pricing. + anyOf: + - $ref: '#/components/schemas/TranscriptTextUsageTokens' + title: TranscriptTextUsageTokens + - $ref: '#/components/schemas/TranscriptTextUsageDuration' + title: TranscriptTextUsageDuration + required: + - event_id + - type + - item_id + - content_index + - transcript + - usage + x-oaiMeta: + name: conversation.item.input_audio_transcription.completed + group: realtime + example: | + { + "type": "conversation.item.input_audio_transcription.completed", + "event_id": "event_CCXGRvtUVrax5SJAnNOWZ", + "item_id": "item_CCXGQ4e1ht4cOraEYcuR2", + "content_index": 0, + "transcript": "Hey, can you hear me?", + "usage": { + "type": "tokens", + "total_tokens": 22, + "input_tokens": 13, + "input_token_details": { + "text_tokens": 0, + "audio_tokens": 13 + }, + "output_tokens": 9 + } + } + RealtimeServerEventConversationItemInputAudioTranscriptionDelta: + type: object + description: | + Returned when the text value of an input audio transcription content part is updated with incremental transcription results. + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + x-stainless-const: true + description: The event type, must be `conversation.item.input_audio_transcription.delta`. + const: conversation.item.input_audio_transcription.delta + item_id: + type: string + description: The ID of the item containing the audio that is being transcribed. + content_index: + type: integer + description: The index of the content part in the item's content array. + delta: + type: string + description: The text delta. + logprobs: + anyOf: + - type: array + description: 'The log probabilities of the transcription. These can be enabled by configuring the session with `"include": ["item.input_audio_transcription.logprobs"]`. Each entry in the array corresponds a log probability of which token would be selected for this chunk of transcription. This can help to identify if it was possible there were multiple valid options for a given chunk of transcription.' + items: + $ref: '#/components/schemas/LogProbProperties' + - type: 'null' + required: + - event_id + - type + - item_id + x-oaiMeta: + name: conversation.item.input_audio_transcription.delta + group: realtime + example: | + { + "type": "conversation.item.input_audio_transcription.delta", + "event_id": "event_CCXGRxsAimPAs8kS2Wc7Z", + "item_id": "item_CCXGQ4e1ht4cOraEYcuR2", + "content_index": 0, + "delta": "Hey", + "obfuscation": "aLxx0jTEciOGe" + } + RealtimeServerEventConversationItemInputAudioTranscriptionFailed: + type: object + description: "Returned when input audio transcription is configured, and a transcription \nrequest for a user message failed. These events are separate from other \n`error` events so that the client can identify the related Item.\n" + properties: + event_id: + type: string + description: The unique ID of the server event. + type: + type: string + enum: + - conversation.item.input_audio_transcription.failed + description: | + The event type, must be + `conversation.item.input_audio_transcription.failed`. + x-stainless-const: true + item_id: + type: string + description: The ID of the user message item. + content_index: + type: integer + description: The index of the content part containing the audio. + error: + type: object + description: Details of the transcription error. + properties: + type: + type: string + description: The type of error. + code: + type: string + description: Error code, if any. + message: + type: string + description: A human-readable error message. + param: + type: string + description: Parameter related to the error, if any. + required: + - event_id + - type + - item_id + - content_index + - error + x-oaiMeta: + name: conversation.item.input_audio_transcription.failed + group: realtime + example: | + { + "event_id": "event_2324", + "type": "conversation.item.input_audio_transcription.failed", + "item_id": "msg_003", + "content_index": 0, + "error": { + "type": "transcription_error", + "code": "audio_unintelligible", + "message": "The audio could not be transcribed.", + "param": null } } RealtimeServerEventConversationItemInputAudioTranscriptionSegment: @@ -60033,10 +60703,9 @@ components: "role": "assistant", "content": [ { - "type": "audio", + "type": "output_audio", "transcript": "Yes, I can hear you loud and clear. How can I help you today?", - "audio": "8//2//v/9//q/+//+P/s...", - "format": "pcm16" + "audio": "AAABAP//AAA=" } ] } @@ -60231,9 +60900,9 @@ components: group: realtime example: | { - "type":" input_audio_buffer.dtmf_event_received", - "event": "9", - "received_at": 1763605109, + "type": "input_audio_buffer.dtmf_event_received", + "event": "9", + "received_at": 1763605109 } RealtimeServerEventInputAudioBufferSpeechStarted: type: object @@ -60957,7 +61626,7 @@ components: }, "usage": null, "metadata": null - }, + } } RealtimeServerEventResponseDone: type: object @@ -61401,23 +62070,23 @@ components: group: realtime example: | { - "event_id": "event_3536", - "type": "response.output_item.done", - "response_id": "resp_001", - "output_index": 0, - "item": { - "id": "msg_007", - "object": "realtime.item", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "text", - "text": "Sure, I can help with that." - } - ] - } + "event_id": "event_3536", + "type": "response.output_item.done", + "response_id": "resp_001", + "output_index": 0, + "item": { + "id": "msg_007", + "object": "realtime.item", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Sure, I can help with that." + } + ] + } } RealtimeServerEventResponseTextDelta: type: object @@ -61588,7 +62257,7 @@ components: } }, "include": null - }, + } } RealtimeServerEventSessionUpdated: type: object @@ -61690,7 +62359,7 @@ components: } }, "include": null - }, + } } RealtimeServerEventTranscriptionSessionUpdated: type: object @@ -63392,6 +64061,13 @@ components: } } } + RealtimeTranslationConnectParams: + type: object + properties: + model: + type: string + required: + - model RealtimeTranslationServerEvent: discriminator: propertyName: type @@ -64288,6 +64964,21 @@ components: x-stainless-skip: true usage: $ref: '#/components/schemas/ResponseUsage' + tool_usage: + $ref: '#/components/schemas/ResponseToolUsage' + description: | + Usage reported by hosted tools, separate from base model `usage`. The field + can be omitted when tool usage is unavailable. When present, tool families + with no recorded usage have zero counts. + + `image_gen` sums available token counts across image generation calls. + Calls with missing or incomplete usage records may be skipped. Output + token details and cached input counts can be omitted. An omitted cache + count means cache usage is unknown. + + `web_search.num_requests` counts billable searches. Discounted searches + and actions such as opening a page are excluded. Use billing records to + reconcile final charges. prompt_cache_options: $ref: '#/components/schemas/PromptCacheOptions' prompt_cache_diagnostics: @@ -64313,21 +65004,6 @@ components: An upper bound for the number of tokens that can be generated for a response, including visible output tokens and [reasoning tokens](https://developers.openai.com/api/docs/guides/reasoning). type: integer - type: 'null' - tool_usage: - $ref: '#/components/schemas/ResponseToolUsage' - description: | - Usage reported by hosted tools, separate from base model `usage`. The field - can be omitted when tool usage is unavailable. When present, tool families - with no recorded usage have zero counts. - - `image_gen` sums available token counts across image generation calls. - Calls with missing or incomplete usage records may be skipped. Output - token details and cached input counts can be omitted. An omitted cache - count means cache usage is unknown. - - `web_search.num_requests` counts billable searches. Discounted searches - and actions such as opening a page are excluded. Use billing records to - reconcile final charges. required: - access_programs - id @@ -78575,6 +79251,10 @@ components: - object - metadata - created_at + markup: + type: string + format: binary + description: A transparent PNG overlaid on the first image to guide the edit. Must match that image's dimensions after orientation and contain transparent pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. ImageGenOutputTokensDetails: properties: image_tokens: @@ -80319,6 +80999,67 @@ components: type: object required: - cyber + CachedUsageDetails: + properties: + cached_text_tokens: + type: integer + cached_image_tokens: + type: integer + type: object + required: + - cached_text_tokens + - cached_image_tokens + ImageGenToolUsage: + properties: + input_tokens: + type: integer + description: The number of tokens (images and text) in the input prompt. + total_tokens: + type: integer + description: The total number of tokens (images and text) used for the image generation. + output_tokens: + type: integer + description: The number of output tokens generated by the model. + output_tokens_details: + $ref: '#/components/schemas/ImageGenOutputTokensDetails' + input_tokens_details: + $ref: '#/components/schemas/ImageGenInputUsageDetails' + cached_tokens: + type: integer + cached_tokens_details: + $ref: '#/components/schemas/CachedUsageDetails' + type: object + required: + - input_tokens + - total_tokens + - output_tokens + - input_tokens_details + title: Image generation tool usage + description: Token usage for image generation tool calls in a response. + WebSearchUsage: + properties: + num_requests: + type: integer + description: The number of billable web search requests issued. + type: object + required: + - num_requests + title: Web search usage + description: Usage details for web search requests that ran while generating the response. + ResponseToolUsage: + properties: + image_gen: + $ref: '#/components/schemas/ImageGenToolUsage' + description: Aggregated usage statistics for image generation tool calls. + web_search: + $ref: '#/components/schemas/WebSearchUsage' + description: Aggregated billable usage statistics for web search tool calls. + type: object + required: + - image_gen + - web_search + title: Response tool usage + description: Aggregated usage details for billable tools that ran while generating the response. PromptCacheOptions: properties: ttl: @@ -80809,6 +81550,242 @@ components: - request_paused - error_type - reason + OrderEnum: + type: string + enum: + - asc + - desc + ExternalKeyPurpose: + type: string + enum: + - ekm + - psp_decryption + ExternalKeyResource: + properties: + object: + type: string + id: + type: string + type: + type: string + purpose: + $ref: '#/components/schemas/ExternalKeyPurpose' + name: + type: string + api_project_ids: + items: + type: string + type: array + workspace_id: + type: string + created_at: + type: integer + role_arn: + type: string + external_id: + type: string + kms_arn: + type: string + workload_identity_project_number: + type: string + workload_identity_pool_id: + type: string + workload_identity_provider_id: + type: string + audience: + type: string + kms_project_id: + type: string + kms_key_ring_name: + type: string + kms_key_name: + type: string + kms_key_location: + type: string + kms_key_version: + type: string + tenant_id: + type: string + vault_uri: + type: string + key_name: + type: string + key_version: + type: string + type: object + required: + - object + - id + - type + - name + - api_project_ids + - workspace_id + - created_at + ListExternalKeysResource: + properties: + object: + type: string + enum: + - list + default: list + x-stainless-const: true + data: + items: + $ref: '#/components/schemas/ExternalKeyResource' + type: array + first_id: + anyOf: + - type: string + - type: 'null' + last_id: + anyOf: + - type: string + - type: 'null' + has_more: + type: boolean + type: object + required: + - object + - data + - first_id + - last_id + - has_more + AwsExternalKeyParams: + properties: + name: + type: string + description: A friendly name to identify this config + type: + type: string + enum: + - aws + default: aws + x-stainless-const: true + role_arn: + type: string + pattern: ^arn:aws:iam::\d{12}:role/.+$ + description: The Role ARN that OpenAI will assume to call your KMS + example: arn:aws:iam::123456789012:role/example-role + kms_arn: + type: string + description: The Key Management System ARN for the master key you manage + external_id: + type: string + description: The ExternalID that OpenAI will pass in the AssumeRole call for the customer's AWS account. This must be the OpenAI organization ID or an API project ID that belongs to the organization. + purpose: + $ref: '#/components/schemas/ExternalKeyPurpose' + type: object + required: + - name + - type + - role_arn + - kms_arn + - external_id + GcpExternalKeyParams: + properties: + name: + type: string + description: A friendly name to identify this config + type: + type: string + enum: + - gcp + default: gcp + x-stainless-const: true + workload_identity_project_number: + type: string + pattern: ^[0-9]{1,19}$ + description: The numeric GCP project number where you registered OpenAI's workload identity + workload_identity_pool_id: + type: string + description: The pool containing the Workload Identity provider that you registered for OpenAI + workload_identity_provider_id: + type: string + description: The Workload Identity provider that you registered for OpenAI + kms_project_id: + type: string + description: The string GCP project name where your KMS resides + kms_key_ring_name: + type: string + description: The Key Management System key ring for the master key you manage + kms_key_name: + type: string + description: The name of the Key Management System master key you manage + kms_key_location: + type: string + description: The region where your Key Management System master key is located + audience: + type: string + description: The aud field in the identity token OpenAI will pass in the call to the customer's GCP STS. This must be the OpenAI organization ID or an API project ID that belongs to the organization. + purpose: + $ref: '#/components/schemas/ExternalKeyPurpose' + kms_key_version: + type: string + pattern: ^[1-9][0-9]*$ + description: An exact CryptoKeyVersion number. Required for PSP decryption; not accepted for EKM. + type: object + required: + - name + - type + - workload_identity_project_number + - workload_identity_pool_id + - workload_identity_provider_id + - kms_project_id + - kms_key_ring_name + - kms_key_name + - kms_key_location + - audience + AzureExternalKeyParams: + properties: + name: + type: string + description: A friendly name to identify this config + type: + type: string + enum: + - azure + default: azure + x-stainless-const: true + tenant_id: + type: string + pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ + description: Your Azure tenant UUID + vault_uri: + type: string + pattern: ^https://.+\.vault\.azure\.net/?$ + description: The URI of the Azure vault containing the master key you manage + key_name: + type: string + maxLength: 127 + pattern: ^org-[0-9a-zA-Z-]*--[0-9a-zA-Z-]*$ + description: The name of the Azure Key Vault master key that you manage + purpose: + $ref: '#/components/schemas/ExternalKeyPurpose' + key_version: + type: string + pattern: ^[0-9a-fA-F]{32}$ + description: An exact Azure Key Vault key version. Required for PSP decryption; not accepted for EKM. + type: object + required: + - name + - type + - tenant_id + - vault_uri + - key_name + V1OrganizationExternalKeysPostRequest: + discriminator: + propertyName: type + anyOf: + - $ref: '#/components/schemas/AwsExternalKeyParams' + - $ref: '#/components/schemas/GcpExternalKeyParams' + - $ref: '#/components/schemas/AzureExternalKeyParams' + ValidateExternalKeyResource: + properties: + status: + type: string + type: object + required: + - status DecisionsUsageBucketWidth: type: string enum: @@ -81578,11 +82555,6 @@ components: - object - created_at - results - OrderEnum: - type: string - enum: - - asc - - desc VideoModel: anyOf: - type: string @@ -85036,6 +86008,26 @@ components: - max_concurrent_subagents additionalProperties: false description: The resolved configuration for creating and coordinating subagents. + CompactionResource: + type: object + properties: + async: + type: boolean + description: Whether context is compacted in the background. Defaults to true. + required: + - async + additionalProperties: false + description: The effective settings for compacting the agent's context. + ExperimentalConfigResource: + type: object + properties: + time_awareness: + type: boolean + description: Whether the model receives periodic current-time reminders. + required: + - time_awareness + additionalProperties: false + description: The effective experimental settings for an agent. AutomationAgentConfigResource: type: object properties: @@ -85903,6 +86895,14 @@ components: - error additionalProperties: false description: An API error response. + ExperimentalConfigOverridesParam: + type: object + properties: + time_awareness: + type: boolean + description: Whether to give the model periodic current-time reminders. Omit to keep the current setting. + additionalProperties: false + description: Experimental settings to update. Omitted settings keep their current values. ReasoningEffortParam: type: string enum: @@ -86053,6 +87053,16 @@ components: description: Explicit configuration for creating and coordinating subagents. x-openai-go-legacy-bool-fields: - enabled + CompactionParam: + type: object + properties: + async: + type: + - boolean + - 'null' + description: Whether to compact context in the background. Omission or null defaults to true. Set to false to use blocking compaction. Background compaction may increase token usage. + additionalProperties: false + description: Settings for compacting the agent's context. AgentToolConfigParamFunction: type: object properties: @@ -86470,6 +87480,10 @@ components: SessionAgentConfigParam: type: object properties: + experimental: + $ref: '#/components/schemas/ExperimentalConfigOverridesParam' + default: {} + description: Experimental settings to merge by key. Omitted settings keep their current values. model: type: string minLength: 0 @@ -86502,6 +87516,11 @@ components: - $ref: '#/components/schemas/MultiAgentConfigCurrentParam' - type: 'null' description: Configuration for creating and coordinating subagents. + compaction: + anyOf: + - $ref: '#/components/schemas/CompactionParam' + - type: 'null' + description: Context compaction settings. Omit to keep the current settings. A supplied object replaces the settings; null or an empty object enables background compaction. tools: type: - array @@ -86511,15 +87530,6 @@ components: minItems: 0 maxItems: 16384 description: Tools available to the agent. Omit to inherit, or pass null to clear them. The resolved tool list must fit within 3 MiB (3,145,728 bytes) of compact UTF-8 JSON. - compaction: - anyOf: - - $ref: '#/components/schemas/CompactionParam' - - type: 'null' - description: Context compaction settings. Omit to keep the current settings. A supplied object replaces the settings; null or an empty object enables background compaction. - experimental: - $ref: '#/components/schemas/ExperimentalConfigOverridesParam' - default: {} - description: Experimental settings to merge by key. Omitted settings keep their current values. additionalProperties: false description: Agent configuration for a session. Omitted fields inherit from `agent_id` when supplied. Supplied objects and arrays replace the whole field, except experimental settings, which merge by key. Null resets nullable fields. EnvironmentParamNone: @@ -90051,6 +91061,15 @@ components: - has_more additionalProperties: false description: A page of Agents API resources, with IDs for retrieving additional pages. + ExperimentalConfigParam: + type: object + properties: + time_awareness: + type: boolean + default: false + description: Whether to give the model periodic current-time reminders. Defaults to false. + additionalProperties: false + description: Experimental agent settings. These features may change. PersistedAgentToolConfigParamFunction: type: object properties: @@ -90386,6 +91405,11 @@ components: CreateAgentParams: type: object properties: + experimental: + $ref: '#/components/schemas/ExperimentalConfigParam' + default: + time_awareness: false + description: Experimental agent settings. All experimental features default to disabled. metadata: type: - object @@ -90454,11 +91478,6 @@ components: - $ref: '#/components/schemas/CompactionParam' - type: 'null' description: Context compaction settings. Omission or null enables background compaction. - experimental: - $ref: '#/components/schemas/ExperimentalConfigParam' - default: - time_awareness: false - description: Experimental agent settings. All experimental features default to disabled. required: - model additionalProperties: false @@ -90466,6 +91485,10 @@ components: UpdateAgentParams: type: object properties: + experimental: + $ref: '#/components/schemas/ExperimentalConfigOverridesParam' + default: {} + description: Experimental settings to merge by key. Omitted settings keep their current values. model: type: string minLength: 0 @@ -90498,6 +91521,11 @@ components: - $ref: '#/components/schemas/MultiAgentConfigCurrentParam' - type: 'null' description: Configuration for creating and coordinating subagents. + compaction: + anyOf: + - $ref: '#/components/schemas/CompactionParam' + - type: 'null' + description: Context compaction settings. Omit to keep the current settings. A supplied object replaces the settings; null or an empty object enables background compaction. metadata: type: - object @@ -90529,15 +91557,6 @@ components: minItems: 0 maxItems: 2000 description: Replaces the tool list. Omit to leave it unchanged, or pass null to clear it. The replacement must fit within 3 MiB (3,145,728 bytes) of compact UTF-8 JSON. - compaction: - anyOf: - - $ref: '#/components/schemas/CompactionParam' - - type: 'null' - description: Context compaction settings. Omit to keep the current settings. A supplied object replaces the settings; null or an empty object enables background compaction. - experimental: - $ref: '#/components/schemas/ExperimentalConfigOverridesParam' - default: {} - description: Experimental settings to merge by key. Omitted settings keep their current values. additionalProperties: false description: Fields to replace on an existing reusable agent. DeletedAgentResource: @@ -97471,7 +98490,7 @@ components: reason: close_requested session: id: live_abc123 - model: '{{models.live.default}}' + model: gpt-live-1 status: active expires_at: 1788555600 instructions: Help the caller plan a restaurant reservation. Confirm details before booking. @@ -97548,7 +98567,7 @@ components: reason: close_requested session: id: live_abc123 - model: '{{models.live.default}}' + model: gpt-live-1 status: active expires_at: 1788555600 instructions: Help the caller plan a restaurant reservation. Confirm details before booking. @@ -97701,6 +98720,39 @@ components: type: transport.dtmf.received event_id: event_dtmf_1 event: '5' + LiveTransportDTMFSent: + description: A SIP DTMF keypress successfully sent to the SIP trunk. Delivered only to sideband observers; this is not a client command. + example: + type: transport.dtmf.sent + event_id: event_dtmf_2 + event: '#' + type: object + properties: + type: + default: transport.dtmf.sent + x-stainless-const: true + type: string + enum: + - transport.dtmf.sent + event_id: + type: string + event: + type: string + minLength: 1 + maxLength: 1 + pattern: ^[0-9A-D*#]$ + client_event_id: + description: The event_id of the client command, when supplied. + type: string + required: + - type + - event_id + - event + x-oaiMeta: + example: + type: transport.dtmf.sent + event_id: event_dtmf_2 + event: '#' LiveTransportRinging: description: The outbound SIP provider leg is ringing or providing early media. Delivered only to sideband observers. example: @@ -97852,6 +98904,40 @@ components: - $ref: '#/components/schemas/LiveTransportRinging' - $ref: '#/components/schemas/LiveTransportAnswered' - $ref: '#/components/schemas/LiveTransportFailed' + LiveTransportDTMFSendParam: + description: Send one DTMF keypress to the SIP trunk through a Live sideband connection. This command is supported only for SIP calls. A successful send emits `transport.dtmf.sent` on sideband. If you provide `event_id`, the success notification includes it as `client_event_id`; a failure includes it as `error.client_event_id`. Send separate events for multiple keypresses. + example: + type: transport.dtmf.send + event_id: evt_dtmf_001 + event: '#' + type: object + properties: + event_id: + anyOf: + - description: Optional client identifier for correlating this command with a server event's client_event_id or error.client_event_id. + type: string + maxLength: 512 + - type: 'null' + type: + default: transport.dtmf.send + x-stainless-const: true + type: string + enum: + - transport.dtmf.send + event: + description: 'The DTMF key to send: one of `0`–`9`, `A`–`D`, `*`, or `#`.' + type: string + minLength: 1 + maxLength: 1 + pattern: ^[0-9A-D*#]$ + required: + - type + - event + x-oaiMeta: + example: + type: transport.dtmf.send + event_id: evt_dtmf_001 + event: '#' UserMessageItemParam: properties: id: @@ -105211,6 +106297,21 @@ components: x-stainless-skip: true usage: $ref: '#/components/schemas/BetaResponseUsage' + tool_usage: + $ref: '#/components/schemas/BetaResponseToolUsage' + description: | + Usage reported by hosted tools, separate from base model `usage`. The field + can be omitted when tool usage is unavailable. When present, tool families + with no recorded usage have zero counts. + + `image_gen` sums available token counts across image generation calls. + Calls with missing or incomplete usage records may be skipped. Output + token details and cached input counts can be omitted. An omitted cache + count means cache usage is unknown. + + `web_search.num_requests` counts billable searches. Discounted searches + and actions such as opening a page are excluded. Use billing records to + reconcile final charges. prompt_cache_options: $ref: '#/components/schemas/BetaPromptCacheOptions' prompt_cache_diagnostics: @@ -105236,21 +106337,6 @@ components: An upper bound for the number of tokens that can be generated for a response, including visible output tokens and [reasoning tokens](https://developers.openai.com/api/docs/guides/reasoning). type: integer - type: 'null' - tool_usage: - $ref: '#/components/schemas/BetaResponseToolUsage' - description: | - Usage reported by hosted tools, separate from base model `usage`. The field - can be omitted when tool usage is unavailable. When present, tool families - with no recorded usage have zero counts. - - `image_gen` sums available token counts across image generation calls. - Calls with missing or incomplete usage records may be skipped. Output - token details and cached input counts can be omitted. An omitted cache - count means cache usage is unknown. - - `web_search.num_requests` counts billable searches. Discounted searches - and actions such as opening a page are excluded. Use billing records to - reconcile final charges. required: - access_programs - id @@ -105466,6 +106552,95 @@ components: - mode title: Prompt cache options description: The prompt-caching options that were applied to the response. Supported for `gpt-5.6` and later models. + BetaResponseToolUsage: + properties: + image_gen: + $ref: '#/components/schemas/BetaImageGenToolUsage' + description: Aggregated usage statistics for image generation tool calls. + web_search: + $ref: '#/components/schemas/BetaWebSearchUsage' + description: Aggregated billable usage statistics for web search tool calls. + type: object + required: + - image_gen + - web_search + title: Response tool usage + description: Aggregated usage details for billable tools that ran while generating the response. + BetaWebSearchUsage: + properties: + num_requests: + type: integer + description: The number of billable web search requests issued. + type: object + required: + - num_requests + title: Web search usage + description: Usage details for web search requests that ran while generating the response. + BetaImageGenToolUsage: + properties: + input_tokens: + type: integer + description: The number of tokens (images and text) in the input prompt. + total_tokens: + type: integer + description: The total number of tokens (images and text) used for the image generation. + output_tokens: + type: integer + description: The number of output tokens generated by the model. + output_tokens_details: + $ref: '#/components/schemas/BetaImageGenOutputTokensDetails' + input_tokens_details: + $ref: '#/components/schemas/BetaImageGenInputUsageDetails' + cached_tokens: + type: integer + cached_tokens_details: + $ref: '#/components/schemas/BetaCachedUsageDetails' + type: object + required: + - input_tokens + - total_tokens + - output_tokens + - input_tokens_details + title: Image generation tool usage + description: Token usage for image generation tool calls in a response. + BetaCachedUsageDetails: + properties: + cached_text_tokens: + type: integer + cached_image_tokens: + type: integer + type: object + required: + - cached_text_tokens + - cached_image_tokens + BetaImageGenInputUsageDetails: + properties: + text_tokens: + type: integer + description: The number of text tokens in the input prompt. + image_tokens: + type: integer + description: The number of image tokens in the input prompt. + type: object + required: + - text_tokens + - image_tokens + title: Input usage details + description: The input tokens detailed information for the image generation. + BetaImageGenOutputTokensDetails: + properties: + image_tokens: + type: integer + description: The number of image output tokens generated by the model. + text_tokens: + type: integer + description: The number of text output tokens generated by the model. + type: object + required: + - image_tokens + - text_tokens + title: Image generation output token details + description: The output token details for the image generation. BetaOutputItem: discriminator: propertyName: type @@ -109774,1395 +110949,897 @@ components: description: | Emitted when an image generation tool call has completed and the final image is available. allOf: - - $ref: '#/components/schemas/BetaResponseImageGenCallCompletedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseImageGenCallWsGenerating - description: | - Emitted when an image generation tool call is actively generating an image (intermediate state). - allOf: - - $ref: '#/components/schemas/BetaResponseImageGenCallGeneratingEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseImageGenCallInWsProgress - description: | - Emitted when an image generation tool call is in progress. - allOf: - - $ref: '#/components/schemas/BetaResponseImageGenCallInProgressEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseImageGenCallPartialWsImage - description: | - Emitted when a partial image is available during image generation streaming. - allOf: - - $ref: '#/components/schemas/BetaResponseImageGenCallPartialImageEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpCallArgumentsWsDelta - description: | - Emitted when there is a delta (partial update) to the arguments of an MCP tool call. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDeltaEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpCallArgumentsWsDone - description: | - Emitted when the arguments for an MCP tool call are finalized. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDoneEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpCallWsCompleted - description: | - Emitted when an MCP tool call has completed successfully. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPCallCompletedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpCallWsFailed - description: | - Emitted when an MCP tool call has failed. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPCallFailedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpCallInWsProgress - description: | - Emitted when an MCP tool call is in progress. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPCallInProgressEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpListToolsWsCompleted - description: | - Emitted when the list of available MCP tools has been successfully retrieved. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPListToolsCompletedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpListToolsWsFailed - description: | - Emitted when the attempt to list available MCP tools has failed. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPListToolsFailedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseMcpListToolsInWsProgress - description: | - Emitted when the system is in the process of retrieving the list of available MCP tools. - allOf: - - $ref: '#/components/schemas/BetaResponseMCPListToolsInProgressEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseOutputTextAnnotationWsAdded - description: | - Emitted when an annotation is added to output text content. - allOf: - - $ref: '#/components/schemas/BetaResponseOutputTextAnnotationAddedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseWsQueued - description: | - Emitted when a response is queued and waiting to be processed. - allOf: - - $ref: '#/components/schemas/BetaResponseQueuedEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseCustomToolCallInputWsDelta - description: | - Event representing a delta (partial update) to the input of a custom tool call. - allOf: - - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDeltaEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - title: BetaResponseCustomToolCallInputWsDone - description: | - Event indicating that input for a custom tool call is complete. - allOf: - - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDoneEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - - $ref: '#/components/schemas/BetaResponseWsError' - - $ref: '#/components/schemas/BetaResponseSteerAcceptedEvent' - - $ref: '#/components/schemas/BetaResponseSteerPendingEvent' - - $ref: '#/components/schemas/BetaResponseSteerFailedEvent' - - $ref: '#/components/schemas/BetaResponseInjectCreatedEvent' - - $ref: '#/components/schemas/BetaResponseInjectFailedEvent' - BetaResponseInjectFailedEvent: - type: object - title: ResponseInjectFailedEvent - description: | - Emitted when injected input could not be committed to a response. The event - returns the uncommitted raw input so the client can retry it in another - response when appropriate. - properties: - type: - type: string - enum: - - response.inject.failed - description: The event discriminator. Always `response.inject.failed`. - x-stainless-const: true - response_id: - type: string - description: The ID of the response that rejected the input. - input: - type: array - description: The raw input items that were not committed. - items: - $ref: '#/components/schemas/BetaInputItem' - error: - type: object - description: Information about why the input was not committed. - properties: - code: - type: string - enum: - - response_already_completed - - response_not_found - description: A machine-readable error code. - message: - type: string - description: A human-readable description of the error. - required: - - code - - message - sequence_number: - type: integer - description: The sequence number for this event. - stream_id: - type: string - description: | - The multiplexed WebSocket stream that emitted the event. This field is - present only when WebSocket multiplexing is enabled separately. - required: - - type - - response_id - - input - - error - - sequence_number - x-oai-beta: responses_multi_agent=v1 - x-oaiMeta: - example: | - { - "type": "response.inject.failed", - "response_id": "resp_123", - "input": [ - { - "type": "function_call_output", - "call_id": "call_123", - "output": "{\"temperature\":72}" - } - ], - "error": { - "code": "response_already_completed", - "message": "Response 'resp_123' has already completed." - }, - "sequence_number": 9 - } - BetaResponseInjectCreatedEvent: - type: object - title: ResponseInjectCreatedEvent - description: | - Emitted when all injected input items were validated and committed to the - active response. - properties: - type: - type: string - enum: - - response.inject.created - description: The event discriminator. Always `response.inject.created`. - x-stainless-const: true - response_id: - type: string - description: The ID of the response that accepted the input. - sequence_number: - type: integer - description: The sequence number for this event. - stream_id: - type: string - description: | - The multiplexed WebSocket stream that emitted the event. This field is - present only when WebSocket multiplexing is enabled separately. - required: - - type - - response_id - - sequence_number - x-oai-beta: responses_multi_agent=v1 - x-oaiMeta: - example: | - { - "type": "response.inject.created", - "response_id": "resp_123", - "sequence_number": 8 - } - BetaResponseWsError: - type: object - title: ResponseWsError - description: Emitted when an error occurs while processing a Responses WebSocket request. - properties: - agent: - anyOf: - - $ref: '#/components/schemas/BetaAgentTag' - - type: 'null' - description: The agent that owns this multi-agent streaming event. - type: - type: string - description: The type of the event. Always `error`. - enum: - - error - x-stainless-const: true - status: - type: integer - description: The HTTP status code associated with a WebSocket protocol error. - sequence_number: - type: integer - description: The sequence number of an error emitted by the response stream. - error: - $ref: '#/components/schemas/BetaErrorPayload' - description: Details about the error. - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present when the - originating `response.create` event supplied a `stream_id`. - required: - - type - - error - x-oaiMeta: - name: error - group: responses - example: | - { - "type": "error", - "status": 400, - "stream_id": "agent_1", - "error": { - "type": "invalid_request_error", - "code": "websocket_stream_limit_reached", - "message": "This WebSocket connection has reached its stream limit.", - "param": "stream_id" - } - } - BetaErrorPayload: - properties: - type: - type: string - description: The error type that was emitted. - code: - anyOf: - - type: string - description: The error code that was emitted, if any. - - type: 'null' - message: - type: string - description: The human-readable error message that was emitted. - param: - anyOf: - - type: string - description: The parameter name that was associated with the error, if any. - - type: 'null' - headers: - additionalProperties: - type: string - description: The header value that was emitted. - type: object - description: The response headers that were emitted with the error, if any. - x-oaiTypeLabel: map - misalignment: - $ref: '#/components/schemas/BetaMisalignmentErrorDetailsResource' - type: object - required: - - type - - code - - message - - param - title: Error payload - description: An error payload that was emitted for a streaming error event. - BetaResponsesWebSocketStreamEvent: - allOf: - - anyOf: - - $ref: '#/components/schemas/BetaResponseAudioDeltaEvent' - - $ref: '#/components/schemas/BetaResponseAudioDoneEvent' - - $ref: '#/components/schemas/BetaResponseAudioTranscriptDeltaEvent' - - $ref: '#/components/schemas/BetaResponseAudioTranscriptDoneEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDeltaEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDoneEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInterpretingEvent' - - $ref: '#/components/schemas/BetaResponseCompactionCompactingStreamingEvent' - - $ref: '#/components/schemas/BetaResponseCompletedEvent' - - $ref: '#/components/schemas/BetaResponseContentPartAddedEvent' - - $ref: '#/components/schemas/BetaResponseContentPartDoneEvent' - - $ref: '#/components/schemas/BetaResponseCreatedEvent' - - $ref: '#/components/schemas/BetaResponseFileSearchCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseFileSearchCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseFileSearchCallSearchingEvent' - - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDeltaEvent' - - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDoneEvent' - - $ref: '#/components/schemas/BetaResponseShellCallCommandAddedStreamingEvent' - - $ref: '#/components/schemas/BetaResponseShellCallCommandDeltaStreamingEvent' - - $ref: '#/components/schemas/BetaResponseShellCallCommandDoneStreamingEvent' - - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDeltaStreamingEvent' - x-stainless-skip: - - go - - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDoneStreamingEvent' - - $ref: '#/components/schemas/BetaResponseInProgressEvent' - - $ref: '#/components/schemas/BetaResponseFailedEvent' - - $ref: '#/components/schemas/BetaResponseIncompleteEvent' - - $ref: '#/components/schemas/BetaResponseOutputItemAddedEvent' - - $ref: '#/components/schemas/BetaResponseOutputItemDoneEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartAddedEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartDoneEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDeltaEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDoneEvent' - - $ref: '#/components/schemas/BetaResponseReasoningTextDeltaEvent' - - $ref: '#/components/schemas/BetaResponseReasoningTextDoneEvent' - - $ref: '#/components/schemas/BetaResponseRefusalDeltaEvent' - - $ref: '#/components/schemas/BetaResponseRefusalDoneEvent' - - $ref: '#/components/schemas/BetaResponseTextDeltaEvent' - - $ref: '#/components/schemas/BetaResponseTextDoneEvent' - - $ref: '#/components/schemas/BetaResponseWebSearchCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseWebSearchCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseWebSearchCallSearchingEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallGeneratingEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallPartialImageEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDeltaEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDoneEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallFailedEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseMCPListToolsCompletedEvent' - - $ref: '#/components/schemas/BetaResponseMCPListToolsFailedEvent' - - $ref: '#/components/schemas/BetaResponseMCPListToolsInProgressEvent' - - $ref: '#/components/schemas/BetaResponseOutputTextAnnotationAddedEvent' - - $ref: '#/components/schemas/BetaResponseQueuedEvent' - - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDeltaEvent' - - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDoneEvent' - - type: object - properties: - stream_id: - type: string - description: | - The WebSocket lane that emitted this event. This field is present - when the originating `response.create` event supplied a - `stream_id`. - BetaResponsesClientEvent: - discriminator: - propertyName: type - description: | - Client events accepted by the Responses WebSocket server. - anyOf: - - $ref: '#/components/schemas/BetaResponsesClientEventResponseCreate' - - $ref: '#/components/schemas/BetaResponseSteerEvent' - - $ref: '#/components/schemas/BetaResponseInjectEvent' - BetaResponseInjectEvent: - type: object - title: ResponseInjectEvent - description: | - Injects input items into an active response over a WebSocket connection. - The items are validated and committed atomically. Currently, the server - accepts client-owned tool outputs that resume a waiting agent. - properties: - type: - type: string - enum: - - response.inject - description: The event discriminator. Always `response.inject`. - x-stainless-const: true - response_id: - type: string - description: The ID of the active response that should receive the input. - input: - type: array - maxItems: 16384 - description: Input items to inject into the active response. - items: - $ref: '#/components/schemas/BetaInputItem' - required: - - type - - response_id - - input - x-oai-beta: responses_multi_agent=v1 - x-oaiMeta: - example: | - { - "type": "response.inject", - "response_id": "resp_123", - "input": [ - { - "type": "function_call_output", - "call_id": "call_123", - "output": "{\"temperature\":72}" - } - ] - } - BetaResponsesClientEventResponseCreate: - allOf: - - type: object - properties: - type: - type: string - enum: - - response.create - description: | - The type of the client event. Always `response.create`. - x-stainless-const: true - stream_id: - type: string - minLength: 1 - maxLength: 256 - pattern: ^[A-Za-z0-9_.-]+$ - description: | - The WebSocket lane for this response. Requests with the same - `stream_id` are processed FIFO, and events for the response echo the - same `stream_id`. - - `stream_id` controls routing; `previous_response_id` controls - conversation lineage, so a new lane can fork from a response created - on another lane. - required: - - type - - $ref: '#/components/schemas/BetaCreateResponse' - description: | - Client event for creating a response over a persistent WebSocket connection. - This payload uses the same top-level fields as `POST /v1/responses`, plus - WebSocket-only envelope metadata. - - Notes: - - `stream` is implicit over WebSocket and should not be sent. - - `background` is not supported over WebSocket. - - `stream_id` is WebSocket-only and is not part of `POST /v1/responses`. - x-oaiMeta: - example: | - { - "type": "response.create", - "stream_id": "agent_1", - "model": "gpt-6-astra", - "input": "Say hello." - } - BetaCreateResponse: - allOf: - - $ref: '#/components/schemas/BetaCreateModelResponseProperties' - - $ref: '#/components/schemas/BetaResponseProperties' - - type: object - properties: - access_programs: - $ref: '#/components/schemas/BetaAccessProgramsParam' - prompt_cache_options: - $ref: '#/components/schemas/BetaResponsePromptCacheOptionsParam' - service_tier: - $ref: '#/components/schemas/BetaServiceTierResponses' - truncation: - deprecated: true - anyOf: - - type: string - description: | - The truncation strategy to use for the model response. - - `auto`: If the input to this Response exceeds - the model's context window size, the model will truncate the - response to fit the context window by dropping items from the beginning of the conversation. - - `disabled` (default): If the input size will exceed the context window - size for a model, the request will fail with a 400 error. - enum: - - auto - - disabled - default: disabled - - type: 'null' - reasoning: - anyOf: - - $ref: '#/components/schemas/BetaReasoning' - - type: 'null' - input: - $ref: '#/components/schemas/BetaInputParam' - include: - anyOf: - - type: array - description: |- - Specify additional output data to include in the model response. Currently supported values are: - - `web_search_call.action.sources`: Include the sources of the web search tool call. - - `code_interpreter_call.outputs`: Includes the outputs of python code execution in code interpreter tool call items. - - `computer_call_output.output.image_url`: Include image urls from the computer call output. - - `file_search_call.results`: Include the search results of the file search tool call. - - `message.input_image.image_url`: Include image urls from the input message. - - `message.output_text.logprobs`: Include logprobs with assistant messages. - - `reasoning.encrypted_content`: Includes an encrypted version of reasoning tokens in reasoning item outputs. This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly (like when the `store` parameter is set to `false`, or when an organization is enrolled in the zero data retention program). - items: - $ref: '#/components/schemas/BetaIncludeEnum' - - type: 'null' - parallel_tool_calls: - anyOf: - - type: boolean - description: | - Whether to allow the model to run tool calls in parallel. - default: true - - type: 'null' - store: - anyOf: - - type: boolean - description: | - Whether to store the generated model response for later retrieval via - API. - Defaults to true when omitted. - If set to true, response data will be stored for at least 30 days, subject to the [data retention exceptions](https://developers.openai.com/api/docs/guides/your-data#v1responses). - default: true - - type: 'null' - instructions: - anyOf: - - type: string - description: | - A system (or developer) message inserted into the model's context. - - When using along with `previous_response_id`, the instructions from a previous - response will not be carried over to the next response. This makes it simple - to swap out system (or developer) messages in new responses. - - type: 'null' - moderation: - anyOf: - - $ref: '#/components/schemas/BetaModerationParam' - description: | - Configuration for running moderation on the input and output of this response. - - type: 'null' - stream: - anyOf: - - description: | - If set to true, the model response data will be streamed to the client - as it is generated using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format). - See the [Streaming section below](https://developers.openai.com/api/reference/resources/responses/streaming-events) - for more information. - type: boolean - default: false - - type: 'null' - stream_options: - $ref: '#/components/schemas/BetaResponseStreamOptions' - conversation: - anyOf: - - $ref: '#/components/schemas/BetaConversationParam' - - type: 'null' - context_management: - anyOf: - - type: array - example: - - type: compaction - compact_threshold: 1000 - description: | - Context management configuration for this request. - minItems: 1 - items: - $ref: '#/components/schemas/BetaContextManagementParam' - - type: 'null' - max_output_tokens: - anyOf: - - description: | - An upper bound for the number of tokens that can be generated for a response, including visible output tokens and [reasoning tokens](https://developers.openai.com/api/docs/guides/reasoning). - type: integer - minimum: 16 - - type: 'null' - multi_agent: - anyOf: - - $ref: '#/components/schemas/BetaMultiAgentParam' - description: Configuration for server-hosted multi-agent execution. - - type: 'null' - x-openai-go-legacy-bool-fields: - - enabled - BetaMultiAgentParam: - properties: - enabled: - type: boolean - description: Whether to enable server-hosted multi-agent execution for this response. - max_concurrent_subagents: - type: integer - minimum: 1 - description: |- - `max_concurrent_subagents` sets the maximum number of subagents that can be active simultaneously across the entire agent tree. It includes all descendants—children, grandchildren, and deeper subagents—but excludes the root agent. - The API does not impose a fixed upper bound on this setting. The default is `3`, which is recommended for most workloads. Multi-agent runs also have no fixed limit on tree depth or the total number of subagents created during a run. - type: object - required: - - enabled - description: Configuration for server-hosted multi-agent execution. - x-oai-beta: responses_multi_agent=v1 - BetaContextManagementParam: - properties: - type: - type: string - description: The context management entry type. Currently only 'compaction' is supported. - compact_threshold: - anyOf: - - type: integer - minimum: 1000 - description: Token threshold at which compaction should be triggered for this entry. - - type: 'null' - type: object - required: - - type - BetaResponseStreamOptions: - anyOf: - - description: | - Options for streaming responses. Only set this when you set `stream: true`. - type: object - properties: - include_obfuscation: - type: boolean - description: | - When true, stream obfuscation will be enabled. Stream obfuscation adds - random characters to an `obfuscation` field on streaming delta events to - normalize payload sizes as a mitigation to certain side-channel attacks. - These obfuscation fields are included by default, but add a small amount - of overhead to the data stream. You can set `include_obfuscation` to - false to optimize for bandwidth if you trust the network links between - your application and the OpenAI API. - - type: 'null' - BetaModerationParam: - properties: - model: - type: string - description: The moderation model to use for moderated completions, e.g. 'omni-moderation-latest'. - policy: - anyOf: - - $ref: '#/components/schemas/BetaModerationPolicyParam' - description: The policy to apply to moderated response input and output. - - type: 'null' - type: object - required: - - model - description: Configuration for running moderation on the input and output of this response. - BetaModerationPolicyParam: - properties: - input: - anyOf: - - $ref: '#/components/schemas/BetaModerationConfigParam' - description: The moderation policy for the response input. - - type: 'null' - output: - anyOf: - - $ref: '#/components/schemas/BetaModerationConfigParam' - description: The moderation policy for the response output. - - type: 'null' - type: object - required: [] - description: The policy to apply to moderated response input and output. - BetaModerationConfigParam: - properties: - mode: - $ref: '#/components/schemas/BetaModerationMode' - type: object - required: - - mode - description: The moderation policy for the response input. - BetaModerationMode: - type: string - enum: - - score - - block - BetaResponsePromptCacheOptionsParam: - properties: - ttl: - $ref: '#/components/schemas/BetaPromptCacheTTLEnum' - description: The minimum lifetime applied to every implicit and explicit cache breakpoint written by the request. Defaults to `30m`, which is currently the only supported value. The backend may retain cache entries for longer. - mode: - $ref: '#/components/schemas/BetaPromptCacheModeEnum' - description: Controls whether OpenAI automatically creates an implicit cache breakpoint. Defaults to `implicit`. With `implicit`, OpenAI creates one implicit breakpoint and writes up to the latest three explicit breakpoints in the request. With `explicit`, OpenAI does not create an implicit breakpoint and writes up to the latest four explicit breakpoints. If there are no explicit breakpoints, the request does not use prompt caching. - prewarm: - type: boolean - description: Prepares the prompt cache without generating output. Defaults to `false`. When set to `true`, overrides the `generate` field to `false`. - comparison_response_id: - anyOf: - - type: string - description: The ID of a response to compare when diagnosing prompt cache reuse. Supplying this field requests prompt cache diagnostics when the feature is enabled. - example: resp_123 - - type: 'null' - type: object - required: [] - title: Prompt cache options - description: Options for prompt caching. Supported for `gpt-5.6` and later models. By default, OpenAI automatically chooses one implicit cache breakpoint. You can add explicit breakpoints to content blocks with `prompt_cache_breakpoint`. Each request can write up to four breakpoints. For cache matching, OpenAI considers up to the latest 80 breakpoints in the conversation, without a content-block lookback limit. Set `mode` to `explicit` to disable the implicit breakpoint. The `ttl` defaults to `30m`, which is currently the only supported value. See the [prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching) for current details. - BetaAccessProgramsParam: - properties: - cyber: - $ref: '#/components/schemas/BetaCyberAccessProgramEnum' - description: The Cyber access program to use for this request. Supported values are `standard`, `daybreak_blue`, and `daybreak_red`. If omitted, the API resolves the program from the model's Cyber tier and your organization and project access, subject to model-specific eligibility restrictions. By default, models without a Cyber tier use Standard. Blue-tier models use Daybreak Blue when authorized; otherwise they fall back to Standard unless the model requires Daybreak access. Red-tier models use Daybreak Red and require authorization. Requests that require unavailable Daybreak access return 403. An implicit Standard fallback is represented by null in the response's access_programs field, rather than an explicit Standard selection. - additionalProperties: false - type: object - required: [] - description: Domain-specific access programs to use for this request. - BetaCreateModelResponseProperties: - allOf: - - $ref: '#/components/schemas/BetaModelResponseProperties' - - type: object - properties: - prompt_cache_options: - $ref: '#/components/schemas/BetaPromptCacheOptionsParam' - top_logprobs: - description: | - An integer between 0 and 20 specifying the maximum number of most likely - tokens to return at each token position, each with an associated log - probability. In some cases, the number of returned tokens may be fewer than - requested. - type: integer - minimum: 0 - maximum: 20 - BetaResponseStreamEvent: - description: Event emitted while a response is streamed. - anyOf: - - $ref: '#/components/schemas/BetaResponseAudioDeltaEvent' - - $ref: '#/components/schemas/BetaResponseAudioDoneEvent' - - $ref: '#/components/schemas/BetaResponseAudioTranscriptDeltaEvent' - - $ref: '#/components/schemas/BetaResponseAudioTranscriptDoneEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDeltaEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDoneEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInterpretingEvent' - - $ref: '#/components/schemas/BetaResponseCompactionCompactingStreamingEvent' - - $ref: '#/components/schemas/BetaResponseCompletedEvent' - - $ref: '#/components/schemas/BetaResponseContentPartAddedEvent' - - $ref: '#/components/schemas/BetaResponseContentPartDoneEvent' - - $ref: '#/components/schemas/BetaResponseCreatedEvent' - - $ref: '#/components/schemas/BetaResponseErrorEvent' - - $ref: '#/components/schemas/BetaResponseFileSearchCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseFileSearchCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseFileSearchCallSearchingEvent' - - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDeltaEvent' - - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDoneEvent' - - $ref: '#/components/schemas/BetaResponseShellCallCommandAddedStreamingEvent' - - $ref: '#/components/schemas/BetaResponseShellCallCommandDeltaStreamingEvent' - - $ref: '#/components/schemas/BetaResponseShellCallCommandDoneStreamingEvent' - - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDeltaStreamingEvent' - x-stainless-skip: - - go - x-openai-go-include-union-variant: true - - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDoneStreamingEvent' - - $ref: '#/components/schemas/BetaResponseInProgressEvent' - - $ref: '#/components/schemas/BetaResponseFailedEvent' - - $ref: '#/components/schemas/BetaResponseIncompleteEvent' - - $ref: '#/components/schemas/BetaResponseOutputItemAddedEvent' - - $ref: '#/components/schemas/BetaResponseOutputItemDoneEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartAddedEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartDoneEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDeltaEvent' - - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDoneEvent' - - $ref: '#/components/schemas/BetaResponseReasoningTextDeltaEvent' - - $ref: '#/components/schemas/BetaResponseReasoningTextDoneEvent' - - $ref: '#/components/schemas/BetaResponseRefusalDeltaEvent' - - $ref: '#/components/schemas/BetaResponseRefusalDoneEvent' - - $ref: '#/components/schemas/BetaResponseTextDeltaEvent' - - $ref: '#/components/schemas/BetaResponseTextDoneEvent' - - $ref: '#/components/schemas/BetaResponseWebSearchCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseWebSearchCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseWebSearchCallSearchingEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallGeneratingEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseImageGenCallPartialImageEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDeltaEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDoneEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallCompletedEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallFailedEvent' - - $ref: '#/components/schemas/BetaResponseMCPCallInProgressEvent' - - $ref: '#/components/schemas/BetaResponseMCPListToolsCompletedEvent' - - $ref: '#/components/schemas/BetaResponseMCPListToolsFailedEvent' - - $ref: '#/components/schemas/BetaResponseMCPListToolsInProgressEvent' - - $ref: '#/components/schemas/BetaResponseOutputTextAnnotationAddedEvent' - - $ref: '#/components/schemas/BetaResponseQueuedEvent' - - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDeltaEvent' - - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDoneEvent' - discriminator: - propertyName: type - BetaCachedUsageDetails: - properties: - cached_text_tokens: - type: integer - cached_image_tokens: - type: integer - type: object - required: - - cached_text_tokens - - cached_image_tokens - BetaImageGenInputUsageDetails: - properties: - text_tokens: - type: integer - description: The number of text tokens in the input prompt. - image_tokens: - type: integer - description: The number of image tokens in the input prompt. - type: object - required: - - text_tokens - - image_tokens - title: Input usage details - description: The input tokens detailed information for the image generation. - BetaImageGenOutputTokensDetails: - properties: - image_tokens: - type: integer - description: The number of image output tokens generated by the model. - text_tokens: - type: integer - description: The number of text output tokens generated by the model. - type: object - required: - - image_tokens - - text_tokens - title: Image generation output token details - description: The output token details for the image generation. - BetaImageGenToolUsage: - properties: - input_tokens: - type: integer - description: The number of tokens (images and text) in the input prompt. - total_tokens: - type: integer - description: The total number of tokens (images and text) used for the image generation. - output_tokens: - type: integer - description: The number of output tokens generated by the model. - output_tokens_details: - $ref: '#/components/schemas/BetaImageGenOutputTokensDetails' - input_tokens_details: - $ref: '#/components/schemas/BetaImageGenInputUsageDetails' - cached_tokens: - type: integer - cached_tokens_details: - $ref: '#/components/schemas/BetaCachedUsageDetails' - type: object - required: - - input_tokens - - total_tokens - - output_tokens - - input_tokens_details - title: Image generation tool usage - description: Token usage for image generation tool calls in a response. - BetaResponseToolUsage: - properties: - image_gen: - $ref: '#/components/schemas/BetaImageGenToolUsage' - description: Aggregated usage statistics for image generation tool calls. - web_search: - $ref: '#/components/schemas/BetaWebSearchUsage' - description: Aggregated billable usage statistics for web search tool calls. - type: object - required: - - image_gen - - web_search - title: Response tool usage - description: Aggregated usage details for billable tools that ran while generating the response. - BetaWebSearchUsage: - properties: - num_requests: - type: integer - description: The number of billable web search requests issued. - type: object - required: - - num_requests - title: Web search usage - description: Usage details for web search requests that ran while generating the response. - CachedUsageDetails: - properties: - cached_text_tokens: - type: integer - cached_image_tokens: - type: integer - type: object - required: - - cached_text_tokens - - cached_image_tokens - ImageGenToolUsage: - properties: - input_tokens: - type: integer - description: The number of tokens (images and text) in the input prompt. - total_tokens: - type: integer - description: The total number of tokens (images and text) used for the image generation. - output_tokens: - type: integer - description: The number of output tokens generated by the model. - output_tokens_details: - $ref: '#/components/schemas/ImageGenOutputTokensDetails' - input_tokens_details: - $ref: '#/components/schemas/ImageGenInputUsageDetails' - cached_tokens: - type: integer - cached_tokens_details: - $ref: '#/components/schemas/CachedUsageDetails' - type: object - required: - - input_tokens - - total_tokens - - output_tokens - - input_tokens_details - title: Image generation tool usage - description: Token usage for image generation tool calls in a response. - ResponseToolUsage: - properties: - image_gen: - $ref: '#/components/schemas/ImageGenToolUsage' - description: Aggregated usage statistics for image generation tool calls. - web_search: - $ref: '#/components/schemas/WebSearchUsage' - description: Aggregated billable usage statistics for web search tool calls. - type: object - required: - - image_gen - - web_search - title: Response tool usage - description: Aggregated usage details for billable tools that ran while generating the response. - WebSearchUsage: - properties: - num_requests: - type: integer - description: The number of billable web search requests issued. + - $ref: '#/components/schemas/BetaResponseImageGenCallCompletedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseImageGenCallWsGenerating + description: | + Emitted when an image generation tool call is actively generating an image (intermediate state). + allOf: + - $ref: '#/components/schemas/BetaResponseImageGenCallGeneratingEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseImageGenCallInWsProgress + description: | + Emitted when an image generation tool call is in progress. + allOf: + - $ref: '#/components/schemas/BetaResponseImageGenCallInProgressEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseImageGenCallPartialWsImage + description: | + Emitted when a partial image is available during image generation streaming. + allOf: + - $ref: '#/components/schemas/BetaResponseImageGenCallPartialImageEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpCallArgumentsWsDelta + description: | + Emitted when there is a delta (partial update) to the arguments of an MCP tool call. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDeltaEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpCallArgumentsWsDone + description: | + Emitted when the arguments for an MCP tool call are finalized. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDoneEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpCallWsCompleted + description: | + Emitted when an MCP tool call has completed successfully. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPCallCompletedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpCallWsFailed + description: | + Emitted when an MCP tool call has failed. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPCallFailedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpCallInWsProgress + description: | + Emitted when an MCP tool call is in progress. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPCallInProgressEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpListToolsWsCompleted + description: | + Emitted when the list of available MCP tools has been successfully retrieved. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPListToolsCompletedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpListToolsWsFailed + description: | + Emitted when the attempt to list available MCP tools has failed. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPListToolsFailedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseMcpListToolsInWsProgress + description: | + Emitted when the system is in the process of retrieving the list of available MCP tools. + allOf: + - $ref: '#/components/schemas/BetaResponseMCPListToolsInProgressEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseOutputTextAnnotationWsAdded + description: | + Emitted when an annotation is added to output text content. + allOf: + - $ref: '#/components/schemas/BetaResponseOutputTextAnnotationAddedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseWsQueued + description: | + Emitted when a response is queued and waiting to be processed. + allOf: + - $ref: '#/components/schemas/BetaResponseQueuedEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseCustomToolCallInputWsDelta + description: | + Event representing a delta (partial update) to the input of a custom tool call. + allOf: + - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDeltaEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - title: BetaResponseCustomToolCallInputWsDone + description: | + Event indicating that input for a custom tool call is complete. + allOf: + - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDoneEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + - $ref: '#/components/schemas/BetaResponseWsError' + - $ref: '#/components/schemas/BetaResponseSteerAcceptedEvent' + - $ref: '#/components/schemas/BetaResponseSteerPendingEvent' + - $ref: '#/components/schemas/BetaResponseSteerFailedEvent' + - $ref: '#/components/schemas/BetaResponseInjectCreatedEvent' + - $ref: '#/components/schemas/BetaResponseInjectFailedEvent' + BetaResponseInjectFailedEvent: type: object - required: - - num_requests - title: Web search usage - description: Usage details for web search requests that ran while generating the response. - AwsExternalKeyParams: + title: ResponseInjectFailedEvent + description: | + Emitted when injected input could not be committed to a response. The event + returns the uncommitted raw input so the client can retry it in another + response when appropriate. properties: - name: - type: string - description: A friendly name to identify this config type: type: string enum: - - aws - default: aws + - response.inject.failed + description: The event discriminator. Always `response.inject.failed`. x-stainless-const: true - role_arn: - type: string - pattern: ^arn:aws:iam::\d{12}:role/.+$ - description: The Role ARN that OpenAI will assume to call your KMS - kms_arn: + response_id: type: string - description: The Key Management System ARN for the master key you manage - external_id: + description: The ID of the response that rejected the input. + input: + type: array + description: The raw input items that were not committed. + items: + $ref: '#/components/schemas/BetaInputItem' + error: + type: object + description: Information about why the input was not committed. + properties: + code: + type: string + enum: + - response_already_completed + - response_not_found + description: A machine-readable error code. + message: + type: string + description: A human-readable description of the error. + required: + - code + - message + sequence_number: + type: integer + description: The sequence number for this event. + stream_id: type: string - description: The ExternalID that OpenAI will pass in the AssumeRole call for the customer's AWS account. This must be the OpenAI organization ID or an API project ID that belongs to the organization. - purpose: - $ref: '#/components/schemas/ExternalKeyPurpose' - type: object + description: | + The multiplexed WebSocket stream that emitted the event. This field is + present only when WebSocket multiplexing is enabled separately. required: - - name - type - - role_arn - - kms_arn - - external_id - AzureExternalKeyParams: + - response_id + - input + - error + - sequence_number + x-oai-beta: responses_multi_agent=v1 + x-oaiMeta: + example: | + { + "type": "response.inject.failed", + "response_id": "resp_123", + "input": [ + { + "type": "function_call_output", + "call_id": "call_123", + "output": "{\"temperature\":72}" + } + ], + "error": { + "code": "response_already_completed", + "message": "Response 'resp_123' has already completed." + }, + "sequence_number": 9 + } + BetaResponseInjectCreatedEvent: + type: object + title: ResponseInjectCreatedEvent + description: | + Emitted when all injected input items were validated and committed to the + active response. properties: - name: - type: string - description: A friendly name to identify this config type: type: string enum: - - azure - default: azure + - response.inject.created + description: The event discriminator. Always `response.inject.created`. x-stainless-const: true - tenant_id: - type: string - pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ - description: Your Azure tenant UUID - vault_uri: - type: string - pattern: ^https://.+\.vault\.azure\.net/?$ - description: The URI of the Azure vault containing the master key you manage - key_name: - type: string - maxLength: 127 - pattern: ^org-[0-9a-zA-Z-]*--[0-9a-zA-Z-]*$ - description: The name of the Azure Key Vault master key that you manage - purpose: - $ref: '#/components/schemas/ExternalKeyPurpose' - key_version: - type: string - pattern: ^[0-9a-fA-F]{32}$ - description: An exact Azure Key Vault key version. Required for PSP decryption; not accepted for EKM. - type: object - required: - - name - - type - - tenant_id - - vault_uri - - key_name - ExternalKeyPurpose: - type: string - enum: - - ekm - - psp_decryption - ExternalKeyResource: - properties: - object: - type: string - id: - type: string - type: - type: string - purpose: - $ref: '#/components/schemas/ExternalKeyPurpose' - name: - type: string - api_project_ids: - items: - type: string - type: array - workspace_id: + response_id: type: string - created_at: + description: The ID of the response that accepted the input. + sequence_number: type: integer - role_arn: - type: string - external_id: - type: string - kms_arn: - type: string - workload_identity_project_number: - type: string - workload_identity_pool_id: - type: string - workload_identity_provider_id: - type: string - audience: - type: string - kms_project_id: - type: string - kms_key_ring_name: - type: string - kms_key_name: - type: string - kms_key_location: - type: string - kms_key_version: - type: string - tenant_id: - type: string - vault_uri: - type: string - key_name: - type: string - key_version: + description: The sequence number for this event. + stream_id: type: string - type: object + description: | + The multiplexed WebSocket stream that emitted the event. This field is + present only when WebSocket multiplexing is enabled separately. required: - - object - - id - type - - name - - api_project_ids - - workspace_id - - created_at - GcpExternalKeyParams: + - response_id + - sequence_number + x-oai-beta: responses_multi_agent=v1 + x-oaiMeta: + example: | + { + "type": "response.inject.created", + "response_id": "resp_123", + "sequence_number": 8 + } + BetaResponseWsError: + type: object + title: ResponseWsError + description: Emitted when an error occurs while processing a Responses WebSocket request. properties: - name: - type: string - description: A friendly name to identify this config + agent: + anyOf: + - $ref: '#/components/schemas/BetaAgentTag' + - type: 'null' + description: The agent that owns this multi-agent streaming event. type: type: string + description: The type of the event. Always `error`. enum: - - gcp - default: gcp + - error x-stainless-const: true - workload_identity_project_number: - type: string - pattern: ^[0-9]{1,19}$ - description: The numeric GCP project number where you registered OpenAI's workload identity - workload_identity_pool_id: - type: string - description: The pool containing the Workload Identity provider that you registered for OpenAI - workload_identity_provider_id: - type: string - description: The Workload Identity provider that you registered for OpenAI - kms_project_id: - type: string - description: The string GCP project name where your KMS resides - kms_key_ring_name: - type: string - description: The Key Management System key ring for the master key you manage - kms_key_name: - type: string - description: The name of the Key Management System master key you manage - kms_key_location: - type: string - description: The region where your Key Management System master key is located - audience: - type: string - description: The aud field in the identity token OpenAI will pass in the call to the customer's GCP STS. This must be the OpenAI organization ID or an API project ID that belongs to the organization. - purpose: - $ref: '#/components/schemas/ExternalKeyPurpose' - kms_key_version: + status: + type: integer + description: The HTTP status code associated with a WebSocket protocol error. + sequence_number: + type: integer + description: The sequence number of an error emitted by the response stream. + error: + $ref: '#/components/schemas/BetaErrorPayload' + description: Details about the error. + stream_id: type: string - pattern: ^[1-9][0-9]*$ - description: An exact CryptoKeyVersion number. Required for PSP decryption; not accepted for EKM. - type: object + description: | + The WebSocket lane that emitted this event. This field is present when the + originating `response.create` event supplied a `stream_id`. required: - - name - type - - workload_identity_project_number - - workload_identity_pool_id - - workload_identity_provider_id - - kms_project_id - - kms_key_ring_name - - kms_key_name - - kms_key_location - - audience - ListExternalKeysResource: - properties: - object: - type: string - enum: - - list - default: list - x-stainless-const: true - data: - items: - $ref: '#/components/schemas/ExternalKeyResource' - type: array - first_id: + - error + x-oaiMeta: + name: error + group: responses + example: | + { + "type": "error", + "status": 400, + "stream_id": "agent_1", + "error": { + "type": "invalid_request_error", + "code": "websocket_stream_limit_reached", + "message": "This WebSocket connection has reached its stream limit.", + "param": "stream_id" + } + } + BetaErrorPayload: + properties: + type: + type: string + description: The error type that was emitted. + code: anyOf: - type: string + description: The error code that was emitted, if any. - type: 'null' - last_id: + message: + type: string + description: The human-readable error message that was emitted. + param: anyOf: - type: string + description: The parameter name that was associated with the error, if any. - type: 'null' - has_more: - type: boolean + headers: + additionalProperties: + type: string + description: The header value that was emitted. + type: object + description: The response headers that were emitted with the error, if any. + x-oaiTypeLabel: map + misalignment: + $ref: '#/components/schemas/BetaMisalignmentErrorDetailsResource' type: object required: - - object - - data - - first_id - - last_id - - has_more - V1OrganizationExternalKeysPostRequest: + - type + - code + - message + - param + title: Error payload + description: An error payload that was emitted for a streaming error event. + BetaResponsesWebSocketStreamEvent: + allOf: + - anyOf: + - $ref: '#/components/schemas/BetaResponseAudioDeltaEvent' + - $ref: '#/components/schemas/BetaResponseAudioDoneEvent' + - $ref: '#/components/schemas/BetaResponseAudioTranscriptDeltaEvent' + - $ref: '#/components/schemas/BetaResponseAudioTranscriptDoneEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDeltaEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDoneEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInterpretingEvent' + - $ref: '#/components/schemas/BetaResponseCompactionCompactingStreamingEvent' + - $ref: '#/components/schemas/BetaResponseCompletedEvent' + - $ref: '#/components/schemas/BetaResponseContentPartAddedEvent' + - $ref: '#/components/schemas/BetaResponseContentPartDoneEvent' + - $ref: '#/components/schemas/BetaResponseCreatedEvent' + - $ref: '#/components/schemas/BetaResponseFileSearchCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseFileSearchCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseFileSearchCallSearchingEvent' + - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDeltaEvent' + - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDoneEvent' + - $ref: '#/components/schemas/BetaResponseShellCallCommandAddedStreamingEvent' + - $ref: '#/components/schemas/BetaResponseShellCallCommandDeltaStreamingEvent' + - $ref: '#/components/schemas/BetaResponseShellCallCommandDoneStreamingEvent' + - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDeltaStreamingEvent' + x-stainless-skip: + - go + - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDoneStreamingEvent' + - $ref: '#/components/schemas/BetaResponseInProgressEvent' + - $ref: '#/components/schemas/BetaResponseFailedEvent' + - $ref: '#/components/schemas/BetaResponseIncompleteEvent' + - $ref: '#/components/schemas/BetaResponseOutputItemAddedEvent' + - $ref: '#/components/schemas/BetaResponseOutputItemDoneEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartAddedEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartDoneEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDeltaEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDoneEvent' + - $ref: '#/components/schemas/BetaResponseReasoningTextDeltaEvent' + - $ref: '#/components/schemas/BetaResponseReasoningTextDoneEvent' + - $ref: '#/components/schemas/BetaResponseRefusalDeltaEvent' + - $ref: '#/components/schemas/BetaResponseRefusalDoneEvent' + - $ref: '#/components/schemas/BetaResponseTextDeltaEvent' + - $ref: '#/components/schemas/BetaResponseTextDoneEvent' + - $ref: '#/components/schemas/BetaResponseWebSearchCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseWebSearchCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseWebSearchCallSearchingEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallGeneratingEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallPartialImageEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDeltaEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDoneEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallFailedEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseMCPListToolsCompletedEvent' + - $ref: '#/components/schemas/BetaResponseMCPListToolsFailedEvent' + - $ref: '#/components/schemas/BetaResponseMCPListToolsInProgressEvent' + - $ref: '#/components/schemas/BetaResponseOutputTextAnnotationAddedEvent' + - $ref: '#/components/schemas/BetaResponseQueuedEvent' + - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDeltaEvent' + - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDoneEvent' + - type: object + properties: + stream_id: + type: string + description: | + The WebSocket lane that emitted this event. This field is present + when the originating `response.create` event supplied a + `stream_id`. + BetaResponsesClientEvent: discriminator: propertyName: type + description: | + Client events accepted by the Responses WebSocket server. anyOf: - - $ref: '#/components/schemas/AwsExternalKeyParams' - - $ref: '#/components/schemas/GcpExternalKeyParams' - - $ref: '#/components/schemas/AzureExternalKeyParams' - ValidateExternalKeyResource: - properties: - status: - type: string - type: object - required: - - status - LiveTransportDTMFSendParam: - description: Send one DTMF keypress to the SIP trunk through a Live sideband connection. This command is supported only for SIP calls. A successful send emits `transport.dtmf.sent` on sideband. If you provide `event_id`, the success notification includes it as `client_event_id`; a failure includes it as `error.client_event_id`. Send separate events for multiple keypresses. - example: - type: transport.dtmf.send - event_id: evt_dtmf_001 - event: '#' + - $ref: '#/components/schemas/BetaResponsesClientEventResponseCreate' + - $ref: '#/components/schemas/BetaResponseSteerEvent' + - $ref: '#/components/schemas/BetaResponseInjectEvent' + BetaResponseInjectEvent: type: object + title: ResponseInjectEvent + description: | + Injects input items into an active response over a WebSocket connection. + The items are validated and committed atomically. Currently, the server + accepts client-owned tool outputs that resume a waiting agent. properties: - event_id: - anyOf: - - description: Optional client identifier for correlating this command with a server event's client_event_id or error.client_event_id. - type: string - maxLength: 512 - - type: 'null' type: - default: transport.dtmf.send - x-stainless-const: true type: string enum: - - transport.dtmf.send - event: - description: 'The DTMF key to send: one of `0`–`9`, `A`–`D`, `*`, or `#`.' + - response.inject + description: The event discriminator. Always `response.inject`. + x-stainless-const: true + response_id: type: string - minLength: 1 - maxLength: 1 - pattern: ^[0-9A-D*#]$ + description: The ID of the active response that should receive the input. + input: + type: array + maxItems: 16384 + description: Input items to inject into the active response. + items: + $ref: '#/components/schemas/BetaInputItem' required: - type - - event + - response_id + - input + x-oai-beta: responses_multi_agent=v1 x-oaiMeta: - example: - type: transport.dtmf.send - event_id: evt_dtmf_001 - event: '#' - LiveTransportDTMFSent: - description: A SIP DTMF keypress successfully sent to the SIP trunk. Delivered only to sideband observers; this is not a client command. - example: - type: transport.dtmf.sent - event_id: event_dtmf_2 - event: '#' + example: | + { + "type": "response.inject", + "response_id": "resp_123", + "input": [ + { + "type": "function_call_output", + "call_id": "call_123", + "output": "{\"temperature\":72}" + } + ] + } + BetaResponsesClientEventResponseCreate: + allOf: + - type: object + properties: + type: + type: string + enum: + - response.create + description: | + The type of the client event. Always `response.create`. + x-stainless-const: true + stream_id: + type: string + minLength: 1 + maxLength: 256 + pattern: ^[A-Za-z0-9_.-]+$ + description: | + The WebSocket lane for this response. Requests with the same + `stream_id` are processed FIFO, and events for the response echo the + same `stream_id`. + + `stream_id` controls routing; `previous_response_id` controls + conversation lineage, so a new lane can fork from a response created + on another lane. + required: + - type + - $ref: '#/components/schemas/BetaCreateResponse' + description: | + Client event for creating a response over a persistent WebSocket connection. + This payload uses the same top-level fields as `POST /v1/responses`, plus + WebSocket-only envelope metadata. + + Notes: + - `stream` is implicit over WebSocket and should not be sent. + - `background` is not supported over WebSocket. + - `stream_id` is WebSocket-only and is not part of `POST /v1/responses`. + x-oaiMeta: + example: | + { + "type": "response.create", + "stream_id": "agent_1", + "model": "gpt-6-astra", + "input": "Say hello." + } + BetaCreateResponse: + allOf: + - $ref: '#/components/schemas/BetaCreateModelResponseProperties' + - $ref: '#/components/schemas/BetaResponseProperties' + - type: object + properties: + access_programs: + $ref: '#/components/schemas/BetaAccessProgramsParam' + prompt_cache_options: + $ref: '#/components/schemas/BetaResponsePromptCacheOptionsParam' + service_tier: + $ref: '#/components/schemas/BetaServiceTierResponses' + truncation: + deprecated: true + anyOf: + - type: string + description: | + The truncation strategy to use for the model response. + - `auto`: If the input to this Response exceeds + the model's context window size, the model will truncate the + response to fit the context window by dropping items from the beginning of the conversation. + - `disabled` (default): If the input size will exceed the context window + size for a model, the request will fail with a 400 error. + enum: + - auto + - disabled + default: disabled + - type: 'null' + reasoning: + anyOf: + - $ref: '#/components/schemas/BetaReasoning' + - type: 'null' + input: + $ref: '#/components/schemas/BetaInputParam' + include: + anyOf: + - type: array + description: |- + Specify additional output data to include in the model response. Currently supported values are: + - `web_search_call.action.sources`: Include the sources of the web search tool call. + - `code_interpreter_call.outputs`: Includes the outputs of python code execution in code interpreter tool call items. + - `computer_call_output.output.image_url`: Include image urls from the computer call output. + - `file_search_call.results`: Include the search results of the file search tool call. + - `message.input_image.image_url`: Include image urls from the input message. + - `message.output_text.logprobs`: Include logprobs with assistant messages. + - `reasoning.encrypted_content`: Includes an encrypted version of reasoning tokens in reasoning item outputs. This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly (like when the `store` parameter is set to `false`, or when an organization is enrolled in the zero data retention program). + items: + $ref: '#/components/schemas/BetaIncludeEnum' + - type: 'null' + parallel_tool_calls: + anyOf: + - type: boolean + description: | + Whether to allow the model to run tool calls in parallel. + default: true + - type: 'null' + store: + anyOf: + - type: boolean + description: | + Whether to store the generated model response for later retrieval via + API. + Defaults to true when omitted. + If set to true, response data will be stored for at least 30 days, subject to the [data retention exceptions](https://developers.openai.com/api/docs/guides/your-data#v1responses). + default: true + - type: 'null' + instructions: + anyOf: + - type: string + description: | + A system (or developer) message inserted into the model's context. + + When using along with `previous_response_id`, the instructions from a previous + response will not be carried over to the next response. This makes it simple + to swap out system (or developer) messages in new responses. + - type: 'null' + moderation: + anyOf: + - $ref: '#/components/schemas/BetaModerationParam' + description: | + Configuration for running moderation on the input and output of this response. + - type: 'null' + stream: + anyOf: + - description: | + If set to true, the model response data will be streamed to the client + as it is generated using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format). + See the [Streaming section below](https://developers.openai.com/api/reference/resources/responses/streaming-events) + for more information. + type: boolean + default: false + - type: 'null' + stream_options: + $ref: '#/components/schemas/BetaResponseStreamOptions' + conversation: + anyOf: + - $ref: '#/components/schemas/BetaConversationParam' + - type: 'null' + context_management: + anyOf: + - type: array + example: + - type: compaction + compact_threshold: 1000 + description: | + Context management configuration for this request. + minItems: 1 + items: + $ref: '#/components/schemas/BetaContextManagementParam' + - type: 'null' + max_output_tokens: + anyOf: + - description: | + An upper bound for the number of tokens that can be generated for a response, including visible output tokens and [reasoning tokens](https://developers.openai.com/api/docs/guides/reasoning). + type: integer + minimum: 16 + - type: 'null' + multi_agent: + anyOf: + - $ref: '#/components/schemas/BetaMultiAgentParam' + description: Configuration for server-hosted multi-agent execution. + - type: 'null' + x-openai-go-legacy-bool-fields: + - enabled + BetaMultiAgentParam: + properties: + enabled: + type: boolean + description: Whether to enable server-hosted multi-agent execution for this response. + max_concurrent_subagents: + type: integer + minimum: 1 + description: |- + `max_concurrent_subagents` sets the maximum number of subagents that can be active simultaneously across the entire agent tree. It includes all descendants—children, grandchildren, and deeper subagents—but excludes the root agent. + The API does not impose a fixed upper bound on this setting. The default is `3`, which is recommended for most workloads. Multi-agent runs also have no fixed limit on tree depth or the total number of subagents created during a run. type: object + required: + - enabled + description: Configuration for server-hosted multi-agent execution. + x-oai-beta: responses_multi_agent=v1 + BetaContextManagementParam: properties: type: - default: transport.dtmf.sent - x-stainless-const: true - type: string - enum: - - transport.dtmf.sent - event_id: - type: string - event: - type: string - minLength: 1 - maxLength: 1 - pattern: ^[0-9A-D*#]$ - client_event_id: - description: The event_id of the client command, when supplied. type: string + description: The context management entry type. Currently only 'compaction' is supported. + compact_threshold: + anyOf: + - type: integer + minimum: 1000 + description: Token threshold at which compaction should be triggered for this entry. + - type: 'null' + type: object required: - type - - event_id - - event - x-oaiMeta: - example: - type: transport.dtmf.sent - event_id: event_dtmf_2 - event: '#' - CompactionParam: - type: object + BetaResponseStreamOptions: + anyOf: + - description: | + Options for streaming responses. Only set this when you set `stream: true`. + type: object + properties: + include_obfuscation: + type: boolean + description: | + When true, stream obfuscation will be enabled. Stream obfuscation adds + random characters to an `obfuscation` field on streaming delta events to + normalize payload sizes as a mitigation to certain side-channel attacks. + These obfuscation fields are included by default, but add a small amount + of overhead to the data stream. You can set `include_obfuscation` to + false to optimize for bandwidth if you trust the network links between + your application and the OpenAI API. + - type: 'null' + BetaModerationParam: properties: - async: - type: - - boolean - - 'null' - description: Whether to compact context in the background. Omission or null defaults to true. Set to false to use blocking compaction. Background compaction may increase token usage. - additionalProperties: false - description: Settings for compacting the agent's context. - CompactionResource: + model: + type: string + description: The moderation model to use for moderated completions, e.g. 'omni-moderation-latest'. + policy: + anyOf: + - $ref: '#/components/schemas/BetaModerationPolicyParam' + description: The policy to apply to moderated response input and output. + - type: 'null' type: object - properties: - async: - type: boolean - description: Whether context is compacted in the background. Defaults to true. required: - - async - additionalProperties: false - description: The effective settings for compacting the agent's context. - markup: - type: string - format: binary - description: A transparent PNG overlaid on the first image to guide the edit. Must match that image's dimensions after orientation and contain transparent pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. - ExperimentalConfigOverridesParam: - type: object + - model + description: Configuration for running moderation on the input and output of this response. + BetaModerationPolicyParam: properties: - time_awareness: - type: boolean - description: Whether to give the model periodic current-time reminders. Omit to keep the current setting. - additionalProperties: false - description: Experimental settings to update. Omitted settings keep their current values. - ExperimentalConfigParam: + input: + anyOf: + - $ref: '#/components/schemas/BetaModerationConfigParam' + description: The moderation policy for the response input. + - type: 'null' + output: + anyOf: + - $ref: '#/components/schemas/BetaModerationConfigParam' + description: The moderation policy for the response output. + - type: 'null' type: object + required: [] + description: The policy to apply to moderated response input and output. + BetaModerationConfigParam: properties: - time_awareness: - type: boolean - default: false - description: Whether to give the model periodic current-time reminders. Defaults to false. - additionalProperties: false - description: Experimental agent settings. These features may change. - ExperimentalConfigResource: + mode: + $ref: '#/components/schemas/BetaModerationMode' type: object + required: + - mode + description: The moderation policy for the response input. + BetaModerationMode: + type: string + enum: + - score + - block + BetaResponsePromptCacheOptionsParam: properties: - time_awareness: + ttl: + $ref: '#/components/schemas/BetaPromptCacheTTLEnum' + description: The minimum lifetime applied to every implicit and explicit cache breakpoint written by the request. Defaults to `30m`, which is currently the only supported value. The backend may retain cache entries for longer. + mode: + $ref: '#/components/schemas/BetaPromptCacheModeEnum' + description: Controls whether OpenAI automatically creates an implicit cache breakpoint. Defaults to `implicit`. With `implicit`, OpenAI creates one implicit breakpoint and writes up to the latest three explicit breakpoints in the request. With `explicit`, OpenAI does not create an implicit breakpoint and writes up to the latest four explicit breakpoints. If there are no explicit breakpoints, the request does not use prompt caching. + prewarm: type: boolean - description: Whether the model receives periodic current-time reminders. - required: - - time_awareness + description: Prepares the prompt cache without generating output. Defaults to `false`. When set to `true`, overrides the `generate` field to `false`. + comparison_response_id: + anyOf: + - type: string + description: The ID of a response to compare when diagnosing prompt cache reuse. Supplying this field requests prompt cache diagnostics when the feature is enabled. + example: resp_123 + - type: 'null' + type: object + required: [] + title: Prompt cache options + description: Options for prompt caching. Supported for `gpt-5.6` and later models. By default, OpenAI automatically chooses one implicit cache breakpoint. You can add explicit breakpoints to content blocks with `prompt_cache_breakpoint`. Each request can write up to four breakpoints. For cache matching, OpenAI considers up to the latest 80 breakpoints in the conversation, without a content-block lookback limit. Set `mode` to `explicit` to disable the implicit breakpoint. The `ttl` defaults to `30m`, which is currently the only supported value. See the [prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching) for current details. + BetaAccessProgramsParam: + properties: + cyber: + $ref: '#/components/schemas/BetaCyberAccessProgramEnum' + description: The Cyber access program to use for this request. Supported values are `standard`, `daybreak_blue`, and `daybreak_red`. If omitted, the API resolves the program from the model's Cyber tier and your organization and project access, subject to model-specific eligibility restrictions. By default, models without a Cyber tier use Standard. Blue-tier models use Daybreak Blue when authorized; otherwise they fall back to Standard unless the model requires Daybreak access. Red-tier models use Daybreak Red and require authorization. Requests that require unavailable Daybreak access return 403. An implicit Standard fallback is represented by null in the response's access_programs field, rather than an explicit Standard selection. additionalProperties: false - description: The effective experimental settings for an agent. + type: object + required: [] + description: Domain-specific access programs to use for this request. + BetaCreateModelResponseProperties: + allOf: + - $ref: '#/components/schemas/BetaModelResponseProperties' + - type: object + properties: + prompt_cache_options: + $ref: '#/components/schemas/BetaPromptCacheOptionsParam' + top_logprobs: + description: | + An integer between 0 and 20 specifying the maximum number of most likely + tokens to return at each token position, each with an associated log + probability. In some cases, the number of returned tokens may be fewer than + requested. + type: integer + minimum: 0 + maximum: 20 + BetaResponseStreamEvent: + description: Event emitted while a response is streamed. + anyOf: + - $ref: '#/components/schemas/BetaResponseAudioDeltaEvent' + - $ref: '#/components/schemas/BetaResponseAudioDoneEvent' + - $ref: '#/components/schemas/BetaResponseAudioTranscriptDeltaEvent' + - $ref: '#/components/schemas/BetaResponseAudioTranscriptDoneEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDeltaEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCodeDoneEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseCodeInterpreterCallInterpretingEvent' + - $ref: '#/components/schemas/BetaResponseCompactionCompactingStreamingEvent' + - $ref: '#/components/schemas/BetaResponseCompletedEvent' + - $ref: '#/components/schemas/BetaResponseContentPartAddedEvent' + - $ref: '#/components/schemas/BetaResponseContentPartDoneEvent' + - $ref: '#/components/schemas/BetaResponseCreatedEvent' + - $ref: '#/components/schemas/BetaResponseErrorEvent' + - $ref: '#/components/schemas/BetaResponseFileSearchCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseFileSearchCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseFileSearchCallSearchingEvent' + - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDeltaEvent' + - $ref: '#/components/schemas/BetaResponseFunctionCallArgumentsDoneEvent' + - $ref: '#/components/schemas/BetaResponseShellCallCommandAddedStreamingEvent' + - $ref: '#/components/schemas/BetaResponseShellCallCommandDeltaStreamingEvent' + - $ref: '#/components/schemas/BetaResponseShellCallCommandDoneStreamingEvent' + - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDeltaStreamingEvent' + x-stainless-skip: + - go + x-openai-go-include-union-variant: true + - $ref: '#/components/schemas/BetaResponseShellCallOutputContentDoneStreamingEvent' + - $ref: '#/components/schemas/BetaResponseInProgressEvent' + - $ref: '#/components/schemas/BetaResponseFailedEvent' + - $ref: '#/components/schemas/BetaResponseIncompleteEvent' + - $ref: '#/components/schemas/BetaResponseOutputItemAddedEvent' + - $ref: '#/components/schemas/BetaResponseOutputItemDoneEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartAddedEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryPartDoneEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDeltaEvent' + - $ref: '#/components/schemas/BetaResponseReasoningSummaryTextDoneEvent' + - $ref: '#/components/schemas/BetaResponseReasoningTextDeltaEvent' + - $ref: '#/components/schemas/BetaResponseReasoningTextDoneEvent' + - $ref: '#/components/schemas/BetaResponseRefusalDeltaEvent' + - $ref: '#/components/schemas/BetaResponseRefusalDoneEvent' + - $ref: '#/components/schemas/BetaResponseTextDeltaEvent' + - $ref: '#/components/schemas/BetaResponseTextDoneEvent' + - $ref: '#/components/schemas/BetaResponseWebSearchCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseWebSearchCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseWebSearchCallSearchingEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallGeneratingEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseImageGenCallPartialImageEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDeltaEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallArgumentsDoneEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallCompletedEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallFailedEvent' + - $ref: '#/components/schemas/BetaResponseMCPCallInProgressEvent' + - $ref: '#/components/schemas/BetaResponseMCPListToolsCompletedEvent' + - $ref: '#/components/schemas/BetaResponseMCPListToolsFailedEvent' + - $ref: '#/components/schemas/BetaResponseMCPListToolsInProgressEvent' + - $ref: '#/components/schemas/BetaResponseOutputTextAnnotationAddedEvent' + - $ref: '#/components/schemas/BetaResponseQueuedEvent' + - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDeltaEvent' + - $ref: '#/components/schemas/BetaResponseCustomToolCallInputDoneEvent' + discriminator: + propertyName: type SessionSpendControlResource: type: object properties: @@ -111247,13 +111924,6 @@ components: - $ref: '#/components/schemas/BetaInputTextContentParam' - $ref: '#/components/schemas/BetaInputImageContentParamAutoParam' - $ref: '#/components/schemas/BetaInputFileContentParam' - RealtimeConnectParams: - type: object - properties: - model: - type: string - call_id: - type: string ModerationImageURLInput: type: object description: An object describing an image to classify. @@ -111472,6 +112142,10 @@ components: message: 'Announced shutdown date: 2027-04-01. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-5.1: message: 'Announced shutdown date: 2027-04-01. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5.1-2025-11-13: + message: 'Announced shutdown date: 2027-04-01. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' + gpt-5.4-nano-2026-03-17: + message: 'Announced shutdown date: 2027-04-01. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-4.1-2025-04-14: message: 'Announced shutdown date: 2027-01-14. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-4.1: @@ -111610,10 +112284,6 @@ components: message: 'Announced shutdown date: 2027-01-20. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' gpt-5.1-mini: message: Not a supported model ID. Retained for SDK compatibility. - gpt-5.1-2025-11-13: - message: 'Announced shutdown date: 2027-04-01. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-5.4-nano-2026-03-17: - message: 'Announced shutdown date: 2027-04-01. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' x-stainless-nominal: false LiveBuiltInVoice: type: string @@ -111642,411 +112312,6 @@ components: - vesper - willow description: A built-in voice available for Live speech. - Summary: - properties: - type: - type: string - enum: - - summary_text - description: The type of the object. Always `summary_text`. - default: summary_text - x-stainless-const: true - text: - type: string - description: A summary of the reasoning output from the model so far. - type: object - required: - - type - - text - title: Summary text - description: A summary text from the model. - CreateThreadAndRunRequestWithoutStream: - type: object - additionalProperties: false - properties: - assistant_id: - description: The ID of the [assistant](https://developers.openai.com/api/docs/assistants/migration) to use to execute this run. - type: string - thread: - $ref: '#/components/schemas/CreateThreadRequest' - model: - description: The ID of the [Model](https://developers.openai.com/api/reference/resources/models) to be used to execute this run. If a value is provided here, it will override the model associated with the assistant. If not, the model associated with the assistant will be used. - example: gpt-4.1 - anyOf: - - type: string - - type: string - enum: - - gpt-5 - - gpt-5-mini - - gpt-5-nano - - gpt-5-2025-08-07 - - gpt-5-mini-2025-08-07 - - gpt-5-nano-2025-08-07 - - gpt-4.1 - - gpt-4.1-mini - - gpt-4.1-nano - - gpt-4.1-2025-04-14 - - gpt-4.1-mini-2025-04-14 - - gpt-4.1-nano-2025-04-14 - - gpt-4o - - gpt-4o-2024-11-20 - - gpt-4o-2024-08-06 - - gpt-4o-2024-05-13 - - gpt-4o-mini - - gpt-4o-mini-2024-07-18 - - gpt-4.5-preview - - gpt-4.5-preview-2025-02-27 - - gpt-4-turbo - - gpt-4-turbo-2024-04-09 - - gpt-4-0125-preview - - gpt-4-turbo-preview - - gpt-4-1106-preview - - gpt-4-vision-preview - - gpt-4 - - gpt-4-0314 - - gpt-4-0613 - - gpt-4-32k - - gpt-4-32k-0314 - - gpt-4-32k-0613 - - gpt-3.5-turbo - - gpt-3.5-turbo-16k - - gpt-3.5-turbo-0613 - - gpt-3.5-turbo-1106 - - gpt-3.5-turbo-0125 - - gpt-3.5-turbo-16k-0613 - x-openai-enum-deprecations: - gpt-5: - message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-5-mini: - message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-5-nano: - message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-5-2025-08-07: - message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-5-mini-2025-08-07: - message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - gpt-5-nano-2025-08-07: - message: 'Announced shutdown date: 2026-12-11. See https://developers.openai.com/api/docs/deprecations for details and recommended replacements.' - x-oaiTypeLabel: string - nullable: true - instructions: - description: Override the default system message of the assistant. This is useful for modifying the behavior on a per-run basis. - type: - - string - - 'null' - tools: - description: Override the tools the assistant can use for this run. This is useful for modifying the behavior on a per-run basis. - type: - - array - - 'null' - maxItems: 20 - items: - $ref: '#/components/schemas/AssistantTool' - tool_resources: - type: - - object - - 'null' - description: | - A set of resources that are used by the assistant's tools. The resources are specific to the type of tool. For example, the `code_interpreter` tool requires a list of file IDs, while the `file_search` tool requires a list of vector store IDs. - properties: - code_interpreter: - type: object - properties: - file_ids: - type: array - description: | - A list of [file](https://developers.openai.com/api/reference/resources/files) IDs made available to the `code_interpreter` tool. There can be a maximum of 20 files associated with the tool. - default: [] - maxItems: 20 - items: - type: string - file_search: - type: object - properties: - vector_store_ids: - type: array - description: | - The ID of the [vector store](https://developers.openai.com/api/reference/resources/vector_stores) attached to this assistant. There can be a maximum of 1 vector store attached to the assistant. - maxItems: 1 - items: - type: string - metadata: - $ref: '#/components/schemas/Metadata' - temperature: - type: - - number - - 'null' - minimum: 0 - maximum: 2 - default: 1 - example: 1 - description: | - What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. - top_p: - type: - - number - - 'null' - minimum: 0 - maximum: 1 - default: 1 - example: 1 - description: | - An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. - - We generally recommend altering this or temperature but not both. - response_format: - $ref: '#/components/schemas/AssistantsApiResponseFormatOption' - nullable: true - max_prompt_tokens: - type: - - integer - - 'null' - description: | - The maximum number of prompt tokens that may be used over the course of the run. The run will make a best effort to use only the number of prompt tokens specified, across multiple turns of the run. If the run exceeds the number of prompt tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. - minimum: 256 - max_completion_tokens: - type: - - integer - - 'null' - description: | - The maximum number of completion tokens that may be used over the course of the run. The run will make a best effort to use only the number of completion tokens specified, across multiple turns of the run. If the run exceeds the number of completion tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. - minimum: 256 - truncation_strategy: - allOf: - - $ref: '#/components/schemas/TruncationObject' - - nullable: true - tool_choice: - allOf: - - $ref: '#/components/schemas/AssistantsApiToolChoiceOption' - - nullable: true - parallel_tool_calls: - $ref: '#/components/schemas/ParallelToolCalls' - required: - - assistant_id - CreateRunRequestWithoutStream: - type: object - additionalProperties: false - properties: - assistant_id: - description: The ID of the [assistant](https://developers.openai.com/api/docs/assistants/migration) to use to execute this run. - type: string - model: - description: The ID of the [Model](https://developers.openai.com/api/reference/resources/models) to be used to execute this run. If a value is provided here, it will override the model associated with the assistant. If not, the model associated with the assistant will be used. - example: gpt-4.1 - anyOf: - - type: string - - $ref: '#/components/schemas/AssistantSupportedModels' - x-oaiTypeLabel: string - nullable: true - reasoning_effort: - $ref: '#/components/schemas/ReasoningEffort' - instructions: - description: Overrides the [instructions](https://developers.openai.com/api/docs/assistants/migration) of the assistant. This is useful for modifying the behavior on a per-run basis. - type: - - string - - 'null' - additional_instructions: - description: Appends additional instructions at the end of the instructions for the run. This is useful for modifying the behavior on a per-run basis without overriding other instructions. - type: - - string - - 'null' - additional_messages: - description: Adds additional messages to the thread before creating the run. - type: - - array - - 'null' - items: - $ref: '#/components/schemas/CreateMessageRequest' - tools: - description: Override the tools the assistant can use for this run. This is useful for modifying the behavior on a per-run basis. - type: - - array - - 'null' - maxItems: 20 - items: - $ref: '#/components/schemas/AssistantTool' - metadata: - $ref: '#/components/schemas/Metadata' - temperature: - type: - - number - - 'null' - minimum: 0 - maximum: 2 - default: 1 - example: 1 - description: | - What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. - top_p: - type: - - number - - 'null' - minimum: 0 - maximum: 1 - default: 1 - example: 1 - description: | - An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. - - We generally recommend altering this or temperature but not both. - response_format: - $ref: '#/components/schemas/AssistantsApiResponseFormatOption' - nullable: true - max_prompt_tokens: - type: - - integer - - 'null' - description: | - The maximum number of prompt tokens that may be used over the course of the run. The run will make a best effort to use only the number of prompt tokens specified, across multiple turns of the run. If the run exceeds the number of prompt tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. - minimum: 256 - max_completion_tokens: - type: - - integer - - 'null' - description: | - The maximum number of completion tokens that may be used over the course of the run. The run will make a best effort to use only the number of completion tokens specified, across multiple turns of the run. If the run exceeds the number of completion tokens specified, the run will end with status `incomplete`. See `incomplete_details` for more info. - minimum: 256 - truncation_strategy: - allOf: - - $ref: '#/components/schemas/TruncationObject' - - nullable: true - tool_choice: - allOf: - - $ref: '#/components/schemas/AssistantsApiToolChoiceOption' - - nullable: true - parallel_tool_calls: - $ref: '#/components/schemas/ParallelToolCalls' - required: - - assistant_id - SubmitToolOutputsRunRequestWithoutStream: - type: object - additionalProperties: false - properties: - tool_outputs: - description: A list of tools for which the outputs are being submitted. - type: array - items: - type: object - properties: - tool_call_id: - type: string - description: The ID of the tool call in the `required_action` object within the run object the output is being submitted for. - output: - type: string - description: The output of the tool call to be submitted to continue the run. - required: - - tool_outputs - RunStatus: - description: The status of the run, which can be either `queued`, `in_progress`, `requires_action`, `cancelling`, `cancelled`, `failed`, `completed`, `incomplete`, or `expired`. - type: string - enum: - - queued - - in_progress - - requires_action - - cancelling - - cancelled - - failed - - completed - - incomplete - - expired - CodeInterpreterContainerAuto: - properties: - type: - type: string - enum: - - auto - description: Always `auto`. - default: auto - x-stainless-const: true - file_ids: - items: - type: string - example: file-123 - type: array - maxItems: 50 - description: An optional list of uploaded files to make available to your code. - memory_limit: - anyOf: - - $ref: '#/components/schemas/ContainerMemoryLimit' - description: The memory limit for the code interpreter container. - - type: 'null' - network_policy: - discriminator: - propertyName: type - description: Network access policy for the container. - anyOf: - - $ref: '#/components/schemas/ContainerNetworkPolicyDisabledParam' - - $ref: '#/components/schemas/ContainerNetworkPolicyAllowlistParam' - type: object - required: - - type - title: CodeInterpreterToolAuto - description: Configuration for a code interpreter container. Optionally specify the IDs of the files to run the code on. - x-stainless-naming: - go: - type_name: ToolCodeInterpreterContainerCodeInterpreterContainerAuto - LiveFunctionToolChoiceParam: - type: object - properties: - type: - default: function - x-stainless-const: true - type: string - enum: - - function - name: - type: string - minLength: 1 - maxLength: 64 - pattern: ^[a-zA-Z0-9_-]+$ - required: - - type - - name - LiveMCPToolChoiceParam: - type: object - properties: - type: - default: mcp - x-stainless-const: true - type: string - enum: - - mcp - server_label: - type: string - minLength: 1 - maxLength: 64 - pattern: ^[a-zA-Z0-9_-]+$ - name: - type: string - minLength: 1 - maxLength: 64 - pattern: ^[a-zA-Z0-9_-]+$ - required: - - type - - server_label - - name - RealtimeResponseItemReference: - type: object - description: | - Reference an existing item in this Realtime session as response.create input. - Item references are only accepted in the response input array, not as new - conversation items. - properties: - type: - type: string - enum: - - item_reference - x-stainless-const: true - description: The type of this item. Always `item_reference`. - id: - type: string - minLength: 1 - maxLength: 32 - pattern: ^[a-zA-Z0-9_-]+$ - description: The ID of an existing item in the same Realtime session. - required: - - type - - id responses: TooManyRequests: description: The request was rejected because a rate limit was exceeded. diff --git a/scripts/castiron/generated-files.json b/scripts/castiron/generated-files.json index 0fa0d0b98e..f9333d51f2 100644 --- a/scripts/castiron/generated-files.json +++ b/scripts/castiron/generated-files.json @@ -3,7 +3,7 @@ "files": { "api.md": { "mode": "100644", - "sha256": "4ac9c7c1fc6873491907b81a74c29c6b992f90ca0bae82fa1397770c9919fc53" + "sha256": "e3e51f952866bcef85ec209e23208f0385a4462b2be43cde2b064df9d123410a" }, "src/openai/__init__.py": { "mode": "100644", @@ -39,7 +39,7 @@ }, "src/openai/resources/admin/organization/__init__.py": { "mode": "100644", - "sha256": "20413ef0ea8ce86960092122a76f79fe51a9cdb843175ae6939cf477cc8a808f" + "sha256": "59fd2b93cd6ab6db1dc05e3875be8672a800556d638039c644e3541f49196948" }, "src/openai/resources/admin/organization/admin_api_keys.py": { "mode": "100644", @@ -87,7 +87,7 @@ }, "src/openai/resources/admin/organization/organization.py": { "mode": "100644", - "sha256": "254724f376de5fb777151c5531b4039d67f7e774fab9b71c2033d8de02a1f96c" + "sha256": "edd6cd1b4705a991e5d4efb18d4bcdea5ce5cb6faaecc1474b68995f884584d0" }, "src/openai/resources/admin/organization/projects/__init__.py": { "mode": "100644", @@ -379,7 +379,7 @@ }, "src/openai/resources/beta/responses/responses.py": { "mode": "100644", - "sha256": "27b0726c4b7c6f4e037f241548a14adf5d38e4f701ff12c46569a03f17f5f53a" + "sha256": "043ff25a6a7f078bb0b11f6368ee136d7f3bb371c65360c8eb608afaf86de023" }, "src/openai/resources/beta/threads/__init__.py": { "mode": "100644", @@ -559,11 +559,11 @@ }, "src/openai/resources/live/forks.py": { "mode": "100644", - "sha256": "a1154022199862fbcb09a71e2402f15e912934de9e931d254af59bf74f34894c" + "sha256": "eda42fc4ed92675d23eb62905f154a5b115567c158dd4b0fe5cf3d0824e2bd9d" }, "src/openai/resources/live/live.py": { "mode": "100644", - "sha256": "97c244e50d61c93cd6a1952064c14f302b86e85b289e024af1c524093cc76aaf" + "sha256": "a42b9133f64a4421eafbb1d0d10a9cddcefdc872270518f7bdf61ce192265985" }, "src/openai/resources/live/sessions.py": { "mode": "100644", @@ -571,7 +571,7 @@ }, "src/openai/resources/live/sideband.py": { "mode": "100644", - "sha256": "9f780c2c96066d0f2a70f48b5c7f4f610900a1d07371c7b19fd0a7b787e79120" + "sha256": "7ed377e577ce3b229692bd112059914700741cbff042a7a92cc2a581ec2ac1f8" }, "src/openai/resources/models.py": { "mode": "100644", @@ -599,7 +599,7 @@ }, "src/openai/resources/realtime/realtime.py": { "mode": "100644", - "sha256": "0bfb331a5a3b95022b8d93532b05f906279d38f52b7dd1d3d455207039b6847f" + "sha256": "563d955afa8c0b023f4fcb353565db0b4348394f36e6a02659db6c2885286149" }, "src/openai/resources/realtime/translations/__init__.py": { "mode": "100644", @@ -611,7 +611,7 @@ }, "src/openai/resources/realtime/translations/translations.py": { "mode": "100644", - "sha256": "805923247278589582e4d4db869aa55f37a18130178d6c467f1c59b67825259c" + "sha256": "8f45807c74c477a1d13df9979c6274a089c380004b943fcb6d9342b1a7ac3ef1" }, "src/openai/resources/responses/__init__.py": { "mode": "100644", @@ -631,7 +631,7 @@ }, "src/openai/resources/responses/responses.py": { "mode": "100644", - "sha256": "b9a2ef1b61d33450318babc49e2b25f604628983804e7ebf4291f8802f372aee" + "sha256": "6e536e48d77d9ed583bd0c86a4f189b664e8636ae46a52a1c80fac28ffc3b4ec" }, "src/openai/resources/safety/__init__.py": { "mode": "100644", @@ -7551,7 +7551,7 @@ }, "tests/api_resources/admin/organization/test_external_keys.py": { "mode": "100644", - "sha256": "0bad3f5c69569508a864df6e84f6ee1b7660d78fb3da1c16eb13f6229ef71b7d" + "sha256": "cfde4ad29b5baf45b96dfb63fe8a096f3491c19c91120efa4618e5b13b99b7a8" }, "tests/api_resources/admin/organization/test_external_storage.py": { "mode": "100644", diff --git a/src/openai/_event_handler.py b/src/openai/_event_handler.py index 3aa6370b0a..bbcbd22a6b 100644 --- a/src/openai/_event_handler.py +++ b/src/openai/_event_handler.py @@ -2,16 +2,22 @@ import threading from typing import Any, Callable +from dataclasses import dataclass EventHandler = Callable[..., Any] +@dataclass(eq=False) +class _Registration: + handler: EventHandler + once: bool + + class EventHandlerRegistry: """Thread-safe (optional) registry of event handlers.""" def __init__(self, *, use_lock: bool = False) -> None: - self._handlers: dict[str, list[EventHandler]] = {} - self._once_ids: set[int] = set() + self._handlers: dict[str, list[_Registration]] = {} self._lock: threading.Lock | None = threading.Lock() if use_lock else None def _acquire(self) -> None: @@ -26,9 +32,7 @@ def add(self, event_type: str, handler: EventHandler, *, once: bool = False) -> self._acquire() try: handlers = self._handlers.setdefault(event_type, []) - handlers.append(handler) - if once: - self._once_ids.add(id(handler)) + handlers.append(_Registration(handler, once)) finally: self._release() @@ -37,11 +41,10 @@ def remove(self, event_type: str, handler: EventHandler) -> None: try: handlers = self._handlers.get(event_type) if handlers is not None: - try: - handlers.remove(handler) - except ValueError: - pass - self._once_ids.discard(id(handler)) + for index, registration in enumerate(handlers): + if registration.handler == handler: + del handlers[index] + break finally: self._release() @@ -52,12 +55,8 @@ def get_handlers(self, event_type: str) -> list[EventHandler]: handlers = self._handlers.get(event_type) if not handlers: return [] - result = list(handlers) - to_remove = [h for h in result if id(h) in self._once_ids] - for h in to_remove: - handlers.remove(h) - self._once_ids.discard(id(h)) - return result + self._handlers[event_type] = [registration for registration in handlers if not registration.once] + return [registration.handler for registration in handlers] finally: self._release() @@ -74,10 +73,8 @@ def merge_into(self, target: EventHandlerRegistry) -> None: self._acquire() try: for event_type, handlers in self._handlers.items(): - for handler in handlers: - once = id(handler) in self._once_ids - target.add(event_type, handler, once=once) + for registration in handlers: + target.add(event_type, registration.handler, once=registration.once) self._handlers.clear() - self._once_ids.clear() finally: self._release() diff --git a/src/openai/_send_queue.py b/src/openai/_send_queue.py index 47567b7c6a..ea7df07082 100644 --- a/src/openai/_send_queue.py +++ b/src/openai/_send_queue.py @@ -6,7 +6,27 @@ import anyio.to_thread -from ._exceptions import WebSocketQueueFullError +from ._exceptions import WebSocketQueueFullError, WebSocketConnectionClosedError + + +class SendReceipt: + """An admitted ordinary sender's own failure, if another caller sends it.""" + + def __init__(self) -> None: + self.error: BaseException | None = None + self.unsent: list[str | bytes] | None = None + + def raise_if_failed(self) -> None: + if self.error is not None: + raise self.error + + def raise_if_unsent(self) -> None: + if self.unsent is not None: + # Construct outside the queue lock: decoding binary frames can + # invoke user code, and terminal reporting must not block a flush. + raise WebSocketConnectionClosedError( + "WebSocket connection closed with unsent messages", unsent_messages=self.unsent + ) class SendQueue: @@ -18,26 +38,56 @@ class SendQueue: """ def __init__(self, max_bytes: int = 1_048_576) -> None: - self._queue: list[tuple[str | bytes, int]] = [] # (data, byte_length) + self._queue: list[tuple[str | bytes, int, SendReceipt | None]] = [] # (data, byte_length, receipt) self._bytes: int = 0 self._max_bytes = max_bytes self._lock = threading.Lock() self._flush_done: threading.Event | None = None + # drain() ends admission to this batch while an in-flight write may + # still hold _flush_done until it unwinds and releases ownership. + self._pending: deque[tuple[str | bytes, int, SendReceipt | None]] | None = None + self._flush_owner: tuple[threading.Thread, int | None] | None = None + + @property + def has_pending_or_flushing(self) -> bool: + with self._lock: + return bool(self._queue) or self._pending is not None - def enqueue(self, data: str | bytes) -> None: + def is_flush_owner(self, *, task_id: int | None = None) -> bool: + """Whether this caller already owns the flush and must not wait on itself.""" + with self._lock: + return self._flush_owner == (threading.current_thread(), task_id) + + def enqueue(self, data: str | bytes, *, only_if_pending: bool = False, receipt: SendReceipt | None = None) -> bool: """Append *data* to the queue. Raises :class:`WebSocketQueueFullError` if the message would exceed the byte-size limit. + + If only_if_pending is true, return False instead of admitting a + message when there is no buffered or active batch; otherwise return True. """ + # Avoid a redundant encoding on direct sends. Recheck below in case + # the queue drains during encoding, which must stay outside its lock. + if only_if_pending and not self.has_pending_or_flushing: + return False byte_length = len(data.encode("utf-8") if isinstance(data, str) else data) with self._lock: + if only_if_pending and not self._queue and self._pending is None: + return False if self._bytes + byte_length > self._max_bytes: raise WebSocketQueueFullError("send queue is full, message discarded") - self._queue.append((data, byte_length)) + self._queue.append((data, byte_length, receipt)) self._bytes += byte_length - - def flush_sync(self, send: typing.Callable[[str | bytes], object], *, requeue_failed: bool = True) -> None: + return True + + def flush_sync( + self, + send: typing.Callable[[str | bytes], object], + *, + requeue_failed: bool = True, + prepare_send: typing.Callable[[], typing.Callable[[str | bytes], object] | None] | None = None, + ) -> None: """Send every queued message via *send*. If *send* raises, the failing message and all subsequent messages @@ -48,22 +98,50 @@ def flush_sync(self, send: typing.Callable[[str | bytes], object], *, requeue_fa pending.wait() try: - while pending: - data, byte_length = pending[0] + with self._lock: + self._flush_owner = (threading.current_thread(), None) + while True: + if prepare_send is not None: + # Check ownership outside the queue lock before each frame; + # the connection may have changed while the last send ran. + with self._lock: + if not pending: + break + prepared = prepare_send() + if prepared is None: + return + send = prepared + with self._lock: + # drain() can run while preparation checks the connection. + if not pending: + break + data, byte_length, receipt = pending.popleft() sent = False try: send(data) sent = True + except BaseException as exc: + if receipt is not None: + # The actual frame's caller may be waiting on this + # batch; retain its own error before releasing the lease. + receipt.error = exc + raise finally: - if sent or not requeue_failed: - with self._lock: - pending.popleft() + with self._lock: + if sent or not requeue_failed: self._bytes -= byte_length + else: + pending.appendleft((data, byte_length, receipt)) finally: self._end_flush(pending) async def flush_async( - self, send: typing.Callable[[str | bytes], typing.Awaitable[object]], *, requeue_failed: bool = True + self, + send: typing.Callable[[str | bytes], typing.Awaitable[object]], + *, + requeue_failed: bool = True, + prepare_send: typing.Callable[[], typing.Callable[[str | bytes], typing.Awaitable[object]] | None] + | None = None, ) -> None: """Async variant of :meth:`flush_sync`.""" while isinstance(pending := self._begin_flush(), threading.Event): @@ -72,43 +150,86 @@ async def flush_async( await anyio.to_thread.run_sync(pending.wait, abandon_on_cancel=True) try: - while pending: - data, byte_length = pending[0] + with self._lock: + self._flush_owner = (threading.current_thread(), anyio.get_current_task().id) + while True: + if prepare_send is not None: + # Check ownership outside the queue lock before each frame; + # the connection may have changed while the last send ran. + with self._lock: + if not pending: + break + prepared = prepare_send() + if prepared is None: + return + send = prepared + with self._lock: + # drain() can run while preparation checks the connection. + if not pending: + break + data, byte_length, receipt = pending.popleft() sent = False try: await send(data) sent = True + except BaseException as exc: + if receipt is not None: + # The actual frame's caller may be waiting on this + # batch; retain its own error before releasing the lease. + receipt.error = exc + raise finally: - if sent or not requeue_failed: - with self._lock: - pending.popleft() + with self._lock: + if sent or not requeue_failed: self._bytes -= byte_length + else: + pending.appendleft((data, byte_length, receipt)) finally: self._end_flush(pending) - def _begin_flush(self) -> deque[tuple[str | bytes, int]] | threading.Event: + def _begin_flush(self) -> deque[tuple[str | bytes, int, SendReceipt | None]] | threading.Event: with self._lock: if self._flush_done is not None: return self._flush_done pending = deque(self._queue) self._queue.clear() self._flush_done = threading.Event() - # Pending messages remain charged until their sends succeed. + self._pending = pending + # Messages remain charged while their sends are in flight. return pending - def _end_flush(self, pending: deque[tuple[str | bytes, int]]) -> None: + def _end_flush(self, pending: deque[tuple[str | bytes, int, SendReceipt | None]]) -> None: with self._lock: self._queue = list(pending) + self._queue assert self._flush_done is not None self._flush_done.set() self._flush_done = None + self._pending = None + self._flush_owner = None def drain(self) -> list[str | bytes]: """Remove and return all queued messages.""" + return self.mark_unsent(clear=True) + + def mark_unsent(self, *, clear: bool = False) -> list[str | bytes]: + """Report retained ordinary frames. A later reader can still drain them after close().""" with self._lock: - items = [data for data, _ in self._queue] - self._bytes -= sum(byte_length for _, byte_length in self._queue) - self._queue.clear() + pending = list(self._pending) if self._pending is not None else [] + pending.extend(self._queue) + items = [data for data, _, _ in pending] + for _, _, receipt in pending: + if receipt is not None: + receipt.unsent = items + if self._pending is not None: + if not clear: + # An old prepare_send may already have returned. Evacuate + # this batch before its next pop but retain it for a reader. + self._queue[:0] = self._pending + self._pending.clear() + self._pending = None + if clear: + self._bytes -= sum(byte_length for _, byte_length, _ in pending) + self._queue.clear() return items def __len__(self) -> int: diff --git a/src/openai/_websocket_connection.py b/src/openai/_websocket_connection.py index 5e1e4ceda7..e74652a93c 100644 --- a/src/openai/_websocket_connection.py +++ b/src/openai/_websocket_connection.py @@ -4,10 +4,13 @@ import random import logging import threading +import contextlib from typing import TYPE_CHECKING, Callable, Awaitable +import anyio + from ._types import Query, Headers -from ._send_queue import SendQueue +from ._send_queue import SendQueue, SendReceipt from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides, is_recoverable_close if TYPE_CHECKING: @@ -45,25 +48,52 @@ def __init__( self._send_queue = send_queue if send_queue is not None else SendQueue() self._logger = logger self._interruptible_reconnect = interruptible_reconnect + self._connection_lock = threading.Lock() if interruptible_reconnect: self._close_event = threading.Event() - self._connection_lock = threading.Lock() def send_raw(self, data: bytes | str) -> None: - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + self._send(data) + + def _send(self, data: bytes | str) -> None: + # Admission and the empty-queue transition share this lock. Never keep + # it across transport I/O: close must be able to interrupt a slow send. + flush_backlog = False + admitted = False + receipt = SendReceipt() + try: + with self._connection_lock: + if not self._intentionally_closed: + if self._is_reconnecting: + self._send_queue.enqueue(data) + return + try: + admitted = self._send_queue.enqueue(data, only_if_pending=True, receipt=receipt) + finally: + # Do not wait on an active flush: send() can be reentrant. + flush_backlog = not self._send_queue.is_flush_owner() and bool(self._send_queue) + if admitted: + return + connection = self._connection + finally: + if flush_backlog: + # Rejected admissions still allow the older backlog to progress. + try: + self._flush_send_queue(raise_on_error=admitted) + except BaseException: + receipt.raise_if_failed() + raise + receipt.raise_if_failed() + receipt.raise_if_unsent() + connection.send(data) def close(self, *, code: int = 1000, reason: str = "") -> None: - self._intentionally_closed = True if self._interruptible_reconnect: self._close_event.set() - with self._connection_lock: - self._is_reconnecting = False - connection = self._connection - else: + with self._connection_lock: + self._intentionally_closed = True self._is_reconnecting = False + self._send_queue.mark_unsent() connection = self._connection connection.close(code=code, reason=reason) @@ -73,32 +103,27 @@ def _reconnect(self, exc: Exception) -> bool: Returns ``True`` if a new connection was established, ``False`` if the caller should re-raise the original exception. """ - if ( - self._on_reconnecting is None - or self._make_ws is None - or (self._interruptible_reconnect and self._close_event.is_set()) - ): + if self._on_reconnecting is None or self._make_ws is None or self._intentionally_closed: return False - from websockets.exceptions import ConnectionClosedError + from websockets.exceptions import ConnectionClosed close_code = 1006 - if isinstance(exc, ConnectionClosedError) and exc.rcvd is not None: + if isinstance(exc, ConnectionClosed) and exc.rcvd is not None: close_code = exc.rcvd.code if not is_recoverable_close(close_code): return False - if self._interruptible_reconnect: - with self._connection_lock: - if self._close_event.is_set(): - return False - self._is_reconnecting = True - else: + with self._connection_lock: + if self._intentionally_closed: + return False self._is_reconnecting = True try: for attempt in range(self._reconnect_attempt + 1, self._max_retries + 1): + if self._intentionally_closed: + return False self._reconnect_attempt = attempt base_delay = min(self._initial_delay * (2 ** (attempt - 1)), self._max_delay) jitter = 0.75 + random.random() * 0.25 @@ -143,19 +168,26 @@ def _reconnect(self, exc: Exception) -> bool: try: connection = self._make_ws(self._extra_query, self._extra_headers) - if self._interruptible_reconnect: - with self._connection_lock: - closed = self._intentionally_closed - if not closed: - self._connection = connection - if closed: + with self._connection_lock: + closed = self._intentionally_closed + if not closed: + self._connection = connection + if closed: + connection.close() + return False + try: + self._flush_send_queue(raise_on_error=True, finish_recovery=True) + except Exception as flush_exc: + with contextlib.suppress(Exception): connection.close() - return False - else: - self._connection = connection + if isinstance(flush_exc, ConnectionClosed): + close_code = flush_exc.rcvd.code if flush_exc.rcvd is not None else 1006 + if not is_recoverable_close(close_code): + return False + raise + if self._intentionally_closed: + return False self._logger.info("Reconnected to WebSocket API") - self._is_reconnecting = False - self._flush_send_queue() return True except Exception: # Ordinary setup failures consume an attempt. The outer @@ -164,14 +196,37 @@ def _reconnect(self, exc: Exception) -> bool: return False finally: - self._is_reconnecting = False + with self._connection_lock: + self._is_reconnecting = False - def _flush_send_queue(self) -> None: + def _flush_send_queue(self, *, raise_on_error: bool = False, finish_recovery: bool = False) -> None: """Send all queued messages over the current connection.""" + + def prepare_send() -> Callable[[str | bytes], object] | None: + with self._connection_lock: + if self._intentionally_closed or (not finish_recovery and self._is_reconnecting): + return None + return self._connection.send + try: - self._send_queue.flush_sync(lambda data: self._connection.send(data), requeue_failed=False) + while True: + with self._connection_lock: + # An ordinary flush must hand new batches to the reader + # once recovery owns admission and transport replacement. + if not finish_recovery and self._is_reconnecting: + return + if self._intentionally_closed or not self._send_queue.has_pending_or_flushing: + if finish_recovery: + self._is_reconnecting = False + return + connection = self._connection + self._send_queue.flush_sync(connection.send, requeue_failed=False, prepare_send=prepare_send) except Exception: self._logger.warning("Failed to flush send queue after reconnect") + # Managers also flush messages queued before the initial connection. + # Preserve that path's behavior; only recovery owns retry policy. + if raise_on_error: + raise class AsyncWebSocketConnectionBase: @@ -204,14 +259,40 @@ def __init__( self._logger = logger async def send_raw(self, data: bytes | str) -> None: - if self._is_reconnecting: - self._send_queue.enqueue(data) - return + await self._send(data) + + async def _send(self, data: bytes | str) -> None: + flush_backlog = False + admitted = False + receipt = SendReceipt() + try: + if not self._intentionally_closed: + if self._is_reconnecting: + self._send_queue.enqueue(data) + return + try: + admitted = self._send_queue.enqueue(data, only_if_pending=True, receipt=receipt) + finally: + flush_backlog = not self._send_queue.is_flush_owner(task_id=anyio.get_current_task().id) and bool( + self._send_queue + ) + if admitted: + return + finally: + if flush_backlog: + try: + await self._flush_send_queue(raise_on_error=admitted) + except BaseException: + receipt.raise_if_failed() + raise + receipt.raise_if_failed() + receipt.raise_if_unsent() await self._connection.send(data) async def close(self, *, code: int = 1000, reason: str = "") -> None: self._intentionally_closed = True self._is_reconnecting = False + self._send_queue.mark_unsent() await self._connection.close(code=code, reason=reason) async def _reconnect(self, exc: Exception) -> bool: @@ -223,10 +304,10 @@ async def _reconnect(self, exc: Exception) -> bool: if self._on_reconnecting is None or self._make_ws is None or self._intentionally_closed: return False - from websockets.exceptions import ConnectionClosedError + from websockets.exceptions import ConnectionClosed close_code = 1006 - if isinstance(exc, ConnectionClosedError) and exc.rcvd is not None: + if isinstance(exc, ConnectionClosed) and exc.rcvd is not None: close_code = exc.rcvd.code if not is_recoverable_close(close_code): @@ -241,9 +322,13 @@ async def _reconnect(self, exc: Exception) -> bool: async def _attempt_reconnect(self, close_code: int) -> bool: import asyncio + from websockets.exceptions import ConnectionClosed + assert self._on_reconnecting is not None assert self._make_ws is not None for attempt in range(self._reconnect_attempt + 1, self._max_retries + 1): + if self._intentionally_closed: + return False self._reconnect_attempt = attempt base_delay = min(self._initial_delay * (2 ** (attempt - 1)), self._max_delay) jitter = 0.75 + random.random() * 0.25 @@ -292,22 +377,46 @@ async def _attempt_reconnect(self, close_code: int) -> bool: await asyncio.shield(connection.close()) return False self._connection = connection + try: + await self._flush_send_queue(raise_on_error=True, finish_recovery=True) + except Exception as flush_exc: + with contextlib.suppress(Exception): + await connection.close() + if isinstance(flush_exc, ConnectionClosed): + close_code = flush_exc.rcvd.code if flush_exc.rcvd is not None else 1006 + if not is_recoverable_close(close_code): + return False + raise + if self._intentionally_closed: + return False self._logger.info("Reconnected to WebSocket API") - self._is_reconnecting = False - await self._flush_send_queue() return True except Exception: pass return False - async def _flush_send_queue(self) -> None: + async def _flush_send_queue(self, *, raise_on_error: bool = False, finish_recovery: bool = False) -> None: """Send all queued messages over the current connection.""" - async def _send(data: str | bytes) -> None: - await self._connection.send(data) + def prepare_send() -> Callable[[str | bytes], Awaitable[object]] | None: + if self._intentionally_closed or (not finish_recovery and self._is_reconnecting): + return None + return self._connection.send try: - await self._send_queue.flush_async(_send, requeue_failed=False) + while True: + # No await separates admission from this handoff/empty transition. + if not finish_recovery and self._is_reconnecting: + return + if self._intentionally_closed or not self._send_queue.has_pending_or_flushing: + if finish_recovery: + self._is_reconnecting = False + return + await self._send_queue.flush_async( + self._connection.send, requeue_failed=False, prepare_send=prepare_send + ) except Exception: self._logger.warning("Failed to flush send queue after reconnect") + if raise_on_error: + raise diff --git a/src/openai/lib/responses_websocket/_session.py b/src/openai/lib/responses_websocket/_session.py index 3b8bac95c6..91dcb1297f 100644 --- a/src/openai/lib/responses_websocket/_session.py +++ b/src/openai/lib/responses_websocket/_session.py @@ -430,15 +430,13 @@ def _check_send(self, lane: _LaneState) -> None: raise RuntimeError("The WebSocket connection is reconnecting; restore state before sending") def _read(self) -> None: - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK try: while True: try: data = self.connection.recv_bytes() - except ConnectionClosedOK: - break - except ConnectionClosedError as exc: + except ConnectionClosed as exc: if self.connection._reconnect(exc): self._router.reconnected(self.connection._connection) continue @@ -447,6 +445,8 @@ def _read(self) -> None: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent ) from exc + if isinstance(exc, ConnectionClosedOK): + break raise self._router.dispatch(self.connection.parse_event(data), len(data)) del data @@ -564,15 +564,13 @@ def _check_send(self, lane: _LaneState) -> None: raise RuntimeError("The WebSocket connection is reconnecting; restore state before sending") async def _read(self) -> None: - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK try: while True: try: data = await self.connection.recv_bytes() - except ConnectionClosedOK: - break - except ConnectionClosedError as exc: + except ConnectionClosed as exc: if await self.connection._reconnect(exc): self._router.reconnected(self.connection._connection) continue @@ -581,6 +579,8 @@ async def _read(self) -> None: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent ) from exc + if isinstance(exc, ConnectionClosedOK): + break raise self._router.dispatch(self.connection.parse_event(data), len(data)) del data diff --git a/src/openai/resources/admin/organization/__init__.py b/src/openai/resources/admin/organization/__init__.py index f3f43c513a..e78a1370b3 100644 --- a/src/openai/resources/admin/organization/__init__.py +++ b/src/openai/resources/admin/organization/__init__.py @@ -174,6 +174,12 @@ "AsyncDataRetentionWithRawResponse": (".data_retention", "AsyncDataRetentionWithRawResponse"), "DataRetentionWithStreamingResponse": (".data_retention", "DataRetentionWithStreamingResponse"), "AsyncDataRetentionWithStreamingResponse": (".data_retention", "AsyncDataRetentionWithStreamingResponse"), + "ExternalKeys": (".external_keys", "ExternalKeys"), + "AsyncExternalKeys": (".external_keys", "AsyncExternalKeys"), + "ExternalKeysWithRawResponse": (".external_keys", "ExternalKeysWithRawResponse"), + "AsyncExternalKeysWithRawResponse": (".external_keys", "AsyncExternalKeysWithRawResponse"), + "ExternalKeysWithStreamingResponse": (".external_keys", "ExternalKeysWithStreamingResponse"), + "AsyncExternalKeysWithStreamingResponse": (".external_keys", "AsyncExternalKeysWithStreamingResponse"), "ExternalStorage": (".external_storage", "ExternalStorage"), "AsyncExternalStorage": (".external_storage", "AsyncExternalStorage"), "ExternalStorageWithRawResponse": (".external_storage", "ExternalStorageWithRawResponse"), @@ -204,12 +210,6 @@ "AsyncProjectsWithRawResponse": (".projects", "AsyncProjectsWithRawResponse"), "ProjectsWithStreamingResponse": (".projects", "ProjectsWithStreamingResponse"), "AsyncProjectsWithStreamingResponse": (".projects", "AsyncProjectsWithStreamingResponse"), - "ExternalKeys": (".external_keys", "ExternalKeys"), - "AsyncExternalKeys": (".external_keys", "AsyncExternalKeys"), - "ExternalKeysWithRawResponse": (".external_keys", "ExternalKeysWithRawResponse"), - "AsyncExternalKeysWithRawResponse": (".external_keys", "AsyncExternalKeysWithRawResponse"), - "ExternalKeysWithStreamingResponse": (".external_keys", "ExternalKeysWithStreamingResponse"), - "AsyncExternalKeysWithStreamingResponse": (".external_keys", "AsyncExternalKeysWithStreamingResponse"), "Organization": (".organization", "Organization"), "AsyncOrganization": (".organization", "AsyncOrganization"), "OrganizationWithRawResponse": (".organization", "OrganizationWithRawResponse"), @@ -301,6 +301,12 @@ def __dir__() -> list[str]: "AsyncDataRetentionWithRawResponse", "DataRetentionWithStreamingResponse", "AsyncDataRetentionWithStreamingResponse", + "ExternalKeys", + "AsyncExternalKeys", + "ExternalKeysWithRawResponse", + "AsyncExternalKeysWithRawResponse", + "ExternalKeysWithStreamingResponse", + "AsyncExternalKeysWithStreamingResponse", "ExternalStorage", "AsyncExternalStorage", "ExternalStorageWithRawResponse", @@ -331,12 +337,6 @@ def __dir__() -> list[str]: "AsyncProjectsWithRawResponse", "ProjectsWithStreamingResponse", "AsyncProjectsWithStreamingResponse", - "ExternalKeys", - "AsyncExternalKeys", - "ExternalKeysWithRawResponse", - "AsyncExternalKeysWithRawResponse", - "ExternalKeysWithStreamingResponse", - "AsyncExternalKeysWithStreamingResponse", "Organization", "AsyncOrganization", "OrganizationWithRawResponse", diff --git a/src/openai/resources/admin/organization/organization.py b/src/openai/resources/admin/organization/organization.py index 162b4f0961..c9de5d850f 100644 --- a/src/openai/resources/admin/organization/organization.py +++ b/src/openai/resources/admin/organization/organization.py @@ -154,6 +154,10 @@ def roles(self) -> Roles: def data_retention(self) -> DataRetention: return DataRetention(self._client) + @cached_property + def external_keys(self) -> ExternalKeys: + return ExternalKeys(self._client) + @cached_property def external_storage(self) -> ExternalStorage: return ExternalStorage(self._client) @@ -174,10 +178,6 @@ def certificates(self) -> Certificates: def projects(self) -> Projects: return Projects(self._client) - @cached_property - def external_keys(self) -> ExternalKeys: - return ExternalKeys(self._client) - @cached_property def with_raw_response(self) -> OrganizationWithRawResponse: """ @@ -232,6 +232,10 @@ def roles(self) -> AsyncRoles: def data_retention(self) -> AsyncDataRetention: return AsyncDataRetention(self._client) + @cached_property + def external_keys(self) -> AsyncExternalKeys: + return AsyncExternalKeys(self._client) + @cached_property def external_storage(self) -> AsyncExternalStorage: return AsyncExternalStorage(self._client) @@ -252,10 +256,6 @@ def certificates(self) -> AsyncCertificates: def projects(self) -> AsyncProjects: return AsyncProjects(self._client) - @cached_property - def external_keys(self) -> AsyncExternalKeys: - return AsyncExternalKeys(self._client) - @cached_property def with_raw_response(self) -> AsyncOrganizationWithRawResponse: """ @@ -313,6 +313,10 @@ def roles(self) -> RolesWithRawResponse: def data_retention(self) -> DataRetentionWithRawResponse: return DataRetentionWithRawResponse(self._organization.data_retention) + @cached_property + def external_keys(self) -> ExternalKeysWithRawResponse: + return ExternalKeysWithRawResponse(self._organization.external_keys) + @cached_property def external_storage(self) -> ExternalStorageWithRawResponse: return ExternalStorageWithRawResponse(self._organization.external_storage) @@ -333,10 +337,6 @@ def certificates(self) -> CertificatesWithRawResponse: def projects(self) -> ProjectsWithRawResponse: return ProjectsWithRawResponse(self._organization.projects) - @cached_property - def external_keys(self) -> ExternalKeysWithRawResponse: - return ExternalKeysWithRawResponse(self._organization.external_keys) - class AsyncOrganizationWithRawResponse: def __init__(self, organization: AsyncOrganization) -> None: @@ -375,6 +375,10 @@ def roles(self) -> AsyncRolesWithRawResponse: def data_retention(self) -> AsyncDataRetentionWithRawResponse: return AsyncDataRetentionWithRawResponse(self._organization.data_retention) + @cached_property + def external_keys(self) -> AsyncExternalKeysWithRawResponse: + return AsyncExternalKeysWithRawResponse(self._organization.external_keys) + @cached_property def external_storage(self) -> AsyncExternalStorageWithRawResponse: return AsyncExternalStorageWithRawResponse(self._organization.external_storage) @@ -395,10 +399,6 @@ def certificates(self) -> AsyncCertificatesWithRawResponse: def projects(self) -> AsyncProjectsWithRawResponse: return AsyncProjectsWithRawResponse(self._organization.projects) - @cached_property - def external_keys(self) -> AsyncExternalKeysWithRawResponse: - return AsyncExternalKeysWithRawResponse(self._organization.external_keys) - class OrganizationWithStreamingResponse: def __init__(self, organization: Organization) -> None: @@ -437,6 +437,10 @@ def roles(self) -> RolesWithStreamingResponse: def data_retention(self) -> DataRetentionWithStreamingResponse: return DataRetentionWithStreamingResponse(self._organization.data_retention) + @cached_property + def external_keys(self) -> ExternalKeysWithStreamingResponse: + return ExternalKeysWithStreamingResponse(self._organization.external_keys) + @cached_property def external_storage(self) -> ExternalStorageWithStreamingResponse: return ExternalStorageWithStreamingResponse(self._organization.external_storage) @@ -457,10 +461,6 @@ def certificates(self) -> CertificatesWithStreamingResponse: def projects(self) -> ProjectsWithStreamingResponse: return ProjectsWithStreamingResponse(self._organization.projects) - @cached_property - def external_keys(self) -> ExternalKeysWithStreamingResponse: - return ExternalKeysWithStreamingResponse(self._organization.external_keys) - class AsyncOrganizationWithStreamingResponse: def __init__(self, organization: AsyncOrganization) -> None: @@ -499,6 +499,10 @@ def roles(self) -> AsyncRolesWithStreamingResponse: def data_retention(self) -> AsyncDataRetentionWithStreamingResponse: return AsyncDataRetentionWithStreamingResponse(self._organization.data_retention) + @cached_property + def external_keys(self) -> AsyncExternalKeysWithStreamingResponse: + return AsyncExternalKeysWithStreamingResponse(self._organization.external_keys) + @cached_property def external_storage(self) -> AsyncExternalStorageWithStreamingResponse: return AsyncExternalStorageWithStreamingResponse(self._organization.external_storage) @@ -518,7 +522,3 @@ def certificates(self) -> AsyncCertificatesWithStreamingResponse: @cached_property def projects(self) -> AsyncProjectsWithStreamingResponse: return AsyncProjectsWithStreamingResponse(self._organization.projects) - - @cached_property - def external_keys(self) -> AsyncExternalKeysWithStreamingResponse: - return AsyncExternalKeysWithStreamingResponse(self._organization.external_keys) diff --git a/src/openai/resources/beta/responses/responses.py b/src/openai/resources/beta/responses/responses.py index 5ef217aafb..940cd3a84b 100644 --- a/src/openai/resources/beta/responses/responses.py +++ b/src/openai/resources/beta/responses/responses.py @@ -4902,21 +4902,28 @@ async def __aiter__(self) -> AsyncIterator[BetaResponsesServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> BetaResponsesServerEvent: @@ -4964,10 +4971,7 @@ async def send(self, event: BetaResponsesClientEvent | BetaResponsesClientEventP if isinstance(event, BaseModel) else json.dumps(await async_maybe_transform(event, BetaResponsesClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> BetaResponsesServerEvent: """ @@ -5316,21 +5320,28 @@ def __iter__(self) -> Iterator[BetaResponsesServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> BetaResponsesServerEvent: @@ -5380,10 +5391,7 @@ def send(self, event: BetaResponsesClientEvent | BetaResponsesClientEventParam) if isinstance(event, BaseModel) else json.dumps(maybe_transform(event, BetaResponsesClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> BetaResponsesServerEvent: """ diff --git a/src/openai/resources/live/forks.py b/src/openai/resources/live/forks.py index 4472f5b8a9..8f5dcd0d28 100644 --- a/src/openai/resources/live/forks.py +++ b/src/openai/resources/live/forks.py @@ -153,21 +153,28 @@ async def __aiter__(self) -> AsyncIterator[ForkServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> ForkServerEvent: @@ -215,10 +222,7 @@ async def send(self, event: ForkClientEvent | ForkClientEventParam) -> None: if isinstance(event, BaseModel) else json.dumps(await async_maybe_transform(event, ForkClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ForkServerEvent: """ @@ -575,21 +579,28 @@ def __iter__(self) -> Iterator[ForkServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> ForkServerEvent: @@ -639,10 +650,7 @@ def send(self, event: ForkClientEvent | ForkClientEventParam) -> None: if isinstance(event, BaseModel) else json.dumps(maybe_transform(event, ForkClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ForkServerEvent: """ diff --git a/src/openai/resources/live/live.py b/src/openai/resources/live/live.py index 0251b108ce..9a474b8da6 100644 --- a/src/openai/resources/live/live.py +++ b/src/openai/resources/live/live.py @@ -373,21 +373,28 @@ async def __aiter__(self) -> AsyncIterator[ServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> ServerEvent: @@ -435,10 +442,7 @@ async def send(self, event: ClientEvent | ClientEventParam) -> None: if isinstance(event, BaseModel) else json.dumps(await async_maybe_transform(event, ClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ServerEvent: """ @@ -788,21 +792,28 @@ def __iter__(self) -> Iterator[ServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> ServerEvent: @@ -852,10 +863,7 @@ def send(self, event: ClientEvent | ClientEventParam) -> None: if isinstance(event, BaseModel) else json.dumps(maybe_transform(event, ClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ServerEvent: """ diff --git a/src/openai/resources/live/sideband.py b/src/openai/resources/live/sideband.py index fca3846e44..f0fd8d20b7 100644 --- a/src/openai/resources/live/sideband.py +++ b/src/openai/resources/live/sideband.py @@ -158,21 +158,28 @@ async def __aiter__(self) -> AsyncIterator[ConnectServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> ConnectServerEvent: @@ -220,10 +227,7 @@ async def send(self, event: ConnectClientEvent | ConnectClientEventParam) -> Non if isinstance(event, BaseModel) else json.dumps(await async_maybe_transform(event, ConnectClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ConnectServerEvent: """ @@ -589,21 +593,28 @@ def __iter__(self) -> Iterator[ConnectServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> ConnectServerEvent: @@ -653,10 +664,7 @@ def send(self, event: ConnectClientEvent | ConnectClientEventParam) -> None: if isinstance(event, BaseModel) else json.dumps(maybe_transform(event, ConnectClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ConnectServerEvent: """ diff --git a/src/openai/resources/realtime/realtime.py b/src/openai/resources/realtime/realtime.py index 44a95eeb0e..eec545bda9 100644 --- a/src/openai/resources/realtime/realtime.py +++ b/src/openai/resources/realtime/realtime.py @@ -136,6 +136,7 @@ def connect( - Simultaneous multimodal output: Text is useful for moderation; faster-than-realtime audio ensures stable playback. The Realtime API is a stateful, event-based API that communicates over a WebSocket. + To start a Realtime conversation, set `model` in the connection URL. To join an existing call, set `call_id` instead. Transcription sessions use `intent=transcription`. """ return RealtimeConnectionManager( client=self._client, @@ -210,6 +211,7 @@ def connect( - Simultaneous multimodal output: Text is useful for moderation; faster-than-realtime audio ensures stable playback. The Realtime API is a stateful, event-based API that communicates over a WebSocket. + To start a Realtime conversation, set `model` in the connection URL. To join an existing call, set `call_id` instead. Transcription sessions use `intent=transcription`. """ return AsyncRealtimeConnectionManager( client=self._client, @@ -343,21 +345,28 @@ async def __aiter__(self) -> AsyncIterator[RealtimeServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> RealtimeServerEvent: @@ -411,10 +420,7 @@ async def send( ) ) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> RealtimeServerEvent: """ @@ -796,21 +802,28 @@ def __iter__(self) -> Iterator[RealtimeServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> RealtimeServerEvent: @@ -864,10 +877,7 @@ def send( maybe_transform(event, Union[RealtimeClientEventParam, ResponseCreateEventWithReferencesParam]) ) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> RealtimeServerEvent: """ diff --git a/src/openai/resources/realtime/translations/translations.py b/src/openai/resources/realtime/translations/translations.py index 7d2ce9c9f7..117e571311 100644 --- a/src/openai/resources/realtime/translations/translations.py +++ b/src/openai/resources/realtime/translations/translations.py @@ -86,7 +86,10 @@ def connect( max_delay: float = 8.0, max_queue_size: int = 1_048_576, ) -> TranslationsConnectionManager: + """Open a Realtime translation session. + The WebSocket URL requires a `model` query parameter. + """ return TranslationsConnectionManager( client=self._client, model=model, @@ -137,7 +140,10 @@ def connect( max_delay: float = 8.0, max_queue_size: int = 1_048_576, ) -> AsyncTranslationsConnectionManager: + """Open a Realtime translation session. + The WebSocket URL requires a `model` query parameter. + """ return AsyncTranslationsConnectionManager( client=self._client, model=model, @@ -229,21 +235,28 @@ async def __aiter__(self) -> AsyncIterator[RealtimeTranslationServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> RealtimeTranslationServerEvent: @@ -291,10 +304,7 @@ async def send(self, event: RealtimeTranslationClientEvent | RealtimeTranslation if isinstance(event, BaseModel) else json.dumps(await async_maybe_transform(event, RealtimeTranslationClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> RealtimeTranslationServerEvent: """ @@ -649,21 +659,28 @@ def __iter__(self) -> Iterator[RealtimeTranslationServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> RealtimeTranslationServerEvent: @@ -713,10 +730,7 @@ def send(self, event: RealtimeTranslationClientEvent | RealtimeTranslationClient if isinstance(event, BaseModel) else json.dumps(maybe_transform(event, RealtimeTranslationClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> RealtimeTranslationServerEvent: """ diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index 1eac3b2e96..4ac7102343 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -3907,21 +3907,28 @@ async def __aiter__(self) -> AsyncIterator[ResponsesServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield await self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not await self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not await self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise async def recv(self) -> ResponsesServerEvent: @@ -3969,10 +3976,7 @@ async def send(self, event: ResponsesClientEvent | ResponsesClientEventParam) -> if isinstance(event, BaseModel) else json.dumps(await async_maybe_transform(event, ResponsesClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - await self._connection.send(data) + await AsyncWebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ResponsesServerEvent: """ @@ -4322,21 +4326,28 @@ def __iter__(self) -> Iterator[ResponsesServerEvent]: An infinite-iterator that will continue to yield events until the connection is closed. """ - from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError while True: try: yield self.recv() - except ConnectionClosedOK: - return - except ConnectionClosedError as exc: - if not self._reconnect(exc): + except ConnectionClosed as exc: + # Older SDK bases read close codes only from ConnectionClosedError. + # Adapt the policy input; keep the original close for EOF and errors. + reconnect_exc = ( + ConnectionClosedError(exc.rcvd, exc.sent, exc.rcvd_then_sent) + if isinstance(exc, ConnectionClosedOK) + else exc + ) + if not self._reconnect(reconnect_exc): unsent = self._send_queue.drain() if unsent: raise WebSocketConnectionClosedError( "WebSocket connection closed with unsent messages", unsent_messages=unsent, ) from exc + if isinstance(exc, ConnectionClosedOK): + return raise def recv(self) -> ResponsesServerEvent: @@ -4386,10 +4397,7 @@ def send(self, event: ResponsesClientEvent | ResponsesClientEventParam) -> None: if isinstance(event, BaseModel) else json.dumps(maybe_transform(event, ResponsesClientEventParam)) ) - if self._is_reconnecting: - self._send_queue.enqueue(data) - return - self._connection.send(data) + WebSocketConnectionBase.send_raw(self, data) def parse_event(self, data: str | bytes) -> ResponsesServerEvent: """ diff --git a/tests/api_resources/admin/organization/test_external_keys.py b/tests/api_resources/admin/organization/test_external_keys.py index 54cf79f13e..8d49cb0aac 100644 --- a/tests/api_resources/admin/organization/test_external_keys.py +++ b/tests/api_resources/admin/organization/test_external_keys.py @@ -27,7 +27,7 @@ def test_method_create_overload_1(self, client: OpenAI) -> None: external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", ) assert_matches_type(ExternalKey, external_key, path=["response"]) @@ -38,7 +38,7 @@ def test_method_create_with_all_params_overload_1(self, client: OpenAI) -> None: external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", purpose="ekm", ) @@ -50,7 +50,7 @@ def test_raw_response_create_overload_1(self, client: OpenAI) -> None: external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", ) @@ -65,7 +65,7 @@ def test_streaming_response_create_overload_1(self, client: OpenAI) -> None: external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", ) as response: assert not response.is_closed @@ -369,7 +369,7 @@ async def test_method_create_overload_1(self, async_client: AsyncOpenAI) -> None external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", ) assert_matches_type(ExternalKey, external_key, path=["response"]) @@ -380,7 +380,7 @@ async def test_method_create_with_all_params_overload_1(self, async_client: Asyn external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", purpose="ekm", ) @@ -392,7 +392,7 @@ async def test_raw_response_create_overload_1(self, async_client: AsyncOpenAI) - external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", ) @@ -407,7 +407,7 @@ async def test_streaming_response_create_overload_1(self, async_client: AsyncOpe external_id="external_id", kms_arn="kms_arn", name="name", - role_arn='arn:aws:iam::732166991022:role/0*bWz"J5bo7', + role_arn="arn:aws:iam::123456789012:role/example-role", type="aws", ) as response: assert not response.is_closed diff --git a/tests/lib/responses/test_websocket_session.py b/tests/lib/responses/test_websocket_session.py index db616621ae..81e6f30344 100644 --- a/tests/lib/responses/test_websocket_session.py +++ b/tests/lib/responses/test_websocket_session.py @@ -650,7 +650,8 @@ def bounded_join(timeout: float) -> None: @pytest.mark.parametrize("mode", ["sync", "async"]) -async def test_default_lane_can_be_recreated_after_recovery(mode: str) -> None: +@pytest.mark.parametrize("close_code", [1011, 1001], ids=["error-close", "recoverable-clean-close"]) +async def test_default_lane_can_be_recreated_after_recovery(mode: str, close_code: int) -> None: disconnect = threading.Event() def reconnect(_event: ReconnectingEvent) -> ReconnectingOverrides: @@ -661,7 +662,7 @@ def script(socket: ServerConnection) -> None: if socket.request.headers.get("X-Recovery") != "fresh": assert json.loads(socket.recv(timeout=5))["input"] == "lost response" assert disconnect.wait(5) - socket.close(code=1011) + socket.close(code=close_code) return socket.send(json.dumps({"type": "response.future", "stream_id": "observer", "restored": True})) assert json.loads(socket.recv(timeout=5)) == {"type": "response.create", "input": "restored context"} diff --git a/tests/test_send_queue.py b/tests/test_send_queue.py index 091697f172..d1f963176b 100644 --- a/tests/test_send_queue.py +++ b/tests/test_send_queue.py @@ -363,3 +363,91 @@ def send(data: str | bytes) -> None: q.flush_sync(sent.append) assert sent == ["b", "c"] assert q._bytes == 0 + + +def test_empty_conditional_send_does_not_allocate_utf8_copy() -> None: + import tracemalloc + + queue = SendQueue() + payload = "é" * (4 * 1024 * 1024) + tracemalloc.start() + try: + assert not queue.enqueue(payload, only_if_pending=True) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + # Generous interpreter headroom, but less than a second payload buffer. + assert peak < len(payload) + assert not queue + + +def test_conditional_send_rechecks_after_reentrant_utf8_encoding() -> None: + queue = SendQueue() + queue.enqueue("older") + captured: list[str | bytes] = [] + admitted: list[bool] = [] + + class DrainingText(str): + def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: + captured.extend(queue.drain()) + return super().encode(encoding, errors) + + def enqueue() -> None: + admitted.append(queue.enqueue(DrainingText("later"), only_if_pending=True)) + + worker = threading.Thread(target=enqueue, daemon=True) + worker.start() + worker.join(3) + assert not worker.is_alive(), "UTF-8 conversion ran under the queue lock" + assert captured == ["older"] + assert admitted == [False] + assert queue.drain() == [] + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("outcome", ["decline", "failure", "cancel"]) +async def test_preparing_send_restores_unconsumed_batch(asynchronous: bool, outcome: str) -> None: + queue = SendQueue(max_bytes=3) + queue.enqueue("é") + queue.enqueue("a") + sent: list[str | bytes] = [] + failure = RuntimeError("prepare failed") + cancellation = asyncio.CancelledError() + + def prepare() -> None: + if outcome == "failure": + raise failure + if outcome == "cancel": + raise cancellation + return None + + async def send_async(data: str | bytes) -> None: + sent.append(data) + + async def flush() -> None: + if asynchronous: + await queue.flush_async(send_async, requeue_failed=False, prepare_send=prepare) + else: + queue.flush_sync(sent.append, requeue_failed=False, prepare_send=prepare) + + if outcome == "decline": + await flush() + elif outcome == "failure": + with pytest.raises(RuntimeError) as raised: + await flush() + assert raised.value is failure + else: + with pytest.raises(asyncio.CancelledError) as cancelled: + await flush() + assert cancelled.value is cancellation + assert sent == [] + with pytest.raises(WebSocketQueueFullError): + queue.enqueue("b") + # A fresh ordinary flush must own the batch and release the full budget. + if asynchronous: + await queue.flush_async(send_async) + else: + queue.flush_sync(sent.append) + assert sent == ["é", "a"] + assert queue.enqueue("€") + assert queue.drain() == ["€"] diff --git a/tests/test_send_queue_reconnect.py b/tests/test_send_queue_reconnect.py index d574d434ad..41fc6b557f 100644 --- a/tests/test_send_queue_reconnect.py +++ b/tests/test_send_queue_reconnect.py @@ -156,10 +156,10 @@ def failing_send(data: str | bytes) -> None: initial_delay=0, max_retries=3, ) - for expected_attempts in range(1, 4): - assert connection._reconnect(RuntimeError("fake disconnect")) - assert q._bytes == (1 if expected_attempts == 1 else 0) - assert attempts == min(expected_attempts, 2) + # A failed flush consumes its attempt, so both queued sends are attempted + # before recovery succeeds on the third socket. Neither is replayed. + assert connection._reconnect(RuntimeError("fake disconnect")) + assert q._bytes == 0 assert not connection._reconnect(RuntimeError("fake disconnect")) assert attempts == 2 @@ -211,10 +211,8 @@ async def failing_send(data: str | bytes) -> None: initial_delay=0, max_retries=3, ) - for expected_attempts in range(1, 4): - assert await connection._reconnect(RuntimeError("fake disconnect")) - assert q._bytes == (1 if expected_attempts == 1 else 0) - assert attempts == min(expected_attempts, 2) + assert await connection._reconnect(RuntimeError("fake disconnect")) + assert q._bytes == 0 assert not await connection._reconnect(RuntimeError("fake disconnect")) assert attempts == 2 diff --git a/tests/test_websocket_initial_backlog.py b/tests/test_websocket_initial_backlog.py new file mode 100644 index 0000000000..33c570b5a8 --- /dev/null +++ b/tests/test_websocket_initial_backlog.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import json +import asyncio +import threading +from typing import Any +from unittest.mock import Mock, AsyncMock +from concurrent.futures import ThreadPoolExecutor + +import pytest +from websockets.frames import Close +from websockets.exceptions import ConnectionClosedOK + +from openai import OpenAI, AsyncOpenAI, WebSocketQueueFullError +from openai._send_queue import SendQueue +from tests.lib.test_websocket_redirects import RECONNECTING_RESOURCES, options, resource + + +def event(label: str) -> dict[str, Any]: + return {"type": "session.update", "event_id": label, "session": {}} + + +class InitialWriteFails: + def __init__(self) -> None: + self.attempts: list[str | bytes] = [] + self.closed = False + self.failures: dict[str | bytes, Exception] = {} + + def send(self, data: str | bytes) -> None: + if self.closed: + close = Close(1000, "") + raise ConnectionClosedOK(close, close, True) + label = json.loads(data)["event_id"] if isinstance(data, str) else data + self.attempts.append(label) + if len(self.attempts) == 1: + raise OSError("initial write fails with the socket still open") + if label in self.failures: + raise self.failures[label] + + def close(self, **_kwargs: Any) -> None: + self.closed = True + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("later", ["typed", "empty-raw", "overflow", "after-close"]) +def test_public_manager_pending_after_initial_failure(name: str, later: str, monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + monkeypatch.setattr("websockets.sync.client.connect", Mock(return_value=ws)) + on_reconnecting = Mock() + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), max_queue_size=256, on_reconnecting=on_reconnecting + ) + for label in "ABC": + manager.send(event(label)) + with manager as connection: + # Initial failures still warn and enter without an automatic retry. + assert ws.attempts == ["A"] + if later == "typed": + connection.send(event("D")) + assert ws.attempts == ["A", "B", "C", "D"] + elif later == "empty-raw": + connection.send_raw(b"") + assert ws.attempts == ["A", "B", "C", b""] + elif later == "overflow": + with pytest.raises(WebSocketQueueFullError): + connection.send_raw(b"D" * 256) + assert ws.attempts == ["A", "B", "C"] + else: + connection.close() + with pytest.raises(ConnectionClosedOK): + connection.send(event("D")) + assert ws.attempts == ["A"] + on_reconnecting.assert_not_called() + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("later", ["typed", "empty-raw", "overflow", "after-close"]) +async def test_async_public_manager_pending_after_initial_failure( + name: str, later: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + transport = Mock(send=AsyncMock(side_effect=ws.send), close=AsyncMock(side_effect=ws.close)) + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(return_value=transport)) + on_reconnecting = Mock() + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), max_queue_size=256, on_reconnecting=on_reconnecting + ) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + assert ws.attempts == ["A"] + if later == "typed": + await connection.send(event("D")) + assert ws.attempts == ["A", "B", "C", "D"] + elif later == "empty-raw": + await connection.send_raw(b"") + assert ws.attempts == ["A", "B", "C", b""] + elif later == "overflow": + with pytest.raises(WebSocketQueueFullError): + await connection.send_raw(b"D" * 256) + assert ws.attempts == ["A", "B", "C"] + else: + await connection.close() + with pytest.raises(ConnectionClosedOK): + await connection.send(event("D")) + assert ws.attempts == ["A"] + on_reconnecting.assert_not_called() + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("owner_fails", [False, True], ids=["successful-owner", "failed-owner"]) +def test_concurrent_sender_owns_its_joined_frame(name: str, owner_fails: bool, monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + entered, release, joined = (threading.Event() for _ in range(3)) + error = OSError("owner B failed") + enqueue = SendQueue.enqueue + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + result = enqueue(self, data, **kwargs) + if data == b"E": + joined.set() + return result + + monkeypatch.setattr(SendQueue, "enqueue", notified) + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + assert release.wait(5) + if owner_fails: + raise error + + original = Mock(send=Mock(side_effect=send), close=ws.close) + monkeypatch.setattr("websockets.sync.client.connect", Mock(return_value=original)) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=2) as workers: + owner = workers.submit(connection.send_raw, b"D") + joiner = None + try: + assert entered.wait(3) + joiner = workers.submit(connection.send_raw, b"E") + assert joined.wait(3) + finally: + release.set() + if owner_fails: + with pytest.raises(OSError) as got: + owner.result(timeout=3) + assert got.value is error + else: + owner.result(timeout=3) + if joiner is not None: + joiner.result(timeout=3) + assert ws.attempts == ["A", "B", "C", b"D", b"E"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("owner_fails", [False, True], ids=["successful-owner", "failed-owner"]) +async def test_async_concurrent_sender_owns_its_joined_frame( + name: str, owner_fails: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + entered, release, joined = (asyncio.Event() for _ in range(3)) + error = OSError("owner B failed") + enqueue = SendQueue.enqueue + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + result = enqueue(self, data, **kwargs) + if data == b"E": + joined.set() + return result + + monkeypatch.setattr(SendQueue, "enqueue", notified) + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + await release.wait() + if owner_fails: + raise error + + original = Mock(send=AsyncMock(side_effect=send), close=AsyncMock(side_effect=ws.close)) + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(return_value=original)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + joiner = None + try: + await asyncio.wait_for(entered.wait(), timeout=3) + joiner = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(joined.wait(), timeout=3) + finally: + release.set() + if owner_fails: + with pytest.raises(OSError) as got: + await owner + assert got.value is error + else: + await owner + if joiner is not None: + await joiner + assert ws.attempts == ["A", "B", "C", b"D", b"E"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("failure", ["new-typed", "new-raw", "older", "older-and-overflow"]) +def test_public_send_reports_its_drain_failure(name: str, failure: str, monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + err = OSError("caller-triggered drain failed") + ws.failures[b"D" if failure == "new-raw" else "D" if failure == "new-typed" else "B"] = err + monkeypatch.setattr("websockets.sync.client.connect", Mock(return_value=ws)) + on_reconnecting = Mock() + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), max_queue_size=256, on_reconnecting=on_reconnecting + ) + for label in "ABC": + manager.send(event(label)) + with manager as connection: + assert ws.attempts == ["A"] + if failure == "older-and-overflow": + with pytest.raises(WebSocketQueueFullError): + connection.send_raw(b"D" * 256) + assert ws.attempts == ["A", "B"] + connection.send_raw(b"E") + assert ws.attempts == ["A", "B", "C", b"E"] + else: + with pytest.raises(OSError) as captured: + if failure == "new-raw": + connection.send_raw(b"D") + else: + connection.send(event("D")) + assert captured.value is err + if failure == "older": + assert ws.attempts == ["A", "B"] + connection.send_raw(b"E") + assert ws.attempts == ["A", "B", "C", "D", b"E"] + else: + assert ws.attempts == ["A", "B", "C", b"D" if failure == "new-raw" else "D"] + connection.send_raw(b"E") + assert len(ws.attempts) == 5 + on_reconnecting.assert_not_called() + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("failure", ["new-typed", "new-raw", "older", "older-and-overflow"]) +async def test_async_public_send_reports_its_drain_failure( + name: str, failure: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + err = OSError("caller-triggered drain failed") + ws.failures[b"D" if failure == "new-raw" else "D" if failure == "new-typed" else "B"] = err + transport = Mock(send=AsyncMock(side_effect=ws.send), close=AsyncMock(side_effect=ws.close)) + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(return_value=transport)) + on_reconnecting = Mock() + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), max_queue_size=256, on_reconnecting=on_reconnecting + ) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + assert ws.attempts == ["A"] + if failure == "older-and-overflow": + with pytest.raises(WebSocketQueueFullError): + await connection.send_raw(b"D" * 256) + assert ws.attempts == ["A", "B"] + await connection.send_raw(b"E") + assert ws.attempts == ["A", "B", "C", b"E"] + else: + with pytest.raises(OSError) as captured: + if failure == "new-raw": + await connection.send_raw(b"D") + else: + await connection.send(event("D")) + assert captured.value is err + if failure == "older": + assert ws.attempts == ["A", "B"] + await connection.send_raw(b"E") + assert ws.attempts == ["A", "B", "C", "D", b"E"] + else: + assert ws.attempts == ["A", "B", "C", b"D" if failure == "new-raw" else "D"] + await connection.send_raw(b"E") + assert len(ws.attempts) == 5 + on_reconnecting.assert_not_called() + + +async def test_cancelled_async_joiner_does_not_release_older_flush(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + entered, release, joined = (asyncio.Event() for _ in range(3)) + enqueue = SendQueue.enqueue + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + result = enqueue(self, data, **kwargs) + if data == b"E": + joined.set() + return result + + monkeypatch.setattr(SendQueue, "enqueue", notified) + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + await release.wait() + + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock(return_value=Mock(send=AsyncMock(side_effect=send), close=AsyncMock(side_effect=ws.close))), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = client.live.connect() + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + try: + await asyncio.wait_for(entered.wait(), timeout=3) + joiner = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(joined.wait(), timeout=3) + joiner.cancel() + with pytest.raises(asyncio.CancelledError): + await joiner + assert ws.attempts == ["A", "B"] + finally: + release.set() + await owner + # Cancellation after admission does not replay or silently drop E. + assert ws.attempts == ["A", "B", "C", b"D", b"E"] + + +async def test_closed_transport_reports_joiner_failure(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + entered, release, joined = (asyncio.Event() for _ in range(3)) + enqueue = SendQueue.enqueue + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + result = enqueue(self, data, **kwargs) + if data == b"E": + joined.set() + return result + + monkeypatch.setattr(SendQueue, "enqueue", notified) + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + await release.wait() + ws.close() + raise OSError("older write closed transport") + + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock(return_value=Mock(send=AsyncMock(side_effect=send), close=AsyncMock(side_effect=ws.close))), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = client.live.connect() + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + joiner = None + try: + await asyncio.wait_for(entered.wait(), timeout=3) + joiner = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(joined.wait(), timeout=3) + finally: + release.set() + with pytest.raises(OSError, match="older write closed transport"): + await owner + if joiner is not None: + with pytest.raises(ConnectionClosedOK): + await joiner + assert ws.attempts == ["A", "B"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("failed_index", [1, 2, 3], ids=["fail-E", "fail-F", "fail-G"]) +def test_each_joined_sender_observes_only_its_own_failed_frame( + name: str, failed_index: int, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + entered, release, later_claim = (threading.Event() for _ in range(3)) + joined = [threading.Event() for _ in range(3)] + delayed: set[threading.Thread] = set() + enqueue = SendQueue.enqueue + flush = SendQueue.flush_sync + count = 0 + first_error = OSError("original B failed") + joined_error = OSError("identical joined frame failed") + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + nonlocal count + result = enqueue(self, data, **kwargs) + if data == b"identical": + joined[count].set() + count += 1 + return result + + def ordered(self: SendQueue, *args: Any, **kwargs: Any) -> None: + if threading.current_thread() in delayed: + assert later_claim.wait(5) + flush(self, *args, **kwargs) + + monkeypatch.setattr(SendQueue, "enqueue", notified) + monkeypatch.setattr(SendQueue, "flush_sync", ordered) + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + assert release.wait(5) + raise first_error + if data == b"identical" and ws.attempts.count(b"identical") == failed_index: + raise joined_error + + monkeypatch.setattr( + "websockets.sync.client.connect", Mock(return_value=Mock(send=Mock(side_effect=send), close=ws.close)) + ) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=4) as workers: + + def join(*, delay: bool) -> None: + if delay: + delayed.add(threading.current_thread()) + connection.send_raw(b"identical") + + owner = workers.submit(connection.send_raw, b"D") + waiters = [] + try: + assert entered.wait(3) + for index in range(3): + waiters.append(workers.submit(join, delay=index != 0)) + assert joined[index].wait(3) + release.set() + with pytest.raises(OSError) as got: + owner.result(timeout=3) + assert got.value is first_error + # E deliberately owns the shared batch and retains its batch error. + with pytest.raises(OSError) as got: + waiters[0].result(timeout=3) + assert got.value is joined_error + finally: + release.set() + later_claim.set() + for index, waiter in enumerate(waiters[1:], start=2): + if index == failed_index: + with pytest.raises(OSError) as got: + waiter.result(timeout=3) + assert got.value is joined_error + else: + waiter.result(timeout=3) + assert ws.attempts == ["A", "B", "C", b"D", b"identical", b"identical", b"identical"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("failed_index", [1, 2, 3], ids=["fail-E", "fail-F", "fail-G"]) +async def test_async_each_joined_sender_observes_only_its_own_failed_frame( + name: str, failed_index: int, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + entered, release, later_claim = (asyncio.Event() for _ in range(3)) + joined = [asyncio.Event() for _ in range(3)] + delayed: set[asyncio.Task[Any] | None] = set() + enqueue = SendQueue.enqueue + flush = SendQueue.flush_async + count = 0 + first_error = OSError("original B failed") + joined_error = OSError("identical joined frame failed") + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + nonlocal count + result = enqueue(self, data, **kwargs) + if data == b"identical": + joined[count].set() + count += 1 + return result + + async def ordered(self: SendQueue, *args: Any, **kwargs: Any) -> None: + if asyncio.current_task() in delayed: + await later_claim.wait() + await flush(self, *args, **kwargs) + + monkeypatch.setattr(SendQueue, "enqueue", notified) + monkeypatch.setattr(SendQueue, "flush_async", ordered) + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + await release.wait() + raise first_error + if data == b"identical" and ws.attempts.count(b"identical") == failed_index: + raise joined_error + + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock(return_value=Mock(send=AsyncMock(side_effect=send), close=AsyncMock(side_effect=ws.close))), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + + async def join(*, delay: bool) -> None: + if delay: + delayed.add(asyncio.current_task()) + await connection.send_raw(b"identical") + + owner = asyncio.create_task(connection.send_raw(b"D")) + waiters = [] + try: + await asyncio.wait_for(entered.wait(), timeout=3) + for index in range(3): + waiters.append(asyncio.create_task(join(delay=index != 0))) + await asyncio.wait_for(joined[index].wait(), timeout=3) + release.set() + with pytest.raises(OSError) as got: + await owner + assert got.value is first_error + with pytest.raises(OSError) as got: + await waiters[0] + assert got.value is joined_error + finally: + release.set() + later_claim.set() + for index, waiter in enumerate(waiters[1:], start=2): + if index == failed_index: + with pytest.raises(OSError) as got: + await waiter + assert got.value is joined_error + else: + await waiter + assert ws.attempts == ["A", "B", "C", b"D", b"identical", b"identical", b"identical"] + + +@pytest.mark.asyncio +async def test_cancelled_send_of_another_waiters_frame_reaches_that_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ws = InitialWriteFails() + entered_b, release_b, admitted_e, entered_e = (asyncio.Event() for _ in range(4)) + enqueue = SendQueue.enqueue + + def notified(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + result = enqueue(self, data, **kwargs) + if data == b"E": + admitted_e.set() + return result + + monkeypatch.setattr(SendQueue, "enqueue", notified) + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered_b.set() + await release_b.wait() + if data == b"E": + entered_e.set() + # The owner will be cancelled while writing another caller's frame. + await asyncio.Event().wait() + + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock(return_value=Mock(send=AsyncMock(side_effect=send), close=AsyncMock(side_effect=ws.close))), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, "realtime").connect(**options(client, "realtime")) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + await asyncio.wait_for(entered_b.wait(), timeout=3) + joined = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(admitted_e.wait(), timeout=3) + release_b.set() + await asyncio.wait_for(entered_e.wait(), timeout=3) + owner.cancel("interrupted while sending E") + # asyncio can produce a distinct CancelledError for each task + # boundary. Neither sender may report this attempted frame as sent. + with pytest.raises(asyncio.CancelledError): + await owner + with pytest.raises(asyncio.CancelledError): + await joined + await connection.send_raw(b"F") + assert ws.attempts == ["A", "B", "C", b"D", b"E", b"F"] diff --git a/tests/test_websocket_recovery_regressions.py b/tests/test_websocket_recovery_regressions.py new file mode 100644 index 0000000000..def2b21edf --- /dev/null +++ b/tests/test_websocket_recovery_regressions.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import json +import asyncio +import threading +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import httpx2 +import pytest +from websockets.frames import Close +from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + +from openai import OpenAI, AsyncOpenAI +from openai._exceptions import WebSocketQueueFullError, WebSocketConnectionClosedError +from openai._send_queue import SendQueue +from openai.resources.live.live import LiveConnection, AsyncLiveConnection +from openai.resources.live.forks import ForksConnection, AsyncForksConnection +from openai.lib.responses_websocket import ( + ResponsesWebSocketLimits, + ResponsesWebSocketSession, + AsyncResponsesWebSocketSession, +) +from openai.resources.live.sideband import SidebandConnection, AsyncSidebandConnection +from openai.resources.realtime.realtime import RealtimeConnection, AsyncRealtimeConnection +from openai.resources.responses.responses import ResponsesConnection, AsyncResponsesConnection +from openai.resources.beta.responses.responses import ( + ResponsesConnection as BetaResponsesConnection, + AsyncResponsesConnection as AsyncBetaResponsesConnection, +) +from openai.resources.realtime.translations.translations import TranslationsConnection, AsyncTranslationsConnection + +from .lib.test_websocket_redirects import ( + RECONNECTING_RESOURCES, + options, + resource, + unexpected_http, + async_http_client, +) + +SYNC_BINDINGS = [ + RealtimeConnection, + ResponsesConnection, + BetaResponsesConnection, + LiveConnection, + SidebandConnection, + ForksConnection, + TranslationsConnection, +] +ASYNC_BINDINGS = [ + AsyncRealtimeConnection, + AsyncResponsesConnection, + AsyncBetaResponsesConnection, + AsyncLiveConnection, + AsyncSidebandConnection, + AsyncForksConnection, + AsyncTranslationsConnection, +] +ERROR = b'{"type":"error","event_id":"fake-id","error":{"type":"fixture","message":"fake message"}}' + + +def closed(code: int) -> ConnectionClosedOK | ConnectionClosedError: + if code == 1006: + # 1006 denotes loss without a close frame; it is never sent on the wire. + return ConnectionClosedError(None, None) + frame = Close(code, "") + kind = ConnectionClosedOK if code in (1000, 1001, 1005) else ConnectionClosedError + return kind(frame, frame, True) + + +@pytest.mark.parametrize("code", [1000, 1001, 1005, 1011]) +def test_sync_session_reports_unsent_after_failed_clean_recovery(code: int) -> None: + socket = MagicMock() + socket.recv.side_effect = closed(code) + + def failed_handshake(*_args: Any) -> Any: + conn.send_raw("queued-during-recovery") + raise OSError("synthetic failed handshake") + + conn = ResponsesConnection( + socket, make_ws=failed_handshake, on_reconnecting=lambda _: None, max_retries=1, initial_delay=0 + ) + limits = ResponsesWebSocketLimits(1, 4, 4, 4096, 4096) + with ResponsesWebSocketSession(conn, limits=limits) as session: + if code == 1000: + with pytest.raises(EOFError): + session.default.recv(timeout=3) + else: + with pytest.raises(WebSocketConnectionClosedError) as caught: + session.default.recv(timeout=3) + assert caught.value.unsent_messages == ["queued-during-recovery"] + assert conn._send_queue.drain() == [] + socket.send.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("code", [1000, 1001, 1005, 1011]) +async def test_async_session_reports_unsent_after_failed_clean_recovery(code: int) -> None: + socket = MagicMock() + socket.recv = AsyncMock(side_effect=closed(code)) + socket.close = AsyncMock() + + async def failed_handshake(*_args: Any) -> Any: + await conn.send_raw("queued-during-recovery") + raise OSError("synthetic failed handshake") + + conn = AsyncResponsesConnection( + socket, make_ws=failed_handshake, on_reconnecting=lambda _: None, max_retries=1, initial_delay=0 + ) + limits = ResponsesWebSocketLimits(1, 4, 4, 4096, 4096) + async with AsyncResponsesWebSocketSession(conn, limits=limits) as session: + if code == 1000: + with pytest.raises(EOFError): + await asyncio.wait_for(session.default.recv(), 3) + else: + with pytest.raises(WebSocketConnectionClosedError) as caught: + await asyncio.wait_for(session.default.recv(), 3) + assert caught.value.unsent_messages == ["queued-during-recovery"] + assert conn._send_queue.drain() == [] + socket.send.assert_not_called() + + +@pytest.mark.parametrize("connection_type", SYNC_BINDINGS) +@pytest.mark.parametrize("code,expected", [(1000, []), (1001, [1001]), (1005, [1005]), (1012, [1012])]) +def test_received_close_policy_on_sync_iterator(connection_type: type[Any], code: int, expected: list[int]) -> None: + original, replacement = MagicMock(), MagicMock() + original.recv.side_effect = closed(code) + replacement.recv.side_effect = [ERROR, closed(1000)] + seen: list[int] = [] + conn = connection_type( + original, + make_ws=lambda *_: replacement, + on_reconnecting=lambda e: seen.append(e.close_code), + initial_delay=0, + ) + events = list(conn) + assert seen == expected + assert [e.type for e in events] == (["error"] if expected else []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_type", ASYNC_BINDINGS) +@pytest.mark.parametrize("code,expected", [(1000, []), (1001, [1001]), (1005, [1005]), (1012, [1012])]) +async def test_received_close_policy_on_async_iterator( + connection_type: type[Any], code: int, expected: list[int] +) -> None: + original, replacement = MagicMock(), MagicMock() + original.recv = AsyncMock(side_effect=closed(code)) + replacement.recv = AsyncMock(side_effect=[ERROR, closed(1000)]) + seen: list[int] = [] + conn = connection_type( + original, + make_ws=AsyncMock(return_value=replacement), + on_reconnecting=lambda e: seen.append(e.close_code), + initial_delay=0, + ) + events = [e async for e in conn] + assert seen == expected + assert [e.type for e in events] == (["error"] if expected else []) + + +def register_handlers(conn: Any, mode: str, seen: list[str]) -> None: + def handler(event: Any) -> None: + seen.append(event.type) + + # The generic no-op leaves an error consumer after the off+once case ends. + conn.on("event", lambda _e: None) + if mode == "same": + conn.on("error", handler) + conn.once("error", handler) + elif mode == "different": + conn.on("event", handler) + conn.once("error", handler) + else: + conn.on("error", handler) + conn.once("error", handler) + conn.off("error", handler) + + +@pytest.mark.parametrize("connection_type", SYNC_BINDINGS) +@pytest.mark.parametrize("mode,count", [("same", 3), ("different", 3), ("off", 1)]) +def test_sync_dispatch_keeps_each_registration(connection_type: type[Any], mode: str, count: int) -> None: + socket = MagicMock() + socket.recv.side_effect = [ERROR, ERROR, closed(1000)] + conn = connection_type(socket) + seen: list[str] = [] + register_handlers(conn, mode, seen) + conn.dispatch_events() + assert seen == ["error"] * count + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_type", ASYNC_BINDINGS) +@pytest.mark.parametrize("mode,count", [("same", 3), ("different", 3), ("off", 1)]) +async def test_async_dispatch_keeps_each_registration(connection_type: type[Any], mode: str, count: int) -> None: + socket = MagicMock() + socket.recv = AsyncMock(side_effect=[ERROR, ERROR, closed(1000)]) + conn = connection_type(socket) + seen: list[str] = [] + register_handlers(conn, mode, seen) + await conn.dispatch_events() + assert seen == ["error"] * count + + +@pytest.mark.parametrize("connection_type", SYNC_BINDINGS) +@pytest.mark.parametrize("typed", [False, True], ids=["raw", "typed"]) +def test_sync_flush_admits_writes_in_order(connection_type: type[Any], typed: bool) -> None: + entered, release, closed_socket = threading.Event(), threading.Event(), threading.Event() + sent: list[str | bytes] = [] + queue = SendQueue(max_bytes=80) + queue.enqueue("old-1") + queue.enqueue("old-2") + original, replacement = MagicMock(), MagicMock() + + def send(data: str | bytes) -> None: + sent.append(data) + if data == "old-1": + entered.set() + assert release.wait(3), "test failed to release blocked write" + + replacement.send.side_effect = send + replacement.close.side_effect = lambda **_kwargs: closed_socket.set() + conn = connection_type( + original, send_queue=queue, make_ws=lambda *_: replacement, on_reconnecting=lambda _: None, initial_delay=0 + ) + recovered: list[bool] = [] + worker = threading.Thread(target=lambda: recovered.append(conn._reconnect(closed(1012)))) + worker.start() + try: + assert entered.wait(3) + if typed: + conn.send({"type": "response.create"}) + last = json.dumps({"type": "response.create"}) + else: + conn.send_raw("new-3") + last = "new-3" + with pytest.raises(WebSocketQueueFullError): + conn.send_raw("x" * 80) + assert sent == ["old-1"] + finally: + release.set() + worker.join(3) + assert not worker.is_alive() + assert recovered == [True] + assert sent == ["old-1", "old-2", last] + conn.close() + assert closed_socket.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_type", ASYNC_BINDINGS) +@pytest.mark.parametrize("typed", [False, True], ids=["raw", "typed"]) +async def test_async_flush_admits_writes_in_order(connection_type: type[Any], typed: bool) -> None: + entered, release = asyncio.Event(), asyncio.Event() + sent: list[str | bytes] = [] + queue = SendQueue(max_bytes=80) + queue.enqueue("old-1") + queue.enqueue("old-2") + original, replacement = MagicMock(), MagicMock() + + async def send(data: str | bytes) -> None: + sent.append(data) + if data == "old-1": + entered.set() + await release.wait() + + replacement.send = AsyncMock(side_effect=send) + conn = connection_type( + original, + send_queue=queue, + make_ws=AsyncMock(return_value=replacement), + on_reconnecting=lambda _: None, + initial_delay=0, + ) + worker = asyncio.create_task(conn._reconnect(closed(1012))) + try: + await asyncio.wait_for(entered.wait(), 3) + if typed: + await conn.send({"type": "response.create"}) + last = json.dumps({"type": "response.create"}) + else: + await conn.send_raw("new-3") + last = "new-3" + with pytest.raises(WebSocketQueueFullError): + await conn.send_raw("x" * 80) + assert sent == ["old-1"] + finally: + release.set() + result = await asyncio.wait_for(worker, 3) + assert result + assert sent == ["old-1", "old-2", last] + + +def test_sync_close_interrupts_a_blocked_flush() -> None: + entered, socket_closed = threading.Event(), threading.Event() + socket = MagicMock() + attempted: list[str | bytes] = [] + queue = SendQueue() + queue.enqueue("attempted") + queue.enqueue("never attempted") + + def send(data: str | bytes) -> None: + attempted.append(data) + entered.set() + assert socket_closed.wait(3), "close must not wait for network send" + raise RuntimeError("fixture: transport closed") + + socket.send.side_effect = send + socket.close.side_effect = lambda **_: socket_closed.set() + conn = RealtimeConnection( + socket, send_queue=queue, make_ws=lambda *_: socket, on_reconnecting=lambda _: None, initial_delay=0 + ) + worker = threading.Thread(target=lambda: conn._reconnect(closed(1012))) + worker.start() + try: + assert entered.wait(3) + conn.close() + assert socket_closed.is_set() + finally: + socket_closed.set() + worker.join(3) + assert not worker.is_alive() + assert attempted == ["attempted"] + assert queue.drain() == ["never attempted"] + + +@pytest.mark.parametrize("connection_type", SYNC_BINDINGS) +@pytest.mark.parametrize( + "failure,expected_codes", + [(None, [1012, 1012]), (1000, [1012]), (1006, [1012, 1006]), (1008, [1012]), (1013, [1012, 1013])], +) +@pytest.mark.parametrize("budget", [1, 2]) +def test_sync_failed_recovery_flush_keeps_unsent_order( + connection_type: type[Any], failure: int | None, expected_codes: list[int], budget: int +) -> None: + original, failed, working = MagicMock(), MagicMock(), MagicMock() + initial_close = closed(1012) + original.recv.side_effect = initial_close + failed.recv.return_value = ERROR + working.recv.return_value = ERROR + queue = SendQueue() + queue.enqueue("attempted") + queue.enqueue("earlier") + entered, release = threading.Event(), threading.Event() + attempts: list[str | bytes] = [] + codes: list[int] = [] + + def send(data: str | bytes) -> None: + attempts.append(data) + entered.set() + assert release.wait(3) + if failure is not None: + raise closed(failure) + raise OSError("synthetic write failure") + + failed.send.side_effect = send + working.send.side_effect = attempts.append + conn = connection_type( + original, + send_queue=queue, + make_ws=MagicMock(side_effect=[failed, working]), + on_reconnecting=lambda e: codes.append(e.close_code), + initial_delay=0, + max_retries=budget, + ) + events: list[Any] = [] + errors: list[WebSocketConnectionClosedError] = [] + + def read() -> None: + try: + events.append(next(iter(conn))) + except WebSocketConnectionClosedError as exc: + errors.append(exc) + + worker = threading.Thread(target=read) + worker.start() + try: + assert entered.wait(3) + conn.send_raw(b"\xfflater") + finally: + release.set() + worker.join(3) + assert not worker.is_alive() + assert codes == expected_codes[:budget] + failed.recv.assert_not_called() + failed.close.assert_called_once() + if len(codes) == 2: + assert [event.type for event in events] == ["error"] + assert not errors + assert attempts == ["attempted", "earlier", b"\xfflater"] + else: + assert not events + assert len(errors) == 1 + assert errors[0].unsent_frames == ["earlier", b"\xfflater"] + assert errors[0].__cause__ is initial_close + assert attempts == ["attempted"] + assert queue.drain() == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_type", ASYNC_BINDINGS) +@pytest.mark.parametrize( + "failure,expected_codes", + [(None, [1012, 1012]), (1000, [1012]), (1006, [1012, 1006]), (1008, [1012]), (1013, [1012, 1013])], +) +@pytest.mark.parametrize("budget", [1, 2]) +async def test_async_failed_recovery_flush_keeps_unsent_order( + connection_type: type[Any], failure: int | None, expected_codes: list[int], budget: int +) -> None: + original, failed, working = MagicMock(), MagicMock(), MagicMock() + initial_close = closed(1012) + original.recv = AsyncMock(side_effect=initial_close) + failed.recv = AsyncMock(return_value=ERROR) + failed.close = AsyncMock() + working.recv = AsyncMock(return_value=ERROR) + working.close = AsyncMock() + queue = SendQueue() + queue.enqueue("attempted") + queue.enqueue("earlier") + entered, release = asyncio.Event(), asyncio.Event() + attempts: list[str | bytes] = [] + codes: list[int] = [] + + async def send(data: str | bytes) -> None: + attempts.append(data) + entered.set() + await release.wait() + if failure is not None: + raise closed(failure) + raise OSError("synthetic write failure") + + failed.send = AsyncMock(side_effect=send) + working.send = AsyncMock(side_effect=attempts.append) + conn = connection_type( + original, + send_queue=queue, + make_ws=AsyncMock(side_effect=[failed, working]), + on_reconnecting=lambda e: codes.append(e.close_code), + initial_delay=0, + max_retries=budget, + ) + worker = asyncio.create_task(anext(aiter(conn))) + await asyncio.wait_for(entered.wait(), 3) + try: + await conn.send_raw(b"\xfflater") + finally: + release.set() + if len(expected_codes) > 1 and budget > 1: + event = await asyncio.wait_for(worker, 3) + assert event.type == "error" + assert attempts == ["attempted", "earlier", b"\xfflater"] + else: + with pytest.raises(WebSocketConnectionClosedError) as caught: + await asyncio.wait_for(worker, 3) + assert caught.value.unsent_frames == ["earlier", b"\xfflater"] + assert caught.value.__cause__ is initial_close + assert attempts == ["attempted"] + assert codes == expected_codes[:budget] + failed.recv.assert_not_called() + failed.close.assert_awaited_once() + assert queue.drain() == [] + + +@pytest.mark.parametrize("connection_type", SYNC_BINDINGS) +def test_sync_non_recovery_flush_preserves_entry_failure_behavior(connection_type: type[Any]) -> None: + queue = SendQueue() + queue.enqueue("attempted") + queue.enqueue("remaining") + socket = MagicMock() + socket.send.side_effect = OSError("synthetic entry failure") + conn = connection_type(socket, send_queue=queue) + conn._flush_send_queue() + assert queue.drain() == ["remaining"] + socket.close.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_type", ASYNC_BINDINGS) +async def test_async_non_recovery_flush_preserves_entry_failure_behavior(connection_type: type[Any]) -> None: + queue = SendQueue() + queue.enqueue("attempted") + queue.enqueue("remaining") + socket = MagicMock() + socket.send = AsyncMock(side_effect=OSError("synthetic entry failure")) + socket.close = AsyncMock() + conn = connection_type(socket, send_queue=queue) + await conn._flush_send_queue() + assert queue.drain() == ["remaining"] + socket.close.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_type", ASYNC_BINDINGS) +async def test_cancelled_recovery_flush_does_not_retry(connection_type: type[Any]) -> None: + original, replacement = MagicMock(), MagicMock() + original.recv = AsyncMock(side_effect=closed(1012)) + replacement.close = AsyncMock() + entered = asyncio.Event() + queue = SendQueue() + queue.enqueue("attempted") + queue.enqueue("remaining") + + async def blocked_send(_data: str | bytes) -> None: + entered.set() + await asyncio.Event().wait() + + replacement.send = AsyncMock(side_effect=blocked_send) + handshakes = AsyncMock(return_value=replacement) + conn = connection_type( + original, + send_queue=queue, + make_ws=handshakes, + on_reconnecting=lambda _: None, + initial_delay=0, + ) + reader = asyncio.create_task(anext(aiter(conn))) + await asyncio.wait_for(entered.wait(), 3) + await conn.send_raw("later") + reader.cancel() + with pytest.raises(asyncio.CancelledError): + await reader + handshakes.assert_awaited_once() + assert queue.drain() == ["remaining", "later"] + await conn.close() + replacement.close.assert_awaited_once() + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("mode,count", [("same", 3), ("different", 3), ("off", 1)]) +def test_sync_manager_transfers_per_registration_flags( + monkeypatch: pytest.MonkeyPatch, name: str, mode: str, count: int +) -> None: + socket = MagicMock() + socket.recv.side_effect = [ERROR, ERROR, closed(1000)] + monkeypatch.setattr("websockets.sync.client.connect", MagicMock(return_value=socket)) + with OpenAI( + api_key="fake-key", + websocket_base_url="wss://origin.test", + http_client=httpx2.Client(transport=httpx2.MockTransport(unexpected_http)), + ) as client: + manager = resource(client, name).connect(**options(client, name)) + seen: list[str] = [] + register_handlers(manager, mode, seen) + with manager as conn: + conn.dispatch_events() + assert seen == ["error"] * count + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("mode,count", [("same", 3), ("different", 3), ("off", 1)]) +async def test_async_manager_transfers_per_registration_flags( + monkeypatch: pytest.MonkeyPatch, name: str, mode: str, count: int +) -> None: + socket = MagicMock() + socket.recv = AsyncMock(side_effect=[ERROR, ERROR, closed(1000)]) + socket.close = AsyncMock() + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(return_value=socket)) + async with AsyncOpenAI( + api_key="fake-key", websocket_base_url="wss://origin.test", http_client=async_http_client() + ) as client: + manager = resource(client, name).connect(**options(client, name)) + seen: list[str] = [] + register_handlers(manager, mode, seen) + async with manager as conn: + await conn.dispatch_events() + assert seen == ["error"] * count diff --git a/tests/test_websocket_terminal_flush.py b/tests/test_websocket_terminal_flush.py new file mode 100644 index 0000000000..550c5cbc9f --- /dev/null +++ b/tests/test_websocket_terminal_flush.py @@ -0,0 +1,1196 @@ +from __future__ import annotations + +import json +import asyncio +import threading +from typing import Any +from unittest.mock import Mock, AsyncMock +from concurrent.futures import ThreadPoolExecutor + +import pytest +from websockets.frames import Close +from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError + +from openai import OpenAI, AsyncOpenAI, WebSocketConnectionClosedError +from openai._send_queue import SendQueue +from tests.lib.test_websocket_redirects import RECONNECTING_RESOURCES, options, resource +from tests.test_websocket_initial_backlog import InitialWriteFails, event + + +def close_error(code: int) -> ConnectionClosedOK | ConnectionClosedError: + frame = Close(code, "synthetic") + cls = ConnectionClosedOK if code == 1000 else ConnectionClosedError + return cls(frame, frame, True) + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("observe", ["unsent", "post-terminal-send"]) +def test_reader_closes_while_public_backlog_write_blocks( + name: str, observe: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + started, release = threading.Event(), threading.Event() + terminal = close_error(1000) + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + started.set() + assert release.wait(5) + raise OSError("blocked write failed after closure") + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise terminal + + transport = Mock(send=Mock(side_effect=send), recv=Mock(side_effect=recv), close=ws.close) + monkeypatch.setattr("websockets.sync.client.connect", Mock(return_value=transport)) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=2) as workers: + writing = workers.submit(connection.send_raw, b"D") + try: + assert started.wait(3), "retained write did not start" + try: + next(iter(connection)) + pytest.fail("terminal recv yielded an event") + except (WebSocketConnectionClosedError, StopIteration) as err: + if observe == "unsent": + assert isinstance(err, WebSocketConnectionClosedError) + assert err.__cause__ is terminal + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in err.unsent_frames] == [ + "C", + b"D", + ] + else: + with pytest.raises(ConnectionClosedOK): + workers.submit(connection.send_raw, b"E").result(timeout=3) + finally: + release.set() + with pytest.raises(OSError, match="blocked write"): + writing.result(timeout=3) + assert ws.attempts == ["A", "B"] + + +def test_terminal_drain_wins_when_new_send_waits_for_queue_admission(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + writing_b, release_b = threading.Event(), threading.Event() + admitting_e, release_e = threading.Event(), threading.Event() + enqueue = SendQueue.enqueue + + def paused_enqueue(queue: SendQueue, data: str | bytes, **kwargs: Any) -> Any: + if data == b"E": + # Pause immediately before the real queue lock/admission operation. + admitting_e.set() + assert release_e.wait(5) + return enqueue(queue, data, **kwargs) + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + writing_b.set() + assert release_b.wait(5) + raise OSError("blocked write failed after closure") + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise close_error(1000) + + monkeypatch.setattr(SendQueue, "enqueue", paused_enqueue) + transport = Mock(send=Mock(side_effect=send), recv=Mock(side_effect=recv), close=ws.close) + monkeypatch.setattr("websockets.sync.client.connect", Mock(return_value=transport)) + with OpenAI(api_key="synthetic-only") as client: + manager = client.live.connect() + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + with manager as connection, ThreadPoolExecutor(max_workers=2) as workers: + writing = workers.submit(connection.send_raw, b"D") + sending = None + try: + assert writing_b.wait(3) + sending = workers.submit(connection.send_raw, b"E") + assert admitting_e.wait(3) + try: + next(iter(connection)) + except (StopIteration, WebSocketConnectionClosedError): + pass + release_e.set() + with pytest.raises(ConnectionClosedOK): + sending.result(timeout=3) + finally: + release_e.set() + release_b.set() + if sending is not None and not sending.done(): + sending.result(timeout=3) + with pytest.raises(OSError, match="blocked write"): + writing.result(timeout=3) + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("observe", ["unsent", "post-terminal-send"]) +async def test_async_reader_closes_while_public_backlog_write_blocks( + name: str, observe: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + started, release = asyncio.Event(), asyncio.Event() + terminal = close_error(1000) + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + started.set() + await release.wait() + raise OSError("blocked write failed after closure") + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise terminal + + transport = Mock( + send=AsyncMock(side_effect=send), recv=AsyncMock(side_effect=recv), close=AsyncMock(side_effect=ws.close) + ) + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(return_value=transport)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + writing = asyncio.create_task(connection.send_raw(b"D")) + try: + await asyncio.wait_for(started.wait(), timeout=3) + try: + await anext(aiter(connection)) + pytest.fail("terminal recv yielded an event") + except (WebSocketConnectionClosedError, StopAsyncIteration) as err: + if observe == "unsent": + assert isinstance(err, WebSocketConnectionClosedError) + assert err.__cause__ is terminal + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in err.unsent_frames] == [ + "C", + b"D", + ] + else: + with pytest.raises(ConnectionClosedOK): + await asyncio.wait_for(connection.send_raw(b"E"), timeout=3) + finally: + release.set() + with pytest.raises(OSError, match="blocked write"): + await writing + assert ws.attempts == ["A", "B"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +def test_close_with_recoverable_code_during_public_recovery_flush(name: str, monkeypatch: pytest.MonkeyPatch) -> None: + initial = close_error(1012) + interrupted = close_error(1011) + callbacks: list[int] = [] + attempted: list[str | bytes] = [] + original = Mock(recv=Mock(side_effect=initial)) + + def send(data: str | bytes) -> None: + attempted.append(data) + connection.close(code=1011) + raise interrupted + + replacement = Mock(send=Mock(side_effect=send)) + + def reconnect(e: Any) -> None: + callbacks.append(e.close_code) + + def dial(*_args: Any, **_kwargs: Any) -> Any: + if callbacks: + connection.send(event("B")) + connection.send_raw(b"C") + return replacement + return original + + monkeypatch.setattr("websockets.sync.client.connect", Mock(side_effect=dial)) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=reconnect, max_retries=3, initial_delay=0 + ) + with manager as connection: + with pytest.raises(WebSocketConnectionClosedError) as captured: + next(iter(connection)) + assert captured.value.__cause__ is initial + assert captured.value.unsent_frames == [b"C"] + assert callbacks == [1012] + assert len(attempted) == 1 + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +async def test_async_close_with_recoverable_code_during_public_recovery_flush( + name: str, monkeypatch: pytest.MonkeyPatch +) -> None: + initial = close_error(1012) + interrupted = close_error(1011) + callbacks: list[int] = [] + attempted: list[str | bytes] = [] + original = Mock(recv=AsyncMock(side_effect=initial), close=AsyncMock()) + + async def send(data: str | bytes) -> None: + attempted.append(data) + await connection.close(code=1011) + raise interrupted + + replacement = Mock(send=AsyncMock(side_effect=send), close=AsyncMock()) + + def reconnect(e: Any) -> None: + callbacks.append(e.close_code) + + async def dial(*_args: Any, **_kwargs: Any) -> Any: + if callbacks: + await connection.send(event("B")) + await connection.send_raw(b"C") + return replacement + return original + + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(side_effect=dial)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=reconnect, max_retries=3, initial_delay=0 + ) + async with manager as connection: + with pytest.raises(WebSocketConnectionClosedError) as captured: + await anext(aiter(connection)) + assert captured.value.__cause__ is initial + assert captured.value.unsent_frames == [b"C"] + assert callbacks == [1012] + assert len(attempted) == 1 + + +def test_recovery_waits_for_older_public_flush_owner(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + started, release, opened, replacement_read = (threading.Event() for _ in range(4)) + sent: list[str | bytes] = [] + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + started.set() + assert release.wait(5) + raise OSError("old owner interrupted") + + def read_replacement(**_kwargs: Any) -> bytes: + replacement_read.set() + return b'{"type":"session.created","session":{"id":"synthetic"}}' + + original = Mock(send=Mock(side_effect=send), recv=Mock(side_effect=close_error(1012)), close=ws.close) + replacement = Mock(send=sent.append, recv=Mock(side_effect=read_replacement)) + calls = 0 + + def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + return original + opened.set() + return replacement + + monkeypatch.setattr("websockets.sync.client.connect", Mock(side_effect=dial)) + with OpenAI(api_key="synthetic-only") as client: + manager = client.live.connect(on_reconnecting=lambda _e: None, initial_delay=0) + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + with manager as connection, ThreadPoolExecutor(max_workers=2) as workers: + writing = workers.submit(connection.send_raw, b"D") + reading = None + try: + assert started.wait(3) + reading = workers.submit(lambda: next(iter(connection))) + assert opened.wait(3) + assert not replacement_read.wait(0.15), "replacement was declared ready with old frames still owned" + finally: + release.set() + with pytest.raises(OSError, match="old owner"): + writing.result(timeout=3) + if reading is not None: + reading.result(timeout=3) + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in sent] == ["C", b"D"] + + +async def test_async_recovery_waits_for_older_public_flush_owner(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + started, release, opened, replacement_read = (asyncio.Event() for _ in range(4)) + sent: list[str | bytes] = [] + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + started.set() + await release.wait() + raise OSError("old owner interrupted") + + def read_replacement(**_kwargs: Any) -> bytes: + replacement_read.set() + return b'{"type":"session.created","session":{"id":"synthetic"}}' + + original = Mock(send=AsyncMock(side_effect=send), recv=AsyncMock(side_effect=close_error(1012)), close=AsyncMock()) + replacement = Mock( + send=AsyncMock(side_effect=sent.append), recv=AsyncMock(side_effect=read_replacement), close=AsyncMock() + ) + calls = 0 + + async def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + return original + opened.set() + return replacement + + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(side_effect=dial)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = client.live.connect(on_reconnecting=lambda _e: None, initial_delay=0) + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + async with manager as connection: + writing = asyncio.create_task(connection.send_raw(b"D")) + reading = None + try: + await asyncio.wait_for(started.wait(), timeout=3) + reading = asyncio.ensure_future(anext(aiter(connection))) + await asyncio.wait_for(opened.wait(), timeout=3) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(replacement_read.wait(), timeout=0.15) + finally: + release.set() + with pytest.raises(OSError, match="old owner"): + await writing + if reading is not None: + await reading + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in sent] == ["C", b"D"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("timing", ["during-write", "after-write"]) +def test_ordinary_flush_hands_recovery_frames_to_reader( + name: str, timing: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + started, release_write = threading.Event(), threading.Event() + handshake, release_handshake = threading.Event(), threading.Event() + recovered: list[str | bytes] = [] + ready = b'{"type":"error","event_id":"fixture","error":{"type":"fixture","message":"ready"}}' + + def send(data: str | bytes) -> None: + ws.send(data) + if data == b"C": + started.set() + assert release_write.wait(5) + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise close_error(1012) + + original = Mock(send=Mock(side_effect=send), recv=Mock(side_effect=recv), close=ws.close) + replacement = Mock(send=Mock(side_effect=recovered.append), recv=Mock(return_value=ready)) + dials = 0 + + def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal dials + dials += 1 + if dials == 1: + return original + handshake.set() + assert release_handshake.wait(5) + return replacement + + monkeypatch.setattr("websockets.sync.client.connect", Mock(side_effect=dial)) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=lambda _e: None, initial_delay=0, max_retries=1 + ) + for value in "AB": + manager.send(event(value)) + with manager as connection, ThreadPoolExecutor(max_workers=2) as pool: + writing = pool.submit(connection.send_raw, b"C") + reading = None + try: + assert started.wait(3) + reading = pool.submit(lambda: next(iter(connection))) + assert handshake.wait(3) + if timing == "during-write": + connection.send_raw(b"D") + release_write.set() + writing.result(timeout=3) + if timing == "after-write": + connection.send_raw(b"D") + assert recovered == [] + finally: + release_write.set() + release_handshake.set() + assert reading is not None + assert reading.result(timeout=3).type == "error" + assert ws.attempts == ["A", "B", b"C"] + assert recovered == [b"D"] + connection.send_raw(b"E") + assert recovered == [b"D", b"E"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("timing", ["during-write", "after-write"]) +async def test_async_ordinary_flush_hands_recovery_frames_to_reader( + name: str, timing: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + started, release_write = asyncio.Event(), asyncio.Event() + handshake, release_handshake = asyncio.Event(), asyncio.Event() + recovered: list[str | bytes] = [] + ready = b'{"type":"error","event_id":"fixture","error":{"type":"fixture","message":"ready"}}' + + async def send(data: str | bytes) -> None: + ws.send(data) + if data == b"C": + started.set() + await release_write.wait() + + async def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise close_error(1012) + + original = Mock( + send=AsyncMock(side_effect=send), recv=AsyncMock(side_effect=recv), close=AsyncMock(side_effect=ws.close) + ) + replacement = Mock( + send=AsyncMock(side_effect=recovered.append), recv=AsyncMock(return_value=ready), close=AsyncMock() + ) + dials = 0 + + async def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal dials + dials += 1 + if dials == 1: + return original + handshake.set() + await release_handshake.wait() + return replacement + + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(side_effect=dial)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=lambda _e: None, initial_delay=0, max_retries=1 + ) + for value in "AB": + manager.send(event(value)) + async with manager as connection: + writing = asyncio.create_task(connection.send_raw(b"C")) + reading = None + try: + await asyncio.wait_for(started.wait(), 3) + reading = asyncio.ensure_future(anext(aiter(connection))) + await asyncio.wait_for(handshake.wait(), 3) + if timing == "during-write": + await connection.send_raw(b"D") + release_write.set() + await asyncio.wait_for(writing, 3) + if timing == "after-write": + await connection.send_raw(b"D") + assert recovered == [] + finally: + release_write.set() + release_handshake.set() + assert reading is not None + assert (await asyncio.wait_for(reading, 3)).type == "error" + assert ws.attempts == ["A", "B", b"C"] + assert recovered == [b"D"] + await connection.send_raw(b"E") + assert recovered == [b"D", b"E"] + + +@pytest.mark.parametrize("owner_state", ["waiting", "released"]) +def test_recovery_takes_frame_from_ordinary_flusher_with_stale_socket( + owner_state: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + first_entered, resume_first = threading.Event(), threading.Event() + last_write, resume_last = threading.Event(), threading.Event() + first_waiting = threading.Event() + handshake, finish_handshake = threading.Event(), threading.Event() + first_ident: int | None = None + recovered: list[str | bytes] = [] + ready = b'{"type":"error","event_id":"fixture","error":{"type":"fixture","message":"ready"}}' + original_flush = SendQueue.flush_sync + original_begin = SendQueue._begin_flush + + def delayed_flush(queue: SendQueue, *args: Any, **kwargs: Any) -> None: + if threading.get_ident() == first_ident: + first_entered.set() + assert resume_first.wait(5) + original_flush(queue, *args, **kwargs) + + def observed_begin(queue: SendQueue) -> Any: + batch = original_begin(queue) + if threading.get_ident() == first_ident and isinstance(batch, threading.Event): + first_waiting.set() + return batch + + def send(data: str | bytes) -> None: + ws.send(data) + if data == b"D": + last_write.set() + assert resume_last.wait(5) + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise close_error(1012) + + original = Mock(send=Mock(side_effect=send), recv=Mock(side_effect=recv), close=ws.close) + replacement = Mock(send=Mock(side_effect=recovered.append), recv=Mock(return_value=ready)) + dials = 0 + + def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal dials + dials += 1 + if dials == 1: + return original + handshake.set() + assert finish_handshake.wait(5) + return replacement + + def first_send(connection: Any) -> None: + nonlocal first_ident + first_ident = threading.get_ident() + connection.send_raw(b"C") + + monkeypatch.setattr("websockets.sync.client.connect", Mock(side_effect=dial)) + monkeypatch.setattr(SendQueue, "flush_sync", delayed_flush) + monkeypatch.setattr(SendQueue, "_begin_flush", observed_begin) + with OpenAI(api_key="synthetic-only") as client: + manager = client.live.connect(on_reconnecting=lambda _e: None, initial_delay=0, max_retries=1) + for value in "AB": + manager.send({"type": "session.update", "event_id": value, "session": {}}) + with manager as connection, ThreadPoolExecutor(max_workers=3) as pool: + first = pool.submit(first_send, connection) + reading = None + try: + assert first_entered.wait(3) + second = pool.submit(connection.send_raw, b"D") + assert last_write.wait(3) + if owner_state == "waiting": + resume_first.set() + assert first_waiting.wait(3) + reading = pool.submit(lambda: next(iter(connection))) + assert handshake.wait(3) + connection.send_raw(b"E") + resume_last.set() + if owner_state == "released": + second.result(timeout=3) + resume_first.set() + first.result(timeout=3) + second.result(timeout=3) + finally: + resume_first.set() + resume_last.set() + finish_handshake.set() + assert reading is not None + assert reading.result(timeout=3).type == "error" + assert ws.attempts == ["A", "B", b"C", b"D"] + assert recovered == [b"E"] + connection.send_raw(b"F") + assert recovered == [b"E", b"F"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("old_closed", [False, True], ids=["old-still-writable", "old-closed"]) +def test_successful_frame_hands_unattempted_batch_tail_to_reader( + name: str, old_closed: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + started, release, opened = (threading.Event() for _ in range(3)) + sent: list[str | bytes] = [] + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + started.set() + assert release.wait(5) + + def recv(**_kwargs: Any) -> bytes: + if old_closed: + ws.close() + raise close_error(1012) + + original = Mock(send=Mock(side_effect=send), recv=Mock(side_effect=recv), close=ws.close) + replacement = Mock(send=sent.append, recv=Mock(return_value=b'{"type":"error","error":{"message":"fixture"}}')) + calls = 0 + + def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + return original + opened.set() + return replacement + + monkeypatch.setattr("websockets.sync.client.connect", Mock(side_effect=dial)) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=lambda _e: None, initial_delay=0 + ) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=2) as workers: + writing = workers.submit(connection.send_raw, b"D") + reading = None + try: + assert started.wait(3) + reading = workers.submit(lambda: next(iter(connection))) + assert opened.wait(3) + connection.send_raw(b"E") + finally: + release.set() + # B completed successfully. Only unattempted C/D/E remain. + writing.result(timeout=3) + if reading is not None: + reading.result(timeout=3) + assert ws.attempts == ["A", "B"] + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in sent] == ["C", b"D", b"E"] + connection.send_raw(b"F") + assert sent[-1] == b"F" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("old_closed", [False, True], ids=["old-still-writable", "old-closed"]) +async def test_async_successful_frame_hands_unattempted_batch_tail_to_reader( + name: str, old_closed: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + started, release, opened = (asyncio.Event() for _ in range(3)) + sent: list[str | bytes] = [] + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + started.set() + await release.wait() + + def recv(**_kwargs: Any) -> bytes: + if old_closed: + ws.close() + raise close_error(1012) + + original = Mock( + send=AsyncMock(side_effect=send), + recv=AsyncMock(side_effect=recv), + close=AsyncMock(side_effect=ws.close), + ) + replacement = Mock( + send=AsyncMock(side_effect=sent.append), + recv=AsyncMock(return_value=b'{"type":"error","error":{"message":"fixture"}}'), + close=AsyncMock(), + ) + calls = 0 + + async def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + return original + opened.set() + return replacement + + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(side_effect=dial)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=lambda _e: None, initial_delay=0 + ) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + writing = asyncio.create_task(connection.send_raw(b"D")) + reading = None + try: + await asyncio.wait_for(started.wait(), timeout=3) + reading = asyncio.ensure_future(anext(aiter(connection))) + await asyncio.wait_for(opened.wait(), timeout=3) + await connection.send_raw(b"E") + finally: + release.set() + await writing + if reading is not None: + await reading + assert ws.attempts == ["A", "B"] + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in sent] == ["C", b"D", b"E"] + await connection.send_raw(b"F") + assert sent[-1] == b"F" + + +def test_reentrant_sync_sender_does_not_wait_for_itself(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + connection.send_raw(b"E") + + monkeypatch.setattr( + "websockets.sync.client.connect", Mock(return_value=Mock(send=Mock(side_effect=send), close=ws.close)) + ) + with OpenAI(api_key="synthetic-only") as client: + manager = client.live.connect() + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + with manager as connection: + connection.send_raw(b"D") + assert ws.attempts == ["A", "B", "C", b"D", b"E"] + + +@pytest.mark.asyncio +async def test_reentrant_async_sender_does_not_wait_for_itself(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + await connection.send_raw(b"E") + + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock(return_value=Mock(send=AsyncMock(side_effect=send), close=AsyncMock(side_effect=ws.close))), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = client.live.connect() + for label in "ABC": + manager.send({"type": "session.update", "event_id": label, "session": {}}) + async with manager as connection: + await asyncio.wait_for(connection.send_raw(b"D"), timeout=3) + assert ws.attempts == ["A", "B", "C", b"D", b"E"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("ending", ["terminal-reader", "explicit-close"]) +def test_waiting_ordinary_sender_reports_terminal_unsent_frame( + name: str, ending: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + entered, release, queued = (threading.Event() for _ in range(3)) + terminal = close_error(1000) + write_error = OSError("older attempted B failed") + original_enqueue = SendQueue.enqueue + + def enqueue(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + admitted = original_enqueue(self, data, **kwargs) + if data == b"E": + queued.set() + return admitted + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + assert release.wait(5) + raise write_error + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise terminal + + monkeypatch.setattr(SendQueue, "enqueue", enqueue) + monkeypatch.setattr( + "websockets.sync.client.connect", + Mock(return_value=Mock(send=Mock(side_effect=send), recv=Mock(side_effect=recv), close=ws.close)), + ) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=2) as workers: + owner = workers.submit(connection.send_raw, b"D") + try: + assert entered.wait(3) + waiting = workers.submit(connection.send_raw, b"E") + assert queued.wait(3) + if ending == "terminal-reader": + with pytest.raises(WebSocketConnectionClosedError) as got: + next(iter(connection)) + assert got.value.__cause__ is terminal + assert [ + json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames + ] == ["C", b"D", b"E"] + else: + connection.close() + finally: + release.set() + # The active owner must keep the original send error. Its unattempted + # D frame still appears in the reader's unsent report. + with pytest.raises(OSError) as got: + owner.result(timeout=3) + assert got.value is write_error + with pytest.raises(WebSocketConnectionClosedError) as got: + waiting.result(timeout=3) + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames] == [ + "C", + b"D", + b"E", + ] + assert ws.attempts == ["A", "B"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +@pytest.mark.parametrize("ending", ["terminal-reader", "explicit-close"]) +async def test_async_waiting_ordinary_sender_reports_terminal_unsent_frame( + name: str, ending: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + entered, release, queued = (asyncio.Event() for _ in range(3)) + terminal = close_error(1000) + write_error = OSError("older attempted B failed") + original_enqueue = SendQueue.enqueue + + def enqueue(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + admitted = original_enqueue(self, data, **kwargs) + if data == b"E": + queued.set() + return admitted + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + await release.wait() + raise write_error + + def recv(**_kwargs: Any) -> bytes: + ws.closed = True + raise terminal + + monkeypatch.setattr(SendQueue, "enqueue", enqueue) + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock( + return_value=Mock( + send=AsyncMock(side_effect=send), + recv=AsyncMock(side_effect=recv), + close=AsyncMock(side_effect=ws.close), + ) + ), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect(**options(client, name)) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + try: + await asyncio.wait_for(entered.wait(), timeout=3) + waiting = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(queued.wait(), timeout=3) + if ending == "terminal-reader": + with pytest.raises(WebSocketConnectionClosedError) as got: + await anext(aiter(connection)) + assert got.value.__cause__ is terminal + assert [ + json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames + ] == ["C", b"D", b"E"] + else: + await connection.close() + finally: + release.set() + with pytest.raises(OSError) as got: + await owner + assert got.value is write_error + with pytest.raises(WebSocketConnectionClosedError) as got: + await waiting + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames] == [ + "C", + b"D", + b"E", + ] + assert ws.attempts == ["A", "B"] + + +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +def test_reader_owns_senders_deferred_during_recovery_takeover(name: str, monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + entered, release, queued, handshake, finish = (threading.Event() for _ in range(5)) + terminal = close_error(1001) + write_error = OSError("older attempted B failed") + original_enqueue = SendQueue.enqueue + dials = 0 + + def enqueue(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + admitted = original_enqueue(self, data, **kwargs) + if data == b"E": + queued.set() + return admitted + + def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + assert release.wait(5) + raise write_error + + def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal dials + dials += 1 + if dials == 1: + return Mock(send=Mock(side_effect=send), recv=Mock(side_effect=terminal), close=ws.close) + handshake.set() + assert finish.wait(5) + raise OSError("replacement setup failed") + + monkeypatch.setattr(SendQueue, "enqueue", enqueue) + monkeypatch.setattr("websockets.sync.client.connect", Mock(side_effect=dial)) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=lambda _: None, max_retries=1, initial_delay=0 + ) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=3) as workers: + owner = workers.submit(connection.send_raw, b"D") + try: + assert entered.wait(3) + waiting = workers.submit(connection.send_raw, b"E") + assert queued.wait(3) + reading = workers.submit(next, iter(connection)) + assert handshake.wait(3) + connection.send_raw(b"F") + release.set() + with pytest.raises(OSError) as got: + owner.result(timeout=3) + assert got.value is write_error + # Recovery owns E and F until its outcome; this cannot block + # the caller that may need to let recovery proceed. + waiting.result(timeout=3) + finally: + release.set() + finish.set() + with pytest.raises(WebSocketConnectionClosedError) as got: + reading.result(timeout=3) + assert got.value.__cause__ is terminal + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames] == [ + "C", + b"D", + b"E", + b"F", + ] + assert ws.attempts == ["A", "B"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", RECONNECTING_RESOURCES) +async def test_async_reader_owns_senders_deferred_during_recovery_takeover( + name: str, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = InitialWriteFails() + entered, release, queued, handshake, finish = (asyncio.Event() for _ in range(5)) + terminal = close_error(1001) + write_error = OSError("older attempted B failed") + original_enqueue = SendQueue.enqueue + dials = 0 + + def enqueue(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + admitted = original_enqueue(self, data, **kwargs) + if data == b"E": + queued.set() + return admitted + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + entered.set() + await release.wait() + raise write_error + + async def dial(*_args: Any, **_kwargs: Any) -> Any: + nonlocal dials + dials += 1 + if dials == 1: + return Mock( + send=AsyncMock(side_effect=send), + recv=AsyncMock(side_effect=terminal), + close=AsyncMock(side_effect=ws.close), + ) + handshake.set() + await finish.wait() + raise OSError("replacement setup failed") + + monkeypatch.setattr(SendQueue, "enqueue", enqueue) + monkeypatch.setattr("openai.lib._websocket._WebSocketConnect", AsyncMock(side_effect=dial)) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, name).connect( + **options(client, name), on_reconnecting=lambda _: None, max_retries=1, initial_delay=0 + ) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + try: + await asyncio.wait_for(entered.wait(), timeout=3) + waiting = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(queued.wait(), timeout=3) + reading = asyncio.create_task(anext(aiter(connection))) + await asyncio.wait_for(handshake.wait(), timeout=3) + await connection.send_raw(b"F") + release.set() + with pytest.raises(OSError) as got: + await owner + assert got.value is write_error + await asyncio.wait_for(waiting, timeout=3) + finally: + release.set() + finish.set() + with pytest.raises(WebSocketConnectionClosedError) as got: + await reading + assert got.value.__cause__ is terminal + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames] == [ + "C", + b"D", + b"E", + b"F", + ] + assert ws.attempts == ["A", "B"] + + +def test_closing_after_prepare_retains_unattempted_manager_and_waiting_frames(monkeypatch: pytest.MonkeyPatch) -> None: + ws = InitialWriteFails() + prepared, release, queued, closing, close_done = (threading.Event() for _ in range(5)) + original_flush = SendQueue.flush_sync + original_enqueue = SendQueue.enqueue + + def flush(self: SendQueue, send: Any, **kwargs: Any) -> None: + original_prepare = kwargs["prepare_send"] + + def prepare() -> Any: + result = original_prepare() + if ws.attempts == ["A", "B"] and not prepared.is_set(): + prepared.set() + assert release.wait(5) + return result + + kwargs["prepare_send"] = prepare + original_flush(self, send, **kwargs) + + def enqueue(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + admitted = original_enqueue(self, data, **kwargs) + if data == b"E": + queued.set() + return admitted + + def close(**_kwargs: Any) -> None: + closing.set() + assert close_done.wait(5) + ws.close() + + monkeypatch.setattr(SendQueue, "flush_sync", flush) + monkeypatch.setattr(SendQueue, "enqueue", enqueue) + terminal = close_error(1000) + monkeypatch.setattr( + "websockets.sync.client.connect", + Mock( + return_value=Mock( + send=Mock(side_effect=ws.send), + recv=Mock(side_effect=terminal), + close=Mock(side_effect=close), + ) + ), + ) + with OpenAI(api_key="synthetic-only") as client: + manager = resource(client, "realtime").connect(**options(client, "realtime")) + for label in "ABC": + manager.send(event(label)) + with manager as connection, ThreadPoolExecutor(max_workers=3) as workers: + owner = workers.submit(connection.send_raw, b"D") + try: + assert prepared.wait(3) + waiting = workers.submit(connection.send_raw, b"E") + assert queued.wait(3) + shutdown = workers.submit(connection.close) + assert closing.wait(3) + release.set() + with pytest.raises(WebSocketConnectionClosedError): + owner.result(timeout=3) + with pytest.raises(WebSocketConnectionClosedError): + waiting.result(timeout=3) + # The physical close has not completed. The prepared old socket + # remains writable, but no marked unsent frame can be started. + assert ws.attempts == ["A", "B"] + finally: + release.set() + close_done.set() + shutdown.result(timeout=3) + with pytest.raises(WebSocketConnectionClosedError) as got: + next(iter(connection)) + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames] == [ + "C", + b"D", + b"E", + ] + + +@pytest.mark.asyncio +async def test_async_close_with_successful_inflight_write_retains_waiting_frames( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ws = InitialWriteFails() + prepared, queued, closing, close_done = (asyncio.Event() for _ in range(4)) + # Async preparation and pop do not await. A sibling close can take over + # when B yields to the transport; successful B stays out of the report. + release_b = asyncio.Event() + original_enqueue = SendQueue.enqueue + + def enqueue(self: SendQueue, data: str | bytes, **kwargs: Any) -> bool: + admitted = original_enqueue(self, data, **kwargs) + if data == b"E": + queued.set() + return admitted + + async def send(data: str | bytes) -> None: + ws.send(data) + if ws.attempts[-1] == "B": + prepared.set() + await release_b.wait() + + async def close(**_kwargs: Any) -> None: + closing.set() + await close_done.wait() + ws.close() + + monkeypatch.setattr(SendQueue, "enqueue", enqueue) + terminal = close_error(1000) + monkeypatch.setattr( + "openai.lib._websocket._WebSocketConnect", + AsyncMock( + return_value=Mock( + send=AsyncMock(side_effect=send), + recv=AsyncMock(side_effect=terminal), + close=AsyncMock(side_effect=close), + ) + ), + ) + async with AsyncOpenAI(api_key="synthetic-only") as client: + manager = resource(client, "realtime").connect(**options(client, "realtime")) + for label in "ABC": + manager.send(event(label)) + async with manager as connection: + owner = asyncio.create_task(connection.send_raw(b"D")) + try: + await asyncio.wait_for(prepared.wait(), timeout=3) + waiting = asyncio.create_task(connection.send_raw(b"E")) + await asyncio.wait_for(queued.wait(), timeout=3) + shutdown = asyncio.create_task(connection.close()) + await asyncio.wait_for(closing.wait(), timeout=3) + release_b.set() + with pytest.raises(WebSocketConnectionClosedError): + await owner + with pytest.raises(WebSocketConnectionClosedError): + await waiting + assert ws.attempts == ["A", "B"] + finally: + release_b.set() + close_done.set() + await shutdown + with pytest.raises(WebSocketConnectionClosedError) as got: + await anext(aiter(connection)) + assert [json.loads(f)["event_id"] if isinstance(f, str) else f for f in got.value.unsent_frames] == [ + "C", + b"D", + b"E", + ]